update dashboard
This commit is contained in:
parent
a9482fa646
commit
4f35b7dad4
|
|
@ -1 +1 @@
|
|||
51557
|
||||
51754
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,14 +3,7 @@
|
|||
> vite
|
||||
|
||||
|
||||
VITE v6.4.3 ready in 130 ms
|
||||
VITE v6.4.3 ready in 192 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: use --host to expose
|
||||
5:37:52 PM [vite] (client) hmr update /src/index.css
|
||||
5:37:55 PM [vite] (client) hmr update /src/index.css
|
||||
5:37:58 PM [vite] (client) hmr update /src/index.css
|
||||
5:37:58 PM [vite] (client) hmr update /src/index.css
|
||||
5:39:34 PM [vite] (client) hmr update /src/index.css
|
||||
5:41:23 PM [vite] (client) hmr update /src/index.css
|
||||
5:42:28 PM [vite] (client) hmr update /src/index.css
|
||||
|
|
|
|||
|
|
@ -2,4 +2,8 @@
|
|||
> haixun-master@0.1.0 worker:style-8d
|
||||
> . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts
|
||||
|
||||
[8d-worker] started id=local-style-8d-node-51650 api=http://127.0.0.1:8890
|
||||
[8d-worker] started id=local-style-8d-node-51888 api=http://127.0.0.1:8890
|
||||
[8d-worker] loop error Error: internal server error
|
||||
at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
|
||||
at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
51572
|
||||
51805
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
51573
|
||||
51806
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ dev-8d: ## 一鍵啟動 API + Node 8D worker(前景,Ctrl+C 結束)
|
|||
CONFIG ?= etc/gateway.yaml
|
||||
INIT_TENANT ?= default
|
||||
INIT_EMAIL ?= admin@30cm.net
|
||||
INIT_PASSWORD ?= Fafafa54088!
|
||||
INIT_PASSWORD ?= Fafafa54088
|
||||
|
||||
tool-init: ## 初始化 Mongo indexes、預設權限與 admin 帳號
|
||||
$(GO) run ./cmd/tool init -f $(CONFIG) -tenant $(INIT_TENANT) -email $(INIT_EMAIL) -password '$(INIT_PASSWORD)'
|
||||
|
|
|
|||
|
|
@ -58,10 +58,28 @@ prod_service_health() {
|
|||
|| true
|
||||
}
|
||||
|
||||
prod_named_container_health() {
|
||||
local name="$1"
|
||||
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{if .State.Running}}running{{else}}stopped{{end}}{{end}}' \
|
||||
"$name" 2>/dev/null || true
|
||||
}
|
||||
|
||||
prod_named_container_running() {
|
||||
local name="$1"
|
||||
[[ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || echo false)" == "true" ]]
|
||||
}
|
||||
|
||||
prod_deps_healthy() {
|
||||
local mongo_ok redis_ok
|
||||
mongo_ok="$(prod_service_health mongo)"
|
||||
redis_ok="$(prod_service_health redis)"
|
||||
if [[ "$mongo_ok" == "healthy" && "$redis_ok" == "healthy" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# compose ps 偶發失敗時,改查實際 container(避免重複 create 撞名)
|
||||
mongo_ok="$(prod_named_container_health haixun-prod-mongo-1)"
|
||||
redis_ok="$(prod_named_container_health haixun-prod-redis-1)"
|
||||
[[ "$mongo_ok" == "healthy" && "$redis_ok" == "healthy" ]]
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +102,12 @@ prod_ensure_deps() {
|
|||
fi
|
||||
|
||||
echo "[prod] starting mongo + redis..."
|
||||
prod_compose up -d mongo redis
|
||||
if prod_named_container_running haixun-prod-mongo-1 && prod_named_container_running haixun-prod-redis-1; then
|
||||
prod_compose start mongo redis 2>/dev/null \
|
||||
|| prod_compose up -d --no-recreate mongo redis
|
||||
else
|
||||
prod_compose up -d mongo redis
|
||||
fi
|
||||
prod_wait_deps_healthy
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -26,7 +26,7 @@ import { PlacementTopicsPage } from './pages/PlacementTopicsPage'
|
|||
import { PersonaResearchPage } from './pages/PersonaResearchPage'
|
||||
import { PersonasPage } from './pages/PersonasPage'
|
||||
import { ProfilePage } from './pages/ProfilePage'
|
||||
import { RegisterPage } from './pages/RegisterPage'
|
||||
|
||||
import { SettingsPage } from './pages/SettingsPage'
|
||||
import { ThreadsAccountWorkspace } from './components/ThreadsAccountWorkspace'
|
||||
import { ThreadsAccountConnectionsPage } from './pages/ThreadsAccountConnectionsPage'
|
||||
|
|
@ -39,7 +39,7 @@ export default function App() {
|
|||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/register" element={<Navigate to="/login" replace />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { createPortal } from 'react-dom'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { SceneDecor } from './AuthDecor'
|
||||
import { AppBrandLink } from './AppBrandLink'
|
||||
|
|
@ -51,7 +52,9 @@ export function Layout() {
|
|||
|
||||
<MobileBottomNav />
|
||||
<JobMonitor />
|
||||
{islanderUnlocked ? <IslanderCompanion /> : null}
|
||||
{islanderUnlocked
|
||||
? createPortal(<IslanderCompanion />, document.body)
|
||||
: null}
|
||||
</div>
|
||||
</OnboardingProvider>
|
||||
</ThreadsAccountProvider>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { useState } from 'react'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { api, ApiError } from '../api/client'
|
||||
import { Button, ErrorText, Field, Input, Notice } from './ui'
|
||||
|
||||
type RegisterFormProps = {
|
||||
onBack?: () => void
|
||||
backLabel?: string
|
||||
onSuccess?: (email: string) => void
|
||||
}
|
||||
|
||||
export function RegisterForm({ onBack, backLabel = '返回登入', onSuccess }: RegisterFormProps) {
|
||||
const { tenantId } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [successEmail, setSuccessEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccessEmail('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/api/v1/auth/register', {
|
||||
tenant_id: tenantId,
|
||||
email,
|
||||
password,
|
||||
display_name: displayName,
|
||||
language: 'zh-TW',
|
||||
})
|
||||
const createdEmail = email
|
||||
setEmail('')
|
||||
setPassword('')
|
||||
setDisplayName('')
|
||||
setSuccessEmail(createdEmail)
|
||||
onSuccess?.(createdEmail)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '註冊失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-shell-body">
|
||||
<p className="auth-shell-lead text-ink-secondary">
|
||||
建立新帳號後,對方可用 Email 與密碼自行登入;你不會被切換登出。
|
||||
</p>
|
||||
|
||||
{successEmail ? (
|
||||
<Notice
|
||||
tone="success"
|
||||
title="帳號已建立"
|
||||
message={`${successEmail} 已建立完成,請通知對方至登入頁登入。`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<form className="auth-shell-form space-y-5" onSubmit={onSubmit}>
|
||||
<Field label="Email">
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Display Name">
|
||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Password(至少 8 字)">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<ErrorText message={error} />
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? '建立中…' : '建立帳號'}
|
||||
</Button>
|
||||
</form>
|
||||
{onBack ? (
|
||||
<p className="auth-shell-footer text-center text-ink-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold text-brand hover:underline"
|
||||
onClick={onBack}
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,60 +1,9 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import {
|
||||
appendIslanderUnlockKey,
|
||||
ISLANDER_UNLOCK_EVENT,
|
||||
readIslanderUnlocked,
|
||||
toggleIslanderUnlocked,
|
||||
subscribeIslanderUnlocked,
|
||||
} from '../lib/islander/unlock'
|
||||
|
||||
function shouldIgnoreKeydown(event: KeyboardEvent): boolean {
|
||||
if (event.isComposing) return true
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return true
|
||||
|
||||
const el = event.target
|
||||
if (!(el instanceof HTMLElement)) return false
|
||||
if (el.isContentEditable) return true
|
||||
if (el instanceof HTMLInputElement && el.type === 'password') return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function useIslanderUnlock() {
|
||||
const [unlocked, setUnlocked] = useState(readIslanderUnlocked)
|
||||
|
||||
useEffect(() => {
|
||||
const onUnlockChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ unlocked?: boolean }>).detail
|
||||
setUnlocked(typeof detail?.unlocked === 'boolean' ? detail.unlocked : readIslanderUnlocked())
|
||||
}
|
||||
|
||||
window.addEventListener(ISLANDER_UNLOCK_EVENT, onUnlockChange)
|
||||
|
||||
return () => window.removeEventListener(ISLANDER_UNLOCK_EVENT, onUnlockChange)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let buffer = ''
|
||||
let toggling = false
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (shouldIgnoreKeydown(event)) return
|
||||
|
||||
const { buffer: nextBuffer, matched } = appendIslanderUnlockKey(buffer, event.key)
|
||||
buffer = nextBuffer
|
||||
if (!matched || toggling) return
|
||||
|
||||
toggling = true
|
||||
const next = toggleIslanderUnlocked()
|
||||
setUnlocked(next)
|
||||
buffer = ''
|
||||
window.setTimeout(() => {
|
||||
toggling = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [])
|
||||
|
||||
return unlocked
|
||||
return useSyncExternalStore(subscribeIslanderUnlocked, readIslanderUnlocked, () => false)
|
||||
}
|
||||
|
|
@ -84,78 +84,78 @@
|
|||
--pocket-screen-height: min(50rem, calc(100dvh - 6.5rem));
|
||||
}
|
||||
|
||||
/* ══ Pokémon palette — dark(薄荷綠/灰藍) ══ */
|
||||
/* ══ Dark palette — 中性黑灰(非綠調) ══ */
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #141a17;
|
||||
--foreground: #f4f6f5;
|
||||
--card: #1b2721;
|
||||
--card-foreground: #f4f6f5;
|
||||
--popover: #1b2721;
|
||||
--popover-foreground: #f4f6f5;
|
||||
--primary: #bccdc4;
|
||||
--primary-foreground: #0a0a0a;
|
||||
--secondary: #7b94a3;
|
||||
--secondary-foreground: #0a0a0a;
|
||||
--muted: #253037;
|
||||
--muted-foreground: #98a9b3;
|
||||
--accent: #6983b0;
|
||||
--background: #0c0d10;
|
||||
--foreground: #e8eaef;
|
||||
--card: #15171c;
|
||||
--card-foreground: #e8eaef;
|
||||
--popover: #15171c;
|
||||
--popover-foreground: #e8eaef;
|
||||
--primary: #2a2f38;
|
||||
--primary-foreground: #e8eaef;
|
||||
--secondary: #5c6b7a;
|
||||
--secondary-foreground: #e8eaef;
|
||||
--muted: #1c1f26;
|
||||
--muted-foreground: #9aa3b2;
|
||||
--accent: #5b8fd4;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #2d4338;
|
||||
--input: #2d4338;
|
||||
--ring: #a8bdb3;
|
||||
--chart-1: #bdcdc5;
|
||||
--chart-2: #7b94a4;
|
||||
--border: #2a2f38;
|
||||
--input: #2a2f38;
|
||||
--ring: #4a5568;
|
||||
--chart-1: #5b8fd4;
|
||||
--chart-2: #7b8ea4;
|
||||
--chart-3: #4a628b;
|
||||
--chart-4: #bdcdc5;
|
||||
--chart-5: #7b94a4;
|
||||
--sidebar: #1b2721;
|
||||
--sidebar-foreground: #f4f6f5;
|
||||
--sidebar-primary: #bccdc4;
|
||||
--sidebar-primary-foreground: #0a0a0a;
|
||||
--sidebar-accent: #253037;
|
||||
--sidebar-accent-foreground: #f4f6f5;
|
||||
--sidebar-border: #2d4338;
|
||||
--sidebar-ring: #a8bdb3;
|
||||
--chart-4: #9aa3b2;
|
||||
--chart-5: #5c6b7a;
|
||||
--sidebar: #15171c;
|
||||
--sidebar-foreground: #e8eaef;
|
||||
--sidebar-primary: #2a2f38;
|
||||
--sidebar-primary-foreground: #e8eaef;
|
||||
--sidebar-accent: #1c1f26;
|
||||
--sidebar-accent-foreground: #e8eaef;
|
||||
--sidebar-border: #2a2f38;
|
||||
--sidebar-ring: #4a5568;
|
||||
|
||||
--hx-canvas: var(--background);
|
||||
--hx-canvas-grass: color-mix(in srgb, var(--muted) 68%, var(--background) 32%);
|
||||
--hx-canvas-grass: #111318;
|
||||
--hx-surface: var(--card);
|
||||
--hx-surface-muted: var(--muted);
|
||||
--hx-ink: var(--foreground);
|
||||
--hx-ink-secondary: #c5d0cc;
|
||||
--hx-ink-secondary: #b4bcc8;
|
||||
--hx-muted: var(--muted-foreground);
|
||||
--hx-subtle: #6f858f;
|
||||
--hx-wood: color-mix(in srgb, var(--border) 65%, var(--secondary) 35%);
|
||||
--hx-wood-dark: color-mix(in srgb, var(--accent) 38%, var(--background) 62%);
|
||||
--hx-wood-deep: #1a2a38;
|
||||
--hx-subtle: #6b7280;
|
||||
--hx-wood: color-mix(in srgb, var(--border) 72%, var(--secondary) 28%);
|
||||
--hx-wood-dark: color-mix(in srgb, var(--accent) 28%, var(--background) 72%);
|
||||
--hx-wood-deep: #141820;
|
||||
--hx-line: var(--border);
|
||||
--hx-brand: var(--accent);
|
||||
--hx-brand-hover: #7a94be;
|
||||
--hx-brand-shadow: #4e6890;
|
||||
--hx-brand-soft: color-mix(in srgb, var(--primary) 18%, var(--muted) 82%);
|
||||
--hx-brand-hover: #4a7ec4;
|
||||
--hx-brand-shadow: #3a5f96;
|
||||
--hx-brand-soft: color-mix(in srgb, var(--accent) 12%, var(--muted) 88%);
|
||||
--hx-on-brand: var(--accent-foreground);
|
||||
--hx-glow: color-mix(in srgb, var(--accent) 14%, var(--background) 86%);
|
||||
--hx-glow-alt: color-mix(in srgb, var(--muted) 52%, var(--background) 48%);
|
||||
--hx-glow: color-mix(in srgb, white 4%, var(--background) 96%);
|
||||
--hx-glow-alt: color-mix(in srgb, var(--muted) 55%, var(--background) 45%);
|
||||
--hx-accent: var(--secondary);
|
||||
--hx-accent-hover: #8fa8b8;
|
||||
--hx-accent-soft: color-mix(in srgb, var(--secondary) 14%, var(--muted) 86%);
|
||||
--hx-accent-hover: #7a8a9a;
|
||||
--hx-accent-soft: color-mix(in srgb, var(--secondary) 12%, var(--muted) 88%);
|
||||
--hx-device: var(--accent);
|
||||
--hx-device-dark: var(--hx-brand-shadow);
|
||||
--hx-success: #6a9a88;
|
||||
--hx-success-soft: color-mix(in srgb, var(--primary) 14%, var(--muted) 86%);
|
||||
--hx-success: #5a9a7a;
|
||||
--hx-success-soft: color-mix(in srgb, var(--hx-success) 12%, var(--muted) 88%);
|
||||
--hx-warning: #d4a84a;
|
||||
--hx-warning-soft: color-mix(in srgb, #d4a84a 14%, var(--muted) 86%);
|
||||
--hx-warning-soft: color-mix(in srgb, #d4a84a 12%, var(--muted) 88%);
|
||||
--hx-danger: var(--destructive);
|
||||
--hx-danger-soft: color-mix(in srgb, var(--destructive) 18%, var(--muted) 82%);
|
||||
--hx-shadow-soft: 0 8px 28px -8px rgb(0 0 0 / 0.42);
|
||||
--hx-shadow-card: 0 1px 2px rgb(0 0 0 / 0.22), 0 6px 20px -4px rgb(0 0 0 / 0.34);
|
||||
--hx-danger-soft: color-mix(in srgb, var(--destructive) 16%, var(--muted) 84%);
|
||||
--hx-shadow-soft: 0 8px 28px -8px rgb(0 0 0 / 0.48);
|
||||
--hx-shadow-card: 0 1px 2px rgb(0 0 0 / 0.28), 0 6px 20px -4px rgb(0 0 0 / 0.38);
|
||||
--hx-hero-gradient: linear-gradient(
|
||||
165deg,
|
||||
color-mix(in srgb, var(--accent) 14%, var(--card) 86%) 0%,
|
||||
color-mix(in srgb, var(--accent) 6%, var(--card) 94%) 0%,
|
||||
var(--card) 55%,
|
||||
var(--muted) 100%
|
||||
);
|
||||
|
|
@ -2500,9 +2500,9 @@ th {
|
|||
[data-theme="dark"] .hx-scene {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--hx-canvas) 92%, black 8%) 0%,
|
||||
var(--hx-canvas) 48%,
|
||||
color-mix(in srgb, var(--hx-canvas-grass) 88%, var(--hx-canvas) 12%) 100%
|
||||
#08090c 0%,
|
||||
var(--hx-canvas) 46%,
|
||||
var(--hx-canvas-grass) 100%
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2808,13 +2808,13 @@ th {
|
|||
}
|
||||
|
||||
[data-theme="dark"] .auth-scene-blob--sky {
|
||||
background: color-mix(in srgb, var(--hx-glow) 80%, var(--hx-accent) 20%);
|
||||
opacity: 0.28;
|
||||
background: color-mix(in srgb, white 5%, var(--hx-canvas) 95%);
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .auth-scene-blob--sky-alt {
|
||||
background: color-mix(in srgb, var(--hx-canvas-grass) 60%, var(--hx-glow) 40%);
|
||||
opacity: 0.22;
|
||||
background: color-mix(in srgb, var(--hx-muted) 35%, var(--hx-canvas) 65%);
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.auth-scene-blob--grass {
|
||||
|
|
@ -2893,7 +2893,8 @@ th {
|
|||
}
|
||||
|
||||
[data-theme="dark"] .auth-leaf {
|
||||
opacity: 0.22;
|
||||
color: var(--hx-muted);
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.auth-leaf--1 {
|
||||
|
|
@ -3417,6 +3418,19 @@ th {
|
|||
|
||||
[data-theme="dark"] .ac-dock {
|
||||
background: var(--hx-surface-muted);
|
||||
box-shadow: 0 -4px 16px rgb(0 0 0 / 0.32);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ac-sidebar {
|
||||
box-shadow: 4px 0 24px -12px rgb(0 0 0 / 0.36);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ac-pocket-device {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.08),
|
||||
inset 0 -2px 4px rgb(0 0 0 / 0.28),
|
||||
0 14px 36px -10px rgb(0 0 0 / 0.45),
|
||||
0 4px 14px rgb(0 0 0 / 0.28);
|
||||
}
|
||||
|
||||
.ac-dock-btn {
|
||||
|
|
@ -3856,12 +3870,12 @@ th {
|
|||
/* ── Islander companion (floating guide) ── */
|
||||
.ac-islander {
|
||||
position: fixed;
|
||||
z-index: 46;
|
||||
z-index: 60;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ac-islander--dragging {
|
||||
z-index: 56;
|
||||
z-index: 70;
|
||||
}
|
||||
|
||||
.ac-islander__anchor {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,6 @@ export type EasterEggEntry = {
|
|||
notes?: string
|
||||
}
|
||||
|
||||
/** 島民鍵盤密碼 — unlock.ts 與本目錄共用 */
|
||||
export const ISLANDER_UNLOCK_CODE = 'abababc'
|
||||
|
||||
/**
|
||||
* 巡樓彩蛋登錄簿(前端)。
|
||||
* 新增彩蛋時請同步更新此檔,管理員可在「彩蛋手冊」頁查閱。
|
||||
|
|
@ -22,16 +19,31 @@ export const easterEggCatalog: EasterEggEntry[] = [
|
|||
{
|
||||
id: 'islander-guide',
|
||||
title: '島民嚮導',
|
||||
password: ISLANDER_UNLOCK_CODE,
|
||||
kind: 'keyboard',
|
||||
summary: '預設隱藏右下角浮動島民。連續輸入密碼可開關,狀態僅存於目前分頁的 session。',
|
||||
password: null,
|
||||
kind: 'admin-page',
|
||||
summary: '預設隱藏右下角浮動島民。管理員在彩蛋手冊按同一顆按鈕開關,狀態存於目前分頁 session。',
|
||||
howTo: [
|
||||
'登入後,在任意已登入頁面(不需點特定區域)',
|
||||
'鍵盤連續輸入密碼(英文小寫,不需 Enter)',
|
||||
'右下角出現島民浮動按鈕;再輸入一次相同密碼可隱藏',
|
||||
'以 admin 角色登入,進入彩蛋手冊(本頁)',
|
||||
'在「島民嚮導」卡片點「開啟島民嚮導」',
|
||||
'右下角出現島民浮動按鈕;再點「關閉島民嚮導」即可隱藏',
|
||||
'重新整理分頁後,若 session 仍有效會維持顯示狀態',
|
||||
],
|
||||
notes: '密碼欄位與 IME 組字中不會觸發;一般搜尋框內輸入亦可解鎖。',
|
||||
notes: '開啟後可於任意已登入頁面使用島民對話。',
|
||||
},
|
||||
{
|
||||
id: 'hidden-register',
|
||||
title: '隱藏註冊',
|
||||
password: null,
|
||||
kind: 'admin-page',
|
||||
summary: '登入頁不顯示註冊入口。管理員在彩蛋手冊按同一顆按鈕開關註冊表單(無 /register 網址、無鍵盤暗號)。',
|
||||
howTo: [
|
||||
'以 admin 角色登入,進入彩蛋手冊(本頁)',
|
||||
'在「隱藏註冊」卡片點「開啟註冊表單」展開表單',
|
||||
'再點一次「關閉註冊表單」即可收起',
|
||||
'填寫 Email、顯示名稱與密碼後送出',
|
||||
'建立成功後維持目前管理員登入;新帳號需自行至登入頁登入',
|
||||
],
|
||||
notes: '直接造訪 /register 會導回登入頁。建立帳號不會自動登入新使用者。',
|
||||
},
|
||||
{
|
||||
id: 'easter-eggs-handbook',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { ISLANDER_UNLOCK_CODE } from '../easterEggCatalog'
|
||||
import { defaultIslanderPosition, ISLANDER_POSITION_KEY } from './useIslanderPosition'
|
||||
|
||||
export { ISLANDER_UNLOCK_CODE }
|
||||
export const ISLANDER_UNLOCK_KEY = 'haixun.islander.unlocked'
|
||||
export const ISLANDER_UNLOCK_EVENT = 'haixun.islander-unlock-changed'
|
||||
|
||||
|
|
@ -18,36 +17,27 @@ export function writeIslanderUnlocked(unlocked: boolean) {
|
|||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (unlocked) {
|
||||
try {
|
||||
localStorage.setItem(ISLANDER_POSITION_KEY, JSON.stringify(defaultIslanderPosition()))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ISLANDER_UNLOCK_EVENT, { detail: { unlocked } }),
|
||||
)
|
||||
}
|
||||
|
||||
export function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
const tag = target.tagName
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|
||||
}
|
||||
|
||||
export function toggleIslanderUnlocked(): boolean {
|
||||
const next = !readIslanderUnlocked()
|
||||
writeIslanderUnlocked(next)
|
||||
return next
|
||||
}
|
||||
|
||||
/** Append one key to the rolling buffer; returns true when the unlock code matches. */
|
||||
export function appendIslanderUnlockKey(buffer: string, key: string): { buffer: string; matched: boolean } {
|
||||
const code = ISLANDER_UNLOCK_CODE
|
||||
|
||||
if (key === 'Backspace') {
|
||||
return { buffer: buffer.slice(0, -1), matched: false }
|
||||
}
|
||||
|
||||
if (key.length !== 1) {
|
||||
return { buffer, matched: false }
|
||||
}
|
||||
|
||||
const next = (buffer + key.toLowerCase()).slice(-code.length)
|
||||
return { buffer: next, matched: next === code }
|
||||
export function subscribeIslanderUnlocked(onStoreChange: () => void) {
|
||||
window.addEventListener(ISLANDER_UNLOCK_EVENT, onStoreChange)
|
||||
return () => window.removeEventListener(ISLANDER_UNLOCK_EVENT, onStoreChange)
|
||||
}
|
||||
|
|
@ -1,11 +1,23 @@
|
|||
import { Badge, Card, CopyableId, Notice, PageTitle } from '../components/ui'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Badge, Button, Card, CopyableId, Notice, PageTitle } from '../components/ui'
|
||||
import { RegisterForm } from '../components/RegisterForm'
|
||||
import { useIslanderUnlock } from '../hooks/useIslanderUnlock'
|
||||
import { writeIslanderUnlocked } from '../lib/islander/unlock'
|
||||
import {
|
||||
easterEggCatalog,
|
||||
easterEggKindLabel,
|
||||
type EasterEggEntry,
|
||||
} from '../lib/easterEggCatalog'
|
||||
|
||||
function EasterEggCard({ entry }: { entry: EasterEggEntry }) {
|
||||
const ADMIN_TOGGLE_IDS = new Set(['hidden-register', 'islander-guide'])
|
||||
|
||||
function EasterEggCard({
|
||||
entry,
|
||||
action,
|
||||
}: {
|
||||
entry: EasterEggEntry
|
||||
action?: ReactNode
|
||||
}) {
|
||||
const kindTone =
|
||||
entry.kind === 'keyboard' ? 'brand' : entry.kind === 'admin-page' ? 'sky' : 'neutral'
|
||||
|
||||
|
|
@ -21,7 +33,7 @@ function EasterEggCard({ entry }: { entry: EasterEggEntry }) {
|
|||
|
||||
{entry.password ? (
|
||||
<CopyableId label="密碼(連續輸入)" value={entry.password} />
|
||||
) : (
|
||||
) : ADMIN_TOGGLE_IDS.has(entry.id) ? null : (
|
||||
<div className="ac-slot px-5 py-5">
|
||||
<div className="text-sm font-bold text-ink-secondary">密碼</div>
|
||||
<p className="mt-3 text-base text-muted">無 — 透過管理員導覽進入</p>
|
||||
|
|
@ -37,6 +49,8 @@ function EasterEggCard({ entry }: { entry: EasterEggEntry }) {
|
|||
</ol>
|
||||
</div>
|
||||
|
||||
{action ? <div className="flex flex-col gap-4">{action}</div> : null}
|
||||
|
||||
{entry.notes ? (
|
||||
<p className="rounded-[var(--radius-md)] border border-line bg-surface-muted px-4 py-3 text-sm leading-relaxed text-ink-secondary">
|
||||
<span className="font-bold text-ink">備註:</span>
|
||||
|
|
@ -47,7 +61,63 @@ function EasterEggCard({ entry }: { entry: EasterEggEntry }) {
|
|||
)
|
||||
}
|
||||
|
||||
function renderEasterEggAction(
|
||||
entry: EasterEggEntry,
|
||||
state: {
|
||||
registerOpen: boolean
|
||||
setRegisterOpen: (open: boolean | ((value: boolean) => boolean)) => void
|
||||
islanderOpen: boolean
|
||||
},
|
||||
) {
|
||||
if (entry.id === 'hidden-register') {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={state.registerOpen ? 'soft' : 'primary'}
|
||||
className="self-start"
|
||||
onClick={() => state.setRegisterOpen((open) => !open)}
|
||||
>
|
||||
{state.registerOpen ? '關閉註冊表單' : '開啟註冊表單'}
|
||||
</Button>
|
||||
{state.registerOpen ? (
|
||||
<div className="rounded-[var(--radius-lg)] border border-line bg-surface-muted p-5">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (entry.id === 'islander-guide') {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={state.islanderOpen ? 'soft' : 'primary'}
|
||||
className="self-start"
|
||||
onClick={() => writeIslanderUnlocked(!state.islanderOpen)}
|
||||
>
|
||||
{state.islanderOpen ? '關閉島民嚮導' : '開啟島民嚮導'}
|
||||
</Button>
|
||||
{state.islanderOpen ? (
|
||||
<Notice
|
||||
tone="success"
|
||||
title="島民已開啟"
|
||||
message="請看畫面右下角,會出現島民浮動按鈕(任務觀察站在左下角,島民在右側)。"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function EasterEggsPage() {
|
||||
const [registerOpen, setRegisterOpen] = useState(false)
|
||||
const islanderOpen = useIslanderUnlock()
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<PageTitle
|
||||
|
|
@ -63,7 +133,15 @@ export function EasterEggsPage() {
|
|||
|
||||
<div className="mt-6 flex flex-col gap-5">
|
||||
{easterEggCatalog.map((entry) => (
|
||||
<EasterEggCard key={entry.id} entry={entry} />
|
||||
<EasterEggCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
action={renderEasterEggAction(entry, {
|
||||
registerOpen,
|
||||
setRegisterOpen,
|
||||
islanderOpen,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { ApiError } from '../api/client'
|
||||
import { AuthShell } from '../components/AuthShell'
|
||||
|
|
@ -56,12 +56,6 @@ export function LoginPage() {
|
|||
{loading ? '登入中…' : '登入'}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="auth-shell-footer text-center text-ink-secondary">
|
||||
還沒有帳號?{' '}
|
||||
<Link className="font-semibold text-brand hover:underline" to="/register">
|
||||
前往註冊
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { ApiError } from '../api/client'
|
||||
import { AuthShell } from '../components/AuthShell'
|
||||
import { Button, ErrorText, Field, Input } from '../components/ui'
|
||||
|
||||
export function RegisterPage() {
|
||||
const { register, setTenantId, isAuthenticated } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setTenantId('default')
|
||||
}, [setTenantId])
|
||||
|
||||
if (isAuthenticated) return <Navigate to="/" replace />
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await register({ email, password, displayName, language: 'zh-TW' })
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '註冊失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell tagline="新夥伴加入!">
|
||||
<div className="auth-shell-body">
|
||||
<p className="auth-shell-lead text-ink-secondary">
|
||||
建立帳號後,就能一起打理這座<span className="font-bold text-brand">巡樓小島</span>。
|
||||
</p>
|
||||
<form className="auth-shell-form space-y-5" onSubmit={onSubmit}>
|
||||
<Field label="Email">
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Display Name">
|
||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Password(至少 8 字)">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<ErrorText message={error} />
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? '註冊中…' : '建立帳號並登入'}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="auth-shell-footer text-center text-ink-secondary">
|
||||
已有帳號?{' '}
|
||||
<Link className="font-semibold text-brand hover:underline" to="/login">
|
||||
前往登入
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue