diff --git a/.env.setup.example b/.env.setup.example new file mode 100644 index 0000000..f817838 --- /dev/null +++ b/.env.setup.example @@ -0,0 +1,11 @@ +# Setup wizard sandbox — copy to .env.setup.local (gitignored) +# Use a SEPARATE Supabase project from your main .env.local dev/deploy DB. + +NEXT_PUBLIC_SUPABASE_URL=https://YOUR_SANDBOX_PROJECT.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_sandbox_anon_key +SUPABASE_SERVICE_ROLE_KEY=your_sandbox_service_role_key +POSTGRES_URL_NON_POOLING=postgresql://postgres.YOUR_SANDBOX_REF:YOUR_PASSWORD@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres + +# Optional for local setup testing +NEXT_PUBLIC_APP_URL=http://localhost:3000 +SCHOOL_AUTH_DOMAIN=localhost diff --git a/.gitignore b/.gitignore index 7f39dcb..44cc803 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env.local .env +.env.setup.local # Firebase Service Account Keys (NEVER commit these!) *-firebase-adminsdk-*.json diff --git a/README.md b/README.md index a8c0e89..9ad9759 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,30 @@ bun dev # http://localhost:3000 | `bun run gen:students` / `import:students` | สร้าง/นำเข้ารายชื่อนักเรียนจาก CSV | | `bun run test:login` | ทดสอบ flow ล็อกอินนักเรียน | +### ทดสอบ Setup Wizard แบบ Sandbox (ไม่กระทบ `.env.local`) + +ใช้ **Supabase project แยก** สำหรับลอง wizard ซ้ำๆ โดยไม่ต้อง redeploy และไม่ต้องเปลี่ยน env หลักที่เชื่อม deploy อยู่แล้ว + +```bash +# 1) สร้างโปรเจกต์ Supabase ฟรีอีกตัว (เช่น found-u-setup-sandbox) +cp .env.setup.example .env.setup.local +# แก้ .env.setup.local ใส่ URL / keys / POSTGRES ของ sandbox + +# 2) รีเซ็ตสถานะ wizard (ทำซ้ำได้ทุกครั้งหลังทดสอบ) +bun run setup:reset + +# 3) รัน dev ด้วย sandbox env (override .env.local ชั่วคราว) +bun run dev:setup +# เปิด http://localhost:3000/setup +``` + +| คำสั่ง | ใช้ทำอะไร | +|--------|-----------| +| `bun run dev:setup` | `next dev` โดยโหลด `.env.setup.local` | +| `bun run setup:reset` | รีเซ็ต `setup_status`, branding, AI config, แอดมินที่สร้างจาก wizard | + +DB ว่างครั้งแรก: เปิด `/setup` แล้ว hydrator จะรัน migration อัตโนมัติ (เหมือน production) — ไม่ต้อง `db:push` ถ้าใช้ sandbox ใหม่เปล่าๆ + ## Deploy ให้โรงเรียนใหม่ แนวทางที่แนะนำ: **Clone + Deploy ก่อน** ให้ใช้งานได้จริง แล้วค่อย **Fork** ทีหลังถ้าต้องการ sync อัปเดตจากโค้ดหลัก diff --git a/app/(app)/home/page.tsx b/app/(app)/home/page.tsx index bd49873..d578fef 100644 --- a/app/(app)/home/page.tsx +++ b/app/(app)/home/page.tsx @@ -49,8 +49,10 @@ function UserNameSlot({ } function HomeQuickMenu({ className }: { className?: string }) { + // Always single column — this menu is mobile-only; a 2-col grid on + // misreported viewports (≥640px CSS) is what makes phones look "squeezed". return ( -
+
{menuItems .filter((m) => m.href !== "/home") .map((item) => { @@ -59,20 +61,20 @@ function HomeQuickMenu({ className }: { className?: string }) {
- +
-

+

{item.title}

{item.subtitle}

@@ -136,7 +138,7 @@ export default function Home() { return (
{/* Mobile header — switcher inside the green band */} -
-
+
+
@@ -302,12 +304,12 @@ export default function Home() {
-
+
diff --git a/app/admin/ai/models/page.tsx b/app/admin/ai/models/page.tsx index 86064a0..4643ff2 100644 --- a/app/admin/ai/models/page.tsx +++ b/app/admin/ai/models/page.tsx @@ -1,148 +1,30 @@ "use client"; -// Force dynamic rendering for security export const dynamic = "force-dynamic"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { ArrowLeft, Bot, - RefreshCw, Save, Loader2, CheckCircle2, AlertTriangle, - Search, - Activity, } from "lucide-react"; import { useAuth } from "@/contexts/auth-context"; import { getAppSettingsWithMeta, updateAppSettings } from "@/lib/database"; import { pickSettingsKeys, GEMINI_PIPELINE_SETTING_KEYS } from "@/lib/admin/ai-settings-keys"; import { DEFAULT_APP_SETTINGS, type AppSettings } from "@/lib/types"; -interface ModelInfo { - name: string; - displayName?: string; - description?: string; - supportedGenerationMethods?: string[]; -} - -function normalizeModelName(model: string) { - return model.replace(/^models\//, ""); -} - function parseNumber(value: string) { if (value.trim() === "") return undefined; const parsed = Number(value); return Number.isNaN(parsed) ? undefined : parsed; } -type ProviderName = "gemini" | "openrouter"; - -type ProviderTestState = { - testing: boolean; - result: string | null; -}; - -function formatProviderResult( - name: string, - info: { - configured: boolean; - ok: boolean; - model?: string; - error?: string; - } -): string { - const modelSuffix = info.model ? ` [${info.model}]` : ""; - if (info.ok) return `${name}${modelSuffix}: OK`; - if (!info.configured) return `${name}: no key`; - return `${name}${modelSuffix}: ${info.error || "fail"}`; -} - -function ProviderTestButton({ - label, - provider, - settings, - className, -}: { - label: string; - provider?: ProviderName; - settings: AppSettings; - className?: string; -}) { - const [state, setState] = useState({ - testing: false, - result: null, - }); - - const runTest = async () => { - setState({ testing: true, result: null }); - try { - const res = await fetch("/api/agent/test-providers", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ settings, provider }), - }); - const data = await res.json(); - const lines = Object.entries(data.providers || {}).map(([name, info]) => - formatProviderResult(name, info as Parameters[1]) - ); - setState({ testing: false, result: lines.join(" · ") }); - } catch { - setState({ testing: false, result: "ทดสอบไม่สำเร็จ" }); - } - }; - - const isOk = state.result?.includes(": OK"); - const isFail = state.result && !isOk; - - return ( -
- - {state.result ? ( -

- {isOk ? ( - - ) : isFail ? ( - - ) : null} - {state.result} -

- ) : null} -
- ); -} - -function AgentProviderTestPanel({ settings }: { settings: AppSettings }) { - return ( -
- - - -
- ); -} +const inputClass = + "mt-1 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white"; export default function AdminAIModelsPage() { const { user } = useAuth(); @@ -152,34 +34,6 @@ export default function AdminAIModelsPage() { const [settingsLoadError, setSettingsLoadError] = useState(null); const [showSuccess, setShowSuccess] = useState(false); - const [models, setModels] = useState([]); - const [loadingModels, setLoadingModels] = useState(true); - const [modelError, setModelError] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - - const loadModels = async () => { - setLoadingModels(true); - setModelError(null); - - try { - const response = await fetch("/api/ai/models"); - if (!response.ok) { - const errorText = await response.text(); - setModelError(errorText || "ไม่สามารถโหลดรายการโมเดลได้"); - setLoadingModels(false); - return; - } - - const data = await response.json(); - setModels(data.models || []); - } catch (error) { - console.error("Error loading AI models:", error); - setModelError("เกิดข้อผิดพลาดในการเรียกข้อมูลโมเดล"); - } finally { - setLoadingModels(false); - } - }; - useEffect(() => { let mounted = true; @@ -201,54 +55,11 @@ export default function AdminAIModelsPage() { if (mounted) setLoadingSettings(false); }); - loadModels(); - return () => { mounted = false; }; }, []); - const generateContentModels = useMemo(() => { - const filtered = models.filter((model) => - (model.supportedGenerationMethods || []).includes("generateContent") - ); - - if (!searchTerm.trim()) return filtered; - - const term = searchTerm.toLowerCase(); - return filtered.filter((model) => - (model.displayName || model.name).toLowerCase().includes(term) - ); - }, [models, searchTerm]); - - const nerModelValid = useMemo(() => { - if (!settings.aiNerModel) return false; - return generateContentModels.some( - (model) => normalizeModelName(model.name) === normalizeModelName(settings.aiNerModel || "") - ); - }, [generateContentModels, settings.aiNerModel]); - - const matchingModelValid = useMemo(() => { - if (!settings.aiMatchingModel) return false; - return generateContentModels.some( - (model) => normalizeModelName(model.name) === normalizeModelName(settings.aiMatchingModel || "") - ); - }, [generateContentModels, settings.aiMatchingModel]); - - const visionModelValid = useMemo(() => { - if (!settings.aiVisionModel) return false; - return generateContentModels.some( - (model) => normalizeModelName(model.name) === normalizeModelName(settings.aiVisionModel || "") - ); - }, [generateContentModels, settings.aiVisionModel]); - - const agentModelValid = useMemo(() => { - if (!settings.agentModel) return false; - return generateContentModels.some( - (model) => normalizeModelName(model.name) === normalizeModelName(settings.agentModel || "") - ); - }, [generateContentModels, settings.agentModel]); - const handleSave = async () => { if (!user?.uid) return; @@ -294,7 +105,10 @@ export default function AdminAIModelsPage() { โมเดลที่ใช้ในระบบ

- กำหนดโมเดลสำหรับระบบภายใน + กรอกชื่อโมเดล Gemini โดยตรง — ตั้ง API key ที่{" "} + + ตั้งค่า AI +

-
- -
-
- - setSearchTerm(e.target.value)} - placeholder="ค้นหาโมเดล" - className="w-full pl-9 pr-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white" - /> -
-
- - {modelError && ( -
- {modelError} -
- )} - -
- {loadingModels ? ( -
- - กำลังโหลดรายการโมเดล -
- ) : generateContentModels.length === 0 ? ( -
ไม่พบรายการโมเดล
- ) : ( - generateContentModels.map((model) => ( -
-
-
- {model.displayName || model.name} -
-
- {model.name} -
-
- {(model.supportedGenerationMethods || []).map((method) => ( - - {method} - - ))} -
-
-
- - - - -
-
- )) - )} +
+ + + setSettings((prev) => ({ ...prev, agentModel: e.target.value })) + } + placeholder="models/gemini-2.5-flash" + className={inputClass} + />
diff --git a/app/admin/ai/openrouter/page.tsx b/app/admin/ai/openrouter/page.tsx index 9dc0291..62f9c3a 100644 --- a/app/admin/ai/openrouter/page.tsx +++ b/app/admin/ai/openrouter/page.tsx @@ -15,7 +15,6 @@ import { RefreshCw, Save, AlertTriangle, - Activity, } from "lucide-react"; import { useAuth } from "@/contexts/auth-context"; import { getAppSettingsWithMeta, updateAppSettings } from "@/lib/database"; @@ -34,17 +33,6 @@ type EndpointRow = { uptimeLast30m?: number | null; }; -type ProbeResult = { - ok: boolean; - model: string; - text: string; - finishReason?: string; - nativeFinishReason?: string; - provider?: string; - generationId?: string; - error?: string; -}; - function parseCsvList(value: string): string[] { return value .split(",") @@ -64,14 +52,6 @@ export default function AdminOpenRouterSettingsPage() { const [endpointsLoading, setEndpointsLoading] = useState(false); const [endpointsError, setEndpointsError] = useState(null); - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState(null); - - const modelId = - settings.agentOpenRouterModel || - process.env.NEXT_PUBLIC_OPENROUTER_MODEL || - DEFAULT_APP_SETTINGS.agentOpenRouterModel!; - const selectedOrder = settings.agentOpenRouterProviderOrder ?? []; const ignoreText = (settings.agentOpenRouterProviderIgnore ?? []).join(", "); @@ -169,29 +149,6 @@ export default function AdminOpenRouterSettingsPage() { } }; - const runTest = async () => { - setTesting(true); - setTestResult(null); - try { - const res = await fetch("/api/agent/openrouter/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ settings }), - }); - const data = (await res.json()) as ProbeResult; - setTestResult(data); - } catch (error) { - setTestResult({ - ok: false, - model: modelId, - text: "", - error: error instanceof Error ? error.message : "ทดสอบไม่สำเร็จ", - }); - } finally { - setTesting(false); - } - }; - return (
@@ -478,47 +435,6 @@ export default function AdminOpenRouterSettingsPage() {

)}
- -
-
-

- - ทดสอบการเชื่อมต่อ -

- -
- - {testResult ? ( -
- {testResult.ok ? ( - <> -

OK — provider: {testResult.provider || "?"}

-

finish_reason: {testResult.finishReason ?? "-"}

- {testResult.nativeFinishReason ? ( -

native: {testResult.nativeFinishReason}

- ) : null} -

{testResult.text.slice(0, 200)}

- - ) : ( -

{testResult.error || "ทดสอบไม่สำเร็จ"}

- )} -
- ) : null} -
); diff --git a/app/admin/ai/page.tsx b/app/admin/ai/page.tsx index a54806a..1c08944 100644 --- a/app/admin/ai/page.tsx +++ b/app/admin/ai/page.tsx @@ -39,7 +39,7 @@ export default function AdminAIPage() { AI Center

- จัดการโมเดล AI และทดสอบการเชื่อมต่อระบบ + ตั้งค่า AI, API keys และ pipeline models

@@ -80,7 +80,7 @@ export default function AdminAIPage() { Gemini & Pipeline

- NER, Matching, Vision, Gemini Agent model + NER, Matching, Vision — กรอกชื่อโมเดลโดยตรง

diff --git a/app/admin/ai/settings/page.tsx b/app/admin/ai/settings/page.tsx index c04e5d9..d758723 100644 --- a/app/admin/ai/settings/page.tsx +++ b/app/admin/ai/settings/page.tsx @@ -14,6 +14,8 @@ import { Settings2, Sparkles, Route, + Activity, + AlertTriangle, } from "lucide-react"; import { useAuth } from "@/contexts/auth-context"; import { getAppSettingsWithMeta, updateAppSettings } from "@/lib/database"; @@ -22,8 +24,25 @@ import { AGENT_SHARED_SETTING_KEYS, } from "@/lib/admin/ai-settings-keys"; import { AiSettingField } from "@/components/admin/ai-setting-field"; +import { ApiKeyLabelLink } from "@/components/admin/api-key-label-link"; +import { WIZARD_FREE_OPENROUTER_MODELS } from "@/lib/setup/validations/wizard-ai"; import { DEFAULT_APP_SETTINGS, type AppSettings } from "@/lib/types"; +type AiCredentialsMeta = { + provider: "auto" | "gemini" | "openrouter" | "none"; + openrouterModel: string | null; + hasGeminiKey: boolean; + hasOpenrouterKey: boolean; + configuredAt: string | null; +}; + +const KEY_PLACEHOLDER = "••••••••••••••••"; + +function isPlaceholderKeyInput(value: string): boolean { + if (!value.trim()) return true; + return /^[•*.\s]+$/.test(value.trim()); +} + const TABS = [ { id: "agent", label: "Agent ร่วม", icon: Settings2 }, { id: "gemini", label: "Gemini & Pipeline", icon: Sparkles }, @@ -50,6 +69,17 @@ function AdminAiSettingsContent() { const [saving, setSaving] = useState(false); const [showSuccess, setShowSuccess] = useState(false); + const [credentialsMeta, setCredentialsMeta] = useState(null); + const [loadingCredentials, setLoadingCredentials] = useState(false); + const [savingCredentials, setSavingCredentials] = useState(false); + const [credentialsSuccess, setCredentialsSuccess] = useState(false); + const [credentialsError, setCredentialsError] = useState(null); + const [geminiApiKey, setGeminiApiKey] = useState(""); + const [openrouterApiKey, setOpenrouterApiKey] = useState(""); + const [openrouterModel, setOpenrouterModel] = useState(WIZARD_FREE_OPENROUTER_MODELS[0]); + const [testingProvider, setTestingProvider] = useState<"gemini" | "openrouter" | null>(null); + const [testResult, setTestResult] = useState(null); + useEffect(() => { let mounted = true; getAppSettingsWithMeta() @@ -64,6 +94,123 @@ function AdminAiSettingsContent() { }; }, []); + useEffect(() => { + if (activeTab !== "gemini" && activeTab !== "openrouter") return; + + let mounted = true; + setLoadingCredentials(true); + setCredentialsError(null); + + fetch("/api/admin/ai-credentials") + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "โหลด credentials ไม่สำเร็จ"); + } + return res.json() as Promise; + }) + .then((data) => { + if (!mounted) return; + setCredentialsMeta(data); + setOpenrouterModel( + data.openrouterModel?.trim() || WIZARD_FREE_OPENROUTER_MODELS[0] + ); + setGeminiApiKey(""); + setOpenrouterApiKey(""); + }) + .catch((error) => { + if (mounted) { + setCredentialsError( + error instanceof Error ? error.message : "โหลด credentials ไม่สำเร็จ" + ); + } + }) + .finally(() => { + if (mounted) setLoadingCredentials(false); + }); + + return () => { + mounted = false; + }; + }, [activeTab]); + + const handleSaveCredentials = async ( + patch: { + geminiApiKey?: string; + openrouterApiKey?: string; + openrouterModel?: string; + } + ) => { + setSavingCredentials(true); + setCredentialsError(null); + setCredentialsSuccess(false); + setTestResult(null); + + const body: Record = {}; + if (patch.geminiApiKey && !isPlaceholderKeyInput(patch.geminiApiKey)) { + body.geminiApiKey = patch.geminiApiKey.trim(); + } + if (patch.openrouterApiKey && !isPlaceholderKeyInput(patch.openrouterApiKey)) { + body.openrouterApiKey = patch.openrouterApiKey.trim(); + } + if (patch.openrouterModel?.trim()) { + body.openrouterModel = patch.openrouterModel.trim(); + } + + try { + const res = await fetch("/api/admin/ai-credentials", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "บันทึกไม่สำเร็จ"); + } + setCredentialsMeta(data); + setGeminiApiKey(""); + setOpenrouterApiKey(""); + setCredentialsSuccess(true); + setTimeout(() => setCredentialsSuccess(false), 3000); + } catch (error) { + setCredentialsError(error instanceof Error ? error.message : "บันทึกไม่สำเร็จ"); + } finally { + setSavingCredentials(false); + } + }; + + const handleTestProvider = async (provider: "gemini" | "openrouter") => { + setTestingProvider(provider); + setTestResult(null); + setCredentialsError(null); + + try { + const res = await fetch("/api/agent/test-providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings, provider }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "ทดสอบไม่สำเร็จ"); + } + const info = data.providers?.[provider] as + | { configured?: boolean; ok?: boolean; error?: string; model?: string } + | undefined; + if (info?.ok) { + setTestResult(`${provider}: OK${info.model ? ` [${info.model}]` : ""}`); + } else if (!info?.configured) { + setTestResult(`${provider}: ยังไม่ได้ตั้งค่า API key`); + } else { + setTestResult(`${provider}: ${info?.error || "เชื่อมต่อไม่สำเร็จ"}`); + } + } catch (error) { + setTestResult(error instanceof Error ? error.message : "ทดสอบไม่สำเร็จ"); + } finally { + setTestingProvider(null); + } + }; + const handleSaveAgent = async () => { if (!user?.uid) return; setSaving(true); @@ -310,30 +457,221 @@ function AdminAiSettingsContent() { ) : null} {activeTab === "gemini" ? ( -
-

- ตั้งค่า NER, Matching, Vision และ Gemini Agent model — บันทึกเฉพาะฟิลด์ Gemini - ไม่ทับ OpenRouter +

+

+ ตั้งค่า Gemini API key — บันทึกได้โดยไม่ต้องทดสอบก่อน ตรวจคีย์ด้วยปุ่มทดสอบด้านล่าง

+ + {loadingCredentials ? ( +
+ + กำลังโหลด credentials... +
+ ) : null} + + {credentialsError ? ( +

+ + {credentialsError} +

+ ) : null} + + {credentialsMeta?.hasGeminiKey && !geminiApiKey ? ( +

+ + ตั้งค่า Gemini API key แล้ว — กรอกค่าใหม่เพื่อเปลี่ยน +

+ ) : null} + +
+ + setGeminiApiKey(e.target.value)} + placeholder={ + credentialsMeta?.hasGeminiKey ? KEY_PLACEHOLDER : "AIza..." + } + autoComplete="off" + className={inputClass} + /> +
+ +
+ + +
+ + {credentialsSuccess ? ( +

+ + บันทึกแล้ว +

+ ) : null} + {testResult ? ( +

{testResult}

+ ) : null} + - เปิดหน้าตั้งค่า Gemini & Pipeline เต็มรูปแบบ → + ตั้งค่า NER / Matching / Vision / Agent model →
) : null} {activeTab === "openrouter" ? ( -
-

- Lock provider, reasoning, routing — บันทึกเฉพาะฟิลด์ OpenRouter +

+

+ ตั้งค่า OpenRouter API key และโมเดลเริ่มต้น — routing เพิ่มเติมอยู่ที่หน้า OpenRouter

+ + {loadingCredentials ? ( +
+ + กำลังโหลด credentials... +
+ ) : null} + + {credentialsError ? ( +

+ + {credentialsError} +

+ ) : null} + + {credentialsMeta?.hasOpenrouterKey && !openrouterApiKey ? ( +

+ + ตั้งค่า OpenRouter API key แล้ว — กรอกค่าใหม่เพื่อเปลี่ยน +

+ ) : null} + +
+ + setOpenrouterApiKey(e.target.value)} + placeholder={ + credentialsMeta?.hasOpenrouterKey ? KEY_PLACEHOLDER : "sk-or-..." + } + autoComplete="off" + className={inputClass} + /> +
+ +
+ + +
+ +
+ + +
+ + {credentialsSuccess ? ( +

+ + บันทึกแล้ว +

+ ) : null} + {testResult ? ( +

{testResult}

+ ) : null} + - เปิดหน้าตั้งค่า OpenRouter เต็มรูปแบบ → + ตั้งค่า OpenRouter routing / lock provider →
) : null} diff --git a/app/admin/blog/[id]/page.tsx b/app/admin/blog/[id]/page.tsx new file mode 100644 index 0000000..6c559e1 --- /dev/null +++ b/app/admin/blog/[id]/page.tsx @@ -0,0 +1,478 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { + ArrowLeft, + ExternalLink, + Eye, + Loader2, + Save, + Trash2, + Upload, +} from "lucide-react"; +import { createClient } from "@/lib/supabase/client"; +import { useAuth } from "@/contexts/auth-context"; +import { useAppDialog } from "@/hooks/use-app-dialog"; +import { ArticleEditor } from "@/components/blog/article-editor"; +import { mapArticle } from "@/lib/blog/map"; +import { + EMPTY_DOC, + slugifyTitle, + type Article, + type ArticleSection, + type ArticleStatus, + type TipTapDoc, +} from "@/lib/blog/types"; +import { cn } from "@/lib/utils"; + +export default function AdminBlogEditorPage() { + const params = useParams(); + const id = String(params.id ?? ""); + const router = useRouter(); + const { user } = useAuth(); + const { showAlert, showConfirm, dialog } = useAppDialog(); + const supabase = useMemo(() => createClient(), []); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + const [article, setArticle] = useState
(null); + const [title, setTitle] = useState(""); + const [slug, setSlug] = useState(""); + const [excerpt, setExcerpt] = useState(""); + const [coverImageUrl, setCoverImageUrl] = useState(null); + const [authorName, setAuthorName] = useState(""); + const [tagsInput, setTagsInput] = useState(""); + const [section, setSection] = useState("blog"); + const [status, setStatus] = useState("draft"); + const [contentJson, setContentJson] = useState(EMPTY_DOC); + const [editorKey, setEditorKey] = useState(0); + + const load = useCallback(async () => { + if (!id) return; + setLoading(true); + try { + const { data, error } = await supabase + .from("articles") + .select("*") + .eq("id", id) + .maybeSingle(); + if (error) throw error; + if (!data) { + setArticle(null); + return; + } + const mapped = mapArticle(data as Record); + setArticle(mapped); + setTitle(mapped.title); + setSlug(mapped.slug); + setExcerpt(mapped.excerpt ?? ""); + setCoverImageUrl(mapped.cover_image_url); + setAuthorName(mapped.author_name ?? ""); + setTagsInput(mapped.tags.join(", ")); + setSection(mapped.section); + setStatus(mapped.status); + setContentJson(mapped.content_json); + setEditorKey((k) => k + 1); + } catch (error) { + console.error(error); + await showAlert({ + title: "โหลดไม่สำเร็จ", + message: "ไม่พบบทความนี้", + variant: "error", + }); + setArticle(null); + } finally { + setLoading(false); + } + }, [id, showAlert, supabase]); + + useEffect(() => { + void load(); + }, [load]); + + const getToken = useCallback(async () => { + if (!user) throw new Error("Not authenticated"); + return user.getIdToken(); + }, [user]); + + const uploadImage = async (file: File): Promise => { + setUploading(true); + try { + const token = await getToken(); + const formData = new FormData(); + formData.set("file", file); + formData.set("folder", id || "general"); + const res = await fetch("/api/admin/blog/upload", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "อัปโหลดไม่สำเร็จ"); + return String(data.publicUrl); + } catch (error) { + await showAlert({ + title: "อัปโหลดไม่สำเร็จ", + message: error instanceof Error ? error.message : "เกิดข้อผิดพลาด", + variant: "error", + }); + return null; + } finally { + setUploading(false); + } + }; + + const parseTags = (value: string) => + value + .split(/[,#]+/) + .map((t) => t.trim()) + .filter(Boolean); + + const save = async (nextStatus?: ArticleStatus) => { + if (!id) return; + setSaving(true); + try { + const resolvedStatus = nextStatus ?? status; + const payload = { + title: title.trim() || "ไม่มีชื่อ", + slug: slug.trim() || slugifyTitle(title), + excerpt: excerpt.trim() || null, + cover_image_url: coverImageUrl, + author_name: authorName.trim() || null, + tags: parseTags(tagsInput), + section, + status: resolvedStatus, + content_json: contentJson, + published_at: + resolvedStatus === "published" + ? article?.published_at || new Date().toISOString() + : null, + updated_at: new Date().toISOString(), + }; + const { error } = await supabase + .from("articles") + .update(payload) + .eq("id", id); + if (error) throw error; + setStatus(resolvedStatus); + setSlug(payload.slug); + await showAlert({ + title: "บันทึกแล้ว", + message: + resolvedStatus === "published" + ? `เผยแพร่ที่ ${ + section === "help" ? `/help/${payload.slug}` : `/blog/${payload.slug}` + } แล้ว` + : "บันทึกฉบับร่างแล้ว", + variant: "success", + }); + await load(); + } catch (error) { + await showAlert({ + title: "บันทึกไม่สำเร็จ", + message: error instanceof Error ? error.message : "เกิดข้อผิดพลาด", + variant: "error", + }); + } finally { + setSaving(false); + } + }; + + const remove = async () => { + const ok = await showConfirm({ + title: "ลบบทความนี้?", + message: "การลบไม่สามารถย้อนกลับได้", + confirmLabel: "ลบ", + cancelLabel: "ยกเลิก", + variant: "warning", + }); + if (!ok) return; + const { error } = await supabase.from("articles").delete().eq("id", id); + if (error) { + await showAlert({ + title: "ลบไม่สำเร็จ", + message: error.message, + variant: "error", + }); + return; + } + router.push("/admin/blog"); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!article) { + return ( +
+

ไม่พบบทความนี้

+ + กลับรายการ + +
+ ); + } + + const publicPath = + section === "help" ? `/help/${slug}` : `/blog/${slug}`; + + return ( +
+
+
+ + + บทความ + +

+ {section === "help" ? "แก้ไขคู่มือ" : "แก้ไขบทความ"} +

+

{publicPath}

+
+
+ + + ตัวอย่าง + + {status === "published" ? ( + + เปิดหน้าสาธารณะ + + + ) : null} +
+
+ +
+
+

+ เผยแพร่ที่ไหน? +

+

+ เลือกปลายทางสาธารณะของเนื้อหานี้ — เปลี่ยนได้ทุกเมื่อก่อนหรือหลังเผยแพร่ +

+
+ +
+ {( + [ + { + id: "blog" as const, + title: "Blog", + path: `/blog/${slug || "…"}`, + description: "บทความสาธารณะในหน้า /blog", + }, + { + id: "help" as const, + title: "Help", + path: `/help/${slug || "…"}`, + description: "คู่มือในศูนย์ช่วยเหลือ /help", + }, + ] as const + ).map((option) => { + const selected = section === option.id; + return ( + + ); + })} +
+ +

+ URL สาธารณะ:{" "} + + {publicPath} + + {status === "published" ? ( + · เผยแพร่แล้ว + ) : ( + · ยังเป็นฉบับร่าง + )} +

+
+ +
+

ข้อมูลเนื้อหา

+
+ + + +