update dashboard

This commit is contained in:
王性驊 2026-06-25 22:42:25 +08:00
parent a9482fa646
commit 4f35b7dad4
19 changed files with 636 additions and 2060 deletions

View File

@ -1 +1 @@
51557 51754

File diff suppressed because it is too large Load Diff

View File

@ -3,14 +3,7 @@
> vite > vite
VITE v6.4.3 ready in 130 ms VITE v6.4.3 ready in 192 ms
➜ Local: http://localhost:5173/ ➜ Local: http://localhost:5173/
➜ Network: use --host to expose ➜ 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

View File

@ -2,4 +2,8 @@
> haixun-master@0.1.0 worker:style-8d > haixun-master@0.1.0 worker:style-8d
> . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts > . 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)

View File

@ -1 +1 @@
51572 51805

View File

@ -1 +1 @@
51573 51806

View File

@ -51,7 +51,7 @@ dev-8d: ## 一鍵啟動 API + Node 8D worker前景Ctrl+C 結束)
CONFIG ?= etc/gateway.yaml CONFIG ?= etc/gateway.yaml
INIT_TENANT ?= default INIT_TENANT ?= default
INIT_EMAIL ?= admin@30cm.net INIT_EMAIL ?= admin@30cm.net
INIT_PASSWORD ?= Fafafa54088! INIT_PASSWORD ?= Fafafa54088
tool-init: ## 初始化 Mongo indexes、預設權限與 admin 帳號 tool-init: ## 初始化 Mongo indexes、預設權限與 admin 帳號
$(GO) run ./cmd/tool init -f $(CONFIG) -tenant $(INIT_TENANT) -email $(INIT_EMAIL) -password '$(INIT_PASSWORD)' $(GO) run ./cmd/tool init -f $(CONFIG) -tenant $(INIT_TENANT) -email $(INIT_EMAIL) -password '$(INIT_PASSWORD)'

View File

@ -58,10 +58,28 @@ prod_service_health() {
|| true || 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() { prod_deps_healthy() {
local mongo_ok redis_ok local mongo_ok redis_ok
mongo_ok="$(prod_service_health mongo)" mongo_ok="$(prod_service_health mongo)"
redis_ok="$(prod_service_health redis)" 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" ]] [[ "$mongo_ok" == "healthy" && "$redis_ok" == "healthy" ]]
} }
@ -84,7 +102,12 @@ prod_ensure_deps() {
fi fi
echo "[prod] starting mongo + redis..." echo "[prod] starting 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 prod_compose up -d mongo redis
fi
prod_wait_deps_healthy prod_wait_deps_healthy
} }

View File

@ -26,7 +26,7 @@ import { PlacementTopicsPage } from './pages/PlacementTopicsPage'
import { PersonaResearchPage } from './pages/PersonaResearchPage' import { PersonaResearchPage } from './pages/PersonaResearchPage'
import { PersonasPage } from './pages/PersonasPage' import { PersonasPage } from './pages/PersonasPage'
import { ProfilePage } from './pages/ProfilePage' import { ProfilePage } from './pages/ProfilePage'
import { RegisterPage } from './pages/RegisterPage'
import { SettingsPage } from './pages/SettingsPage' import { SettingsPage } from './pages/SettingsPage'
import { ThreadsAccountWorkspace } from './components/ThreadsAccountWorkspace' import { ThreadsAccountWorkspace } from './components/ThreadsAccountWorkspace'
import { ThreadsAccountConnectionsPage } from './pages/ThreadsAccountConnectionsPage' import { ThreadsAccountConnectionsPage } from './pages/ThreadsAccountConnectionsPage'
@ -39,7 +39,7 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<Navigate to="/login" replace />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />

View File

@ -1,3 +1,4 @@
import { createPortal } from 'react-dom'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { SceneDecor } from './AuthDecor' import { SceneDecor } from './AuthDecor'
import { AppBrandLink } from './AppBrandLink' import { AppBrandLink } from './AppBrandLink'
@ -51,7 +52,9 @@ export function Layout() {
<MobileBottomNav /> <MobileBottomNav />
<JobMonitor /> <JobMonitor />
{islanderUnlocked ? <IslanderCompanion /> : null} {islanderUnlocked
? createPortal(<IslanderCompanion />, document.body)
: null}
</div> </div>
</OnboardingProvider> </OnboardingProvider>
</ThreadsAccountProvider> </ThreadsAccountProvider>

View File

@ -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>
)
}

View File

@ -1,60 +1,9 @@
import { useEffect, useState } from 'react' import { useSyncExternalStore } from 'react'
import { import {
appendIslanderUnlockKey,
ISLANDER_UNLOCK_EVENT,
readIslanderUnlocked, readIslanderUnlocked,
toggleIslanderUnlocked, subscribeIslanderUnlocked,
} from '../lib/islander/unlock' } 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() { export function useIslanderUnlock() {
const [unlocked, setUnlocked] = useState(readIslanderUnlocked) return useSyncExternalStore(subscribeIslanderUnlocked, readIslanderUnlocked, () => false)
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
} }

View File

@ -84,78 +84,78 @@
--pocket-screen-height: min(50rem, calc(100dvh - 6.5rem)); --pocket-screen-height: min(50rem, calc(100dvh - 6.5rem));
} }
/* ══ Pokémon palette — dark薄荷綠灰藍 ══ */ /* ══ Dark palette — 中性黑灰(非綠調 ══ */
[data-theme="dark"] { [data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
--background: #141a17; --background: #0c0d10;
--foreground: #f4f6f5; --foreground: #e8eaef;
--card: #1b2721; --card: #15171c;
--card-foreground: #f4f6f5; --card-foreground: #e8eaef;
--popover: #1b2721; --popover: #15171c;
--popover-foreground: #f4f6f5; --popover-foreground: #e8eaef;
--primary: #bccdc4; --primary: #2a2f38;
--primary-foreground: #0a0a0a; --primary-foreground: #e8eaef;
--secondary: #7b94a3; --secondary: #5c6b7a;
--secondary-foreground: #0a0a0a; --secondary-foreground: #e8eaef;
--muted: #253037; --muted: #1c1f26;
--muted-foreground: #98a9b3; --muted-foreground: #9aa3b2;
--accent: #6983b0; --accent: #5b8fd4;
--accent-foreground: #ffffff; --accent-foreground: #ffffff;
--destructive: #dc2626; --destructive: #dc2626;
--destructive-foreground: #ffffff; --destructive-foreground: #ffffff;
--border: #2d4338; --border: #2a2f38;
--input: #2d4338; --input: #2a2f38;
--ring: #a8bdb3; --ring: #4a5568;
--chart-1: #bdcdc5; --chart-1: #5b8fd4;
--chart-2: #7b94a4; --chart-2: #7b8ea4;
--chart-3: #4a628b; --chart-3: #4a628b;
--chart-4: #bdcdc5; --chart-4: #9aa3b2;
--chart-5: #7b94a4; --chart-5: #5c6b7a;
--sidebar: #1b2721; --sidebar: #15171c;
--sidebar-foreground: #f4f6f5; --sidebar-foreground: #e8eaef;
--sidebar-primary: #bccdc4; --sidebar-primary: #2a2f38;
--sidebar-primary-foreground: #0a0a0a; --sidebar-primary-foreground: #e8eaef;
--sidebar-accent: #253037; --sidebar-accent: #1c1f26;
--sidebar-accent-foreground: #f4f6f5; --sidebar-accent-foreground: #e8eaef;
--sidebar-border: #2d4338; --sidebar-border: #2a2f38;
--sidebar-ring: #a8bdb3; --sidebar-ring: #4a5568;
--hx-canvas: var(--background); --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: var(--card);
--hx-surface-muted: var(--muted); --hx-surface-muted: var(--muted);
--hx-ink: var(--foreground); --hx-ink: var(--foreground);
--hx-ink-secondary: #c5d0cc; --hx-ink-secondary: #b4bcc8;
--hx-muted: var(--muted-foreground); --hx-muted: var(--muted-foreground);
--hx-subtle: #6f858f; --hx-subtle: #6b7280;
--hx-wood: color-mix(in srgb, var(--border) 65%, var(--secondary) 35%); --hx-wood: color-mix(in srgb, var(--border) 72%, var(--secondary) 28%);
--hx-wood-dark: color-mix(in srgb, var(--accent) 38%, var(--background) 62%); --hx-wood-dark: color-mix(in srgb, var(--accent) 28%, var(--background) 72%);
--hx-wood-deep: #1a2a38; --hx-wood-deep: #141820;
--hx-line: var(--border); --hx-line: var(--border);
--hx-brand: var(--accent); --hx-brand: var(--accent);
--hx-brand-hover: #7a94be; --hx-brand-hover: #4a7ec4;
--hx-brand-shadow: #4e6890; --hx-brand-shadow: #3a5f96;
--hx-brand-soft: color-mix(in srgb, var(--primary) 18%, var(--muted) 82%); --hx-brand-soft: color-mix(in srgb, var(--accent) 12%, var(--muted) 88%);
--hx-on-brand: var(--accent-foreground); --hx-on-brand: var(--accent-foreground);
--hx-glow: color-mix(in srgb, var(--accent) 14%, var(--background) 86%); --hx-glow: color-mix(in srgb, white 4%, var(--background) 96%);
--hx-glow-alt: color-mix(in srgb, var(--muted) 52%, var(--background) 48%); --hx-glow-alt: color-mix(in srgb, var(--muted) 55%, var(--background) 45%);
--hx-accent: var(--secondary); --hx-accent: var(--secondary);
--hx-accent-hover: #8fa8b8; --hx-accent-hover: #7a8a9a;
--hx-accent-soft: color-mix(in srgb, var(--secondary) 14%, var(--muted) 86%); --hx-accent-soft: color-mix(in srgb, var(--secondary) 12%, var(--muted) 88%);
--hx-device: var(--accent); --hx-device: var(--accent);
--hx-device-dark: var(--hx-brand-shadow); --hx-device-dark: var(--hx-brand-shadow);
--hx-success: #6a9a88; --hx-success: #5a9a7a;
--hx-success-soft: color-mix(in srgb, var(--primary) 14%, var(--muted) 86%); --hx-success-soft: color-mix(in srgb, var(--hx-success) 12%, var(--muted) 88%);
--hx-warning: #d4a84a; --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: var(--destructive);
--hx-danger-soft: color-mix(in srgb, var(--destructive) 18%, var(--muted) 82%); --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.42); --hx-shadow-soft: 0 8px 28px -8px rgb(0 0 0 / 0.48);
--hx-shadow-card: 0 1px 2px rgb(0 0 0 / 0.22), 0 6px 20px -4px rgb(0 0 0 / 0.34); --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( --hx-hero-gradient: linear-gradient(
165deg, 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(--card) 55%,
var(--muted) 100% var(--muted) 100%
); );
@ -2500,9 +2500,9 @@ th {
[data-theme="dark"] .hx-scene { [data-theme="dark"] .hx-scene {
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
color-mix(in srgb, var(--hx-canvas) 92%, black 8%) 0%, #08090c 0%,
var(--hx-canvas) 48%, var(--hx-canvas) 46%,
color-mix(in srgb, var(--hx-canvas-grass) 88%, var(--hx-canvas) 12%) 100% var(--hx-canvas-grass) 100%
); );
} }
@ -2808,13 +2808,13 @@ th {
} }
[data-theme="dark"] .auth-scene-blob--sky { [data-theme="dark"] .auth-scene-blob--sky {
background: color-mix(in srgb, var(--hx-glow) 80%, var(--hx-accent) 20%); background: color-mix(in srgb, white 5%, var(--hx-canvas) 95%);
opacity: 0.28; opacity: 0.32;
} }
[data-theme="dark"] .auth-scene-blob--sky-alt { [data-theme="dark"] .auth-scene-blob--sky-alt {
background: color-mix(in srgb, var(--hx-canvas-grass) 60%, var(--hx-glow) 40%); background: color-mix(in srgb, var(--hx-muted) 35%, var(--hx-canvas) 65%);
opacity: 0.22; opacity: 0.18;
} }
.auth-scene-blob--grass { .auth-scene-blob--grass {
@ -2893,7 +2893,8 @@ th {
} }
[data-theme="dark"] .auth-leaf { [data-theme="dark"] .auth-leaf {
opacity: 0.22; color: var(--hx-muted);
opacity: 0.18;
} }
.auth-leaf--1 { .auth-leaf--1 {
@ -3417,6 +3418,19 @@ th {
[data-theme="dark"] .ac-dock { [data-theme="dark"] .ac-dock {
background: var(--hx-surface-muted); 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 { .ac-dock-btn {
@ -3856,12 +3870,12 @@ th {
/* ── Islander companion (floating guide) ── */ /* ── Islander companion (floating guide) ── */
.ac-islander { .ac-islander {
position: fixed; position: fixed;
z-index: 46; z-index: 60;
pointer-events: none; pointer-events: none;
} }
.ac-islander--dragging { .ac-islander--dragging {
z-index: 56; z-index: 70;
} }
.ac-islander__anchor { .ac-islander__anchor {

View File

@ -11,9 +11,6 @@ export type EasterEggEntry = {
notes?: string notes?: string
} }
/** 島民鍵盤密碼 — unlock.ts 與本目錄共用 */
export const ISLANDER_UNLOCK_CODE = 'abababc'
/** /**
* 簿 * 簿
* *
@ -22,16 +19,31 @@ export const easterEggCatalog: EasterEggEntry[] = [
{ {
id: 'islander-guide', id: 'islander-guide',
title: '島民嚮導', title: '島民嚮導',
password: ISLANDER_UNLOCK_CODE, password: null,
kind: 'keyboard', kind: 'admin-page',
summary: '預設隱藏右下角浮動島民。連續輸入密碼可開關,狀態僅存於目前分頁的 session。', summary: '預設隱藏右下角浮動島民。管理員在彩蛋手冊按同一顆按鈕開關,狀態存於目前分頁 session。',
howTo: [ howTo: [
'登入後,在任意已登入頁面(不需點特定區域', '以 admin 角色登入,進入彩蛋手冊(本頁',
'鍵盤連續輸入密碼(英文小寫,不需 Enter', '在「島民嚮導」卡片點「開啟島民嚮導」',
'右下角出現島民浮動按鈕;再輸入一次相同密碼可隱藏', '右下角出現島民浮動按鈕;再點「關閉島民嚮導」即可隱藏',
'重新整理分頁後,若 session 仍有效會維持顯示狀態', '重新整理分頁後,若 session 仍有效會維持顯示狀態',
], ],
notes: '密碼欄位與 IME 組字中不會觸發;一般搜尋框內輸入亦可解鎖。', notes: '開啟後可於任意已登入頁面使用島民對話。',
},
{
id: 'hidden-register',
title: '隱藏註冊',
password: null,
kind: 'admin-page',
summary: '登入頁不顯示註冊入口。管理員在彩蛋手冊按同一顆按鈕開關註冊表單(無 /register 網址、無鍵盤暗號)。',
howTo: [
'以 admin 角色登入,進入彩蛋手冊(本頁)',
'在「隱藏註冊」卡片點「開啟註冊表單」展開表單',
'再點一次「關閉註冊表單」即可收起',
'填寫 Email、顯示名稱與密碼後送出',
'建立成功後維持目前管理員登入;新帳號需自行至登入頁登入',
],
notes: '直接造訪 /register 會導回登入頁。建立帳號不會自動登入新使用者。',
}, },
{ {
id: 'easter-eggs-handbook', id: 'easter-eggs-handbook',

View File

@ -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_KEY = 'haixun.islander.unlocked'
export const ISLANDER_UNLOCK_EVENT = 'haixun.islander-unlock-changed' export const ISLANDER_UNLOCK_EVENT = 'haixun.islander-unlock-changed'
@ -18,36 +17,27 @@ export function writeIslanderUnlocked(unlocked: boolean) {
} catch { } catch {
// ignore // ignore
} }
if (unlocked) {
try {
localStorage.setItem(ISLANDER_POSITION_KEY, JSON.stringify(defaultIslanderPosition()))
} catch {
// ignore
}
}
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(ISLANDER_UNLOCK_EVENT, { detail: { unlocked } }), 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 { export function toggleIslanderUnlocked(): boolean {
const next = !readIslanderUnlocked() const next = !readIslanderUnlocked()
writeIslanderUnlocked(next) writeIslanderUnlocked(next)
return next return next
} }
/** Append one key to the rolling buffer; returns true when the unlock code matches. */ export function subscribeIslanderUnlocked(onStoreChange: () => void) {
export function appendIslanderUnlockKey(buffer: string, key: string): { buffer: string; matched: boolean } { window.addEventListener(ISLANDER_UNLOCK_EVENT, onStoreChange)
const code = ISLANDER_UNLOCK_CODE return () => window.removeEventListener(ISLANDER_UNLOCK_EVENT, onStoreChange)
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 }
} }

View File

@ -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 { import {
easterEggCatalog, easterEggCatalog,
easterEggKindLabel, easterEggKindLabel,
type EasterEggEntry, type EasterEggEntry,
} from '../lib/easterEggCatalog' } 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 = const kindTone =
entry.kind === 'keyboard' ? 'brand' : entry.kind === 'admin-page' ? 'sky' : 'neutral' entry.kind === 'keyboard' ? 'brand' : entry.kind === 'admin-page' ? 'sky' : 'neutral'
@ -21,7 +33,7 @@ function EasterEggCard({ entry }: { entry: EasterEggEntry }) {
{entry.password ? ( {entry.password ? (
<CopyableId label="密碼(連續輸入)" value={entry.password} /> <CopyableId label="密碼(連續輸入)" value={entry.password} />
) : ( ) : ADMIN_TOGGLE_IDS.has(entry.id) ? null : (
<div className="ac-slot px-5 py-5"> <div className="ac-slot px-5 py-5">
<div className="text-sm font-bold text-ink-secondary"></div> <div className="text-sm font-bold text-ink-secondary"></div>
<p className="mt-3 text-base text-muted"> </p> <p className="mt-3 text-base text-muted"> </p>
@ -37,6 +49,8 @@ function EasterEggCard({ entry }: { entry: EasterEggEntry }) {
</ol> </ol>
</div> </div>
{action ? <div className="flex flex-col gap-4">{action}</div> : null}
{entry.notes ? ( {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"> <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> <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() { export function EasterEggsPage() {
const [registerOpen, setRegisterOpen] = useState(false)
const islanderOpen = useIslanderUnlock()
return ( return (
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-3xl">
<PageTitle <PageTitle
@ -63,7 +133,15 @@ export function EasterEggsPage() {
<div className="mt-6 flex flex-col gap-5"> <div className="mt-6 flex flex-col gap-5">
{easterEggCatalog.map((entry) => ( {easterEggCatalog.map((entry) => (
<EasterEggCard key={entry.id} entry={entry} /> <EasterEggCard
key={entry.id}
entry={entry}
action={renderEasterEggAction(entry, {
registerOpen,
setRegisterOpen,
islanderOpen,
})}
/>
))} ))}
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' 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 { useAuth } from '../auth/AuthContext'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { AuthShell } from '../components/AuthShell' import { AuthShell } from '../components/AuthShell'
@ -56,12 +56,6 @@ export function LoginPage() {
{loading ? '登入中…' : '登入'} {loading ? '登入中…' : '登入'}
</Button> </Button>
</form> </form>
<p className="auth-shell-footer text-center text-ink-secondary">
{' '}
<Link className="font-semibold text-brand hover:underline" to="/register">
</Link>
</p>
</div> </div>
</AuthShell> </AuthShell>
) )

View File

@ -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>
)
}