Login
@@ -156,6 +155,6 @@ export default function AdminLoginPage() {
-
+ >
);
}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index cdaee15..36534e4 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -1,372 +1,5 @@
-import { createDefaultForm } from "@/app/admin/form/actions";
-import { AdminShellServer } from "@/components/admin-shell-server";
-import { RealtimeRefresh } from "@/components/realtime-refresh";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardAction,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Progress } from "@/components/ui/progress";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import { getAdminDashboardData } from "@/lib/admin/admin-dashboard";
-import {
- ArrowUpRight,
- ClipboardList,
- FilePlus2,
- Server,
- Upload,
-} from "lucide-react";
-import Link from "next/link";
+import { AdminWelcome } from "./client";
-export const dynamic = "force-dynamic";
-
-function formatNumber(value: number) {
- return new Intl.NumberFormat("en-US").format(value);
-}
-
-function formatDate(date: Date | null) {
- if (!date) return "No responses";
- return date.toLocaleString("en-GB", {
- timeZone: "Asia/Bangkok",
- hour12: false,
- });
-}
-
-function respondentName(submission: {
- userName: string | null;
- userDiscordId: string | null;
-}) {
- return submission.userName || submission.userDiscordId || "Anonymous";
-}
-
-function MetricCard({
- label,
- value,
- detail,
-}: {
- label: string;
- value: number;
- detail: string;
-}) {
- return (
-
-
- {label}
-
- {formatNumber(value)}
-
-
-
- {detail}
-
-
- );
-}
-
-function QuickAction({
- href,
- title,
- description,
- icon: Icon,
- disabled = false,
-}: {
- href: string;
- title: string;
- description: string;
- icon: typeof ClipboardList;
- disabled?: boolean;
-}) {
- return (
-
-
-
-
- {title}
-
- {description}
-
- {disabled ? (
-
-
-
- ) : (
-
-
-
-
-
- )}
-
-
-
- );
-}
-
-export default async function AdminPage() {
- const { summary, recentSubmissions, activeForms } =
- await getAdminDashboardData();
- const busiestFormResponses = Math.max(
- ...activeForms.map((form) => form.responsesLast7Days),
- 1,
- );
-
- return (
-
-
-
-
-
-
-
-
-
- Responses today
-
- {formatNumber(summary.responsesToday)}
-
-
- Live
-
-
-
-
- Submissions received since midnight. Realtime updates keep this
- workspace current while you are here.
-
-
-
- {formatNumber(summary.responsesLast7Days)} this week
-
-
- {formatNumber(summary.openForms)} forms accepting responses
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Recent responses
-
- The latest submissions across every form.
-
-
-
- View forms
-
-
-
-
- {recentSubmissions.length === 0 ? (
-
- Responses will appear here after someone submits a form.
-
- ) : (
- <>
-
-
-
-
- Respondent
- Form
- Submitted
- Status
-
-
-
- {recentSubmissions.map((submission) => (
-
-
-
- {respondentName(submission)}
-
-
-
-
- {submission.formTitle}
-
-
-
- {formatDate(submission.submittedAt)}
-
-
-
- {submission.edited ? "Edited" : "Original"}
-
-
-
- ))}
-
-
-
-
- {recentSubmissions.map((submission) => (
-
-
-
-
- {respondentName(submission)}
-
-
-
- {submission.formTitle} · {formatDate(submission.submittedAt)}
-
-
-
- {submission.edited ? "Edited" : "Original"}
-
-
-
-
- ))}
-
- >
- )}
-
-
-
-
-
- Form momentum
-
- Response activity during the last 7 days.
-
-
-
- {activeForms.length === 0 ? (
-
- Create a form to start tracking activity.
-
- ) : (
- activeForms.map((form) => (
-
-
-
- {form.title}
-
-
- {formatNumber(form.responsesLast7Days)}
-
-
-
-
-
- {form.isOpen ? "Open" : "Closed"}
-
- {formatNumber(form.totalResponses)} total
-
-
- ))
- )}
-
-
-
-
-
-
-
Workspace
-
- Go directly to the tools you operate most often.
-
-
-
-
-
-
-
-
-
-
- );
+export default function AdminPage() {
+ return
;
}
diff --git a/app/admin/upload/layout.tsx b/app/admin/upload/layout.tsx
index 483d0b1..11acf1d 100644
--- a/app/admin/upload/layout.tsx
+++ b/app/admin/upload/layout.tsx
@@ -1,4 +1,3 @@
-import { AdminShellServer } from "@/components/admin-shell-server";
import { UploadNavigation } from "./upload-navigation";
export default function AdminUploadLayout({
@@ -7,7 +6,7 @@ export default function AdminUploadLayout({
children: React.ReactNode;
}) {
return (
-
+ <>
Creator studio
@@ -21,6 +20,6 @@ export default function AdminUploadLayout({
{children}
-
+ >
);
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 70941b0..2a065cf 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
title: "Erika!",
description:
"แหล่งรวมลิ้งค์ทั้งหมดของช่อง Erika!",
- keywords: ["Erika", "erika!", "erika jjs", "สุดหล่อ", "Roblox", "Discord", "TikTok", "YouTube", "Linktree", "ลิ้งค์"],
+ keywords: ["Erika", "เอริกะ", "เอริก้า", "สุดหล่อ", "Roblox", "Discord", "TikTok", "YouTube", "Linktree", "ลิ้งค์"],
metadataBase: new URL(siteUrl),
alternates: {
canonical: "/",
diff --git a/app/share/layout.tsx b/app/share/layout.tsx
index 771a37c..aabe8a2 100644
--- a/app/share/layout.tsx
+++ b/app/share/layout.tsx
@@ -36,18 +36,6 @@ export default async function ShareLayout({ children }: { children: React.ReactN
-
-
-
- ทั้งหมด
-
-
-
-
-
- สร้างลิงก์
-
-
{session?.user && (
{children}
diff --git a/components/admin-shell.tsx b/components/admin-shell.tsx
index 62c89be..3899821 100644
--- a/components/admin-shell.tsx
+++ b/components/admin-shell.tsx
@@ -8,6 +8,7 @@ import {
SidebarContent,
SidebarGroup,
SidebarGroupContent,
+ SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
@@ -17,8 +18,8 @@ import {
} from "@/components/ui/sidebar";
import {
ClipboardList,
+ House,
KeyRound,
- LayoutDashboard,
LucideIcon,
Server,
Upload,
@@ -34,37 +35,52 @@ interface NavItem {
disabled?: boolean;
}
-const NAV_ITEMS: NavItem[] = [
+const NAV_GROUPS: { label: string; items: NavItem[] }[] = [
{
- href: "/admin",
- label: "Dashboard",
- icon: LayoutDashboard,
- isActive: (pathname) => pathname === "/admin",
+ label: "Workspace",
+ items: [
+ {
+ href: "/admin",
+ label: "Home",
+ icon: House,
+ isActive: (pathname) => pathname === "/admin",
+ },
+ {
+ href: "/admin/form",
+ label: "Forms",
+ icon: ClipboardList,
+ isActive: (pathname) => pathname.startsWith("/admin/form"),
+ },
+ ],
},
{
- href: "/admin/form",
- label: "Form",
- icon: ClipboardList,
- isActive: (pathname) => pathname === "/admin/form",
+ label: "Operations",
+ items: [
+ {
+ href: "/admin/backend",
+ label: "Backend",
+ icon: Server,
+ isActive: (pathname) => pathname.startsWith("/admin/backend"),
+ },
+ {
+ href: "/admin/upload",
+ label: "Upload",
+ icon: Upload,
+ isActive: (pathname) => pathname.startsWith("/admin/upload"),
+ disabled: true,
+ },
+ ],
},
{
- href: "/admin/backend",
- label: "Backend",
- icon: Server,
- isActive: (pathname) => pathname.startsWith("/admin/backend"),
- },
- {
- href: "/admin/upload",
- label: "Upload",
- icon: Upload,
- isActive: (pathname) => pathname.startsWith("/admin/upload"),
- disabled: true,
- },
- {
- href: "/admin/login",
- label: "Login",
- icon: KeyRound,
- isActive: (pathname) => pathname.startsWith("/admin/login"),
+ label: "Platform access",
+ items: [
+ {
+ href: "/admin/login",
+ label: "Login",
+ icon: KeyRound,
+ isActive: (pathname) => pathname.startsWith("/admin/login"),
+ },
+ ],
},
];
@@ -73,11 +89,13 @@ export function AdminShell({
userName,
userImage,
profile,
+ healthStatus,
}: {
children: React.ReactNode;
userName?: string;
userImage?: string;
profile?: DiscordMemberProfile | null;
+ healthStatus?: React.ReactNode;
}) {
const pathname = usePathname();
@@ -105,29 +123,37 @@ export function AdminShell({
-
-
-
- {NAV_ITEMS.map(({ href, label, icon: Icon, isActive, disabled }) => (
-
- {disabled ? (
-
-
- {label}
-
- ) : (
-
-
+ {healthStatus}
+ {NAV_GROUPS.map((group) => (
+
+ {group.label}
+
+
+ {group.items.map(({ href, label, icon: Icon, isActive, disabled }) => (
+
+ {disabled ? (
+
{label}
-
-
- )}
-
- ))}
-
-
-
+
+ ) : (
+
+
+
+ {label}
+
+
+ )}
+
+ ))}
+
+
+
+ ))}
diff --git a/lib/admin/admin-dashboard.ts b/lib/admin/admin-dashboard.ts
deleted file mode 100644
index 4fab7c1..0000000
--- a/lib/admin/admin-dashboard.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-import { db } from "@/db";
-import { forms, submissions } from "@/db/schema";
-import { desc, eq, sql } from "drizzle-orm";
-import { max } from "drizzle-orm/sql/functions/aggregate";
-
-export type DashboardDateWindows = {
- todayStart: Date;
- tomorrowStart: Date;
- last7DaysStart: Date;
-};
-
-export type AdminDashboardData = {
- summary: {
- totalForms: number;
- openForms: number;
- closedForms: number;
- totalResponses: number;
- responsesToday: number;
- responsesLast7Days: number;
- };
- recentSubmissions: {
- id: string;
- formId: string;
- formTitle: string;
- userName: string | null;
- userDiscordId: string | null;
- submittedAt: Date;
- edited: boolean;
- }[];
- activeForms: {
- id: string;
- title: string;
- isOpen: boolean;
- totalResponses: number;
- responsesToday: number;
- responsesLast7Days: number;
- lastResponseAt: Date | null;
- }[];
-};
-
-export function getDashboardDateWindows(now = new Date()): DashboardDateWindows {
- const bangkokDate = new Intl.DateTimeFormat("en-CA", {
- timeZone: "Asia/Bangkok",
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- }).format(now);
- const todayStart = new Date(`${bangkokDate}T00:00:00+07:00`);
- const tomorrowStart = new Date(todayStart);
- tomorrowStart.setUTCDate(tomorrowStart.getUTCDate() + 1);
-
- const last7DaysStart = new Date(todayStart);
- last7DaysStart.setUTCDate(last7DaysStart.getUTCDate() - 6);
-
- return { todayStart, tomorrowStart, last7DaysStart };
-}
-
-function toPostgresTimestamp(date: Date) {
- return date.toISOString().replace("T", " ").replace("Z", "");
-}
-
-export async function getAdminDashboardData(
- now = new Date()
-): Promise {
- const { todayStart, tomorrowStart, last7DaysStart } =
- getDashboardDateWindows(now);
- const nowSql = now.toISOString();
- const todayStartSql = toPostgresTimestamp(todayStart);
- const tomorrowStartSql = toPostgresTimestamp(tomorrowStart);
- const last7DaysStartSql = toPostgresTimestamp(last7DaysStart);
- const totalResponses = sql`count(${submissions.id})::integer`;
- const acceptingSubmissions = sql`${forms.isOpen} and (
- ${forms.closesAt} is null or ${forms.closesAt} > ${nowSql}::timestamptz
- )`;
- const responsesToday = sql`count(${submissions.id}) filter (
- where ${submissions.submittedAt} >= ${todayStartSql}::timestamp
- and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp
- )::integer`;
- const responsesLast7Days = sql`count(${submissions.id}) filter (
- where ${submissions.submittedAt} >= ${last7DaysStartSql}::timestamp
- and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp
- )::integer`;
-
- const [formSummaryRows, responseSummaryRows, recentSubmissions, activeForms] =
- await Promise.all([
- db
- .select({
- totalForms: sql`count(*)::integer`,
- openForms: sql`count(*) filter (where ${acceptingSubmissions})::integer`,
- closedForms: sql`count(*) filter (where not (${acceptingSubmissions}))::integer`,
- })
- .from(forms),
- db
- .select({
- totalResponses: sql`count(*)::integer`,
- responsesToday: sql`count(*) filter (
- where ${submissions.submittedAt} >= ${todayStartSql}::timestamp
- and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp
- )::integer`,
- responsesLast7Days: sql`count(*) filter (
- where ${submissions.submittedAt} >= ${last7DaysStartSql}::timestamp
- and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp
- )::integer`,
- })
- .from(submissions),
- db
- .select({
- id: submissions.id,
- formId: submissions.formId,
- formTitle: forms.title,
- userName: submissions.userName,
- userDiscordId: submissions.userDiscordId,
- submittedAt: submissions.submittedAt,
- edited: sql`jsonb_array_length(coalesce(${submissions.editHistory}, '[]'::jsonb)) > 0`,
- })
- .from(submissions)
- .innerJoin(forms, eq(forms.id, submissions.formId))
- .orderBy(desc(submissions.submittedAt), desc(submissions.id))
- .limit(6),
- db
- .select({
- id: forms.id,
- title: forms.title,
- isOpen: acceptingSubmissions,
- totalResponses,
- responsesToday,
- responsesLast7Days,
- lastResponseAt: max(submissions.submittedAt),
- })
- .from(forms)
- .leftJoin(submissions, eq(submissions.formId, forms.id))
- .groupBy(forms.id, forms.title, forms.isOpen, forms.closesAt, forms.createdAt)
- .orderBy(
- desc(responsesLast7Days),
- desc(totalResponses),
- desc(forms.createdAt),
- desc(forms.id)
- )
- .limit(5),
- ]);
-
- const formSummary = formSummaryRows[0] ?? {
- totalForms: 0,
- openForms: 0,
- closedForms: 0,
- };
- const responseSummary = responseSummaryRows[0] ?? {
- totalResponses: 0,
- responsesToday: 0,
- responsesLast7Days: 0,
- };
-
- return {
- summary: {
- ...formSummary,
- ...responseSummary,
- },
- recentSubmissions,
- activeForms,
- };
-}
diff --git a/lib/admin/service-health-core.test.ts b/lib/admin/service-health-core.test.ts
new file mode 100644
index 0000000..c45ffae
--- /dev/null
+++ b/lib/admin/service-health-core.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test";
+import {
+ collectServiceHealth,
+ createCachedHealthReader,
+ type ServiceHealthProbe,
+ type ServiceHealthSnapshot,
+} from "@/lib/admin/service-health-core";
+
+function probe(
+ id: string,
+ check: ServiceHealthProbe["check"],
+): ServiceHealthProbe {
+ return { id, label: id, category: "core", check };
+}
+
+describe("admin service health", () => {
+ test("reports healthy and unavailable services without rejecting the snapshot", async () => {
+ const snapshot = await collectServiceHealth(
+ [
+ probe("healthy", async () => true),
+ probe("unhealthy", async () => false),
+ probe("failed", async () => {
+ throw new Error("offline");
+ }),
+ ],
+ { now: () => new Date("2026-08-27T12:00:00.000Z") },
+ );
+
+ expect(snapshot.checkedAt).toBe("2026-08-27T12:00:00.000Z");
+ expect(snapshot.services.map(({ id, status }) => ({ id, status }))).toEqual([
+ { id: "healthy", status: "operational" },
+ { id: "unhealthy", status: "unavailable" },
+ { id: "failed", status: "unavailable" },
+ ]);
+ });
+
+ test("marks probes unavailable after the timeout", async () => {
+ const snapshot = await collectServiceHealth(
+ [probe("slow", () => new Promise(() => undefined))],
+ { timeoutMs: 1 },
+ );
+
+ expect(snapshot.services[0]?.status).toBe("unavailable");
+ });
+
+ test("reuses snapshots until the cache expires", async () => {
+ let currentTime = 0;
+ let loads = 0;
+ const snapshot: ServiceHealthSnapshot = {
+ checkedAt: "2026-08-27T12:00:00.000Z",
+ services: [],
+ };
+ const read = createCachedHealthReader(
+ async () => {
+ loads += 1;
+ return snapshot;
+ },
+ { ttlMs: 60_000, now: () => currentTime },
+ );
+
+ expect(await read()).toBe(snapshot);
+ expect(await read()).toBe(snapshot);
+ expect(loads).toBe(1);
+
+ currentTime = 60_000;
+ expect(await read()).toBe(snapshot);
+ expect(loads).toBe(2);
+ });
+});
diff --git a/lib/admin/service-health-core.ts b/lib/admin/service-health-core.ts
new file mode 100644
index 0000000..676936c
--- /dev/null
+++ b/lib/admin/service-health-core.ts
@@ -0,0 +1,79 @@
+export type ServiceHealthCategory = "core" | "integration";
+export type ServiceHealthStatus = "operational" | "unavailable";
+
+export type ServiceHealthProbe = {
+ id: string;
+ label: string;
+ category: ServiceHealthCategory;
+ check: () => Promise;
+};
+
+export type ServiceHealthSnapshot = {
+ checkedAt: string;
+ services: {
+ id: string;
+ label: string;
+ category: ServiceHealthCategory;
+ status: ServiceHealthStatus;
+ }[];
+};
+
+async function checkWithTimeout(check: () => Promise, timeoutMs: number) {
+ let timeout: ReturnType | undefined;
+
+ try {
+ return await Promise.race([
+ check(),
+ new Promise((resolve) => {
+ timeout = setTimeout(() => resolve(false), timeoutMs);
+ }),
+ ]);
+ } catch {
+ return false;
+ } finally {
+ if (timeout) clearTimeout(timeout);
+ }
+}
+
+export async function collectServiceHealth(
+ probes: ServiceHealthProbe[],
+ options: { timeoutMs?: number; now?: () => Date } = {},
+): Promise {
+ const timeoutMs = options.timeoutMs ?? 5_000;
+ const checkedAt = (options.now ?? (() => new Date()))().toISOString();
+ const services = await Promise.all(
+ probes.map(async ({ id, label, category, check }) => ({
+ id,
+ label,
+ category,
+ status: (await checkWithTimeout(check, timeoutMs))
+ ? ("operational" as const)
+ : ("unavailable" as const),
+ })),
+ );
+
+ return { checkedAt, services };
+}
+
+export function createCachedHealthReader(
+ load: () => Promise,
+ options: { ttlMs?: number; now?: () => number } = {},
+) {
+ const ttlMs = options.ttlMs ?? 60_000;
+ const now = options.now ?? Date.now;
+ let cached:
+ | { expiresAt: number; value: Promise }
+ | undefined;
+
+ return () => {
+ const currentTime = now();
+ if (cached && currentTime < cached.expiresAt) return cached.value;
+
+ const value = load();
+ cached = { expiresAt: currentTime + ttlMs, value };
+ void value.catch(() => {
+ if (cached?.value === value) cached = undefined;
+ });
+ return value;
+ };
+}
diff --git a/lib/admin/service-health.ts b/lib/admin/service-health.ts
new file mode 100644
index 0000000..28a9d6b
--- /dev/null
+++ b/lib/admin/service-health.ts
@@ -0,0 +1,60 @@
+import "server-only";
+
+import { sql } from "drizzle-orm";
+import { db } from "@/db";
+import { getDiscordMemberCount } from "@/lib/followers/discord";
+import { getRobloxFollowerCount } from "@/lib/followers/roblox";
+import { getTiktokProfile } from "@/lib/followers/tiktok";
+import { getYoutubeSubscriberCount } from "@/lib/followers/youtube";
+import { getRedisClient } from "@/lib/redis";
+import {
+ collectServiceHealth,
+ createCachedHealthReader,
+ type ServiceHealthProbe,
+} from "./service-health-core";
+
+const probes: ServiceHealthProbe[] = [
+ {
+ id: "database",
+ label: "PostgreSQL",
+ category: "core",
+ check: async () => {
+ await db.execute(sql`select 1`);
+ return true;
+ },
+ },
+ {
+ id: "redis",
+ label: "Redis / realtime",
+ category: "core",
+ check: async () => (await (await getRedisClient()).ping()) === "PONG",
+ },
+ {
+ id: "youtube",
+ label: "YouTube",
+ category: "integration",
+ check: async () => (await getYoutubeSubscriberCount()) !== null,
+ },
+ {
+ id: "tiktok",
+ label: "TikTok",
+ category: "integration",
+ check: async () => (await getTiktokProfile()).followerCount !== null,
+ },
+ {
+ id: "discord",
+ label: "Discord",
+ category: "integration",
+ check: async () => (await getDiscordMemberCount()) !== null,
+ },
+ {
+ id: "roblox",
+ label: "Roblox",
+ category: "integration",
+ check: async () => (await getRobloxFollowerCount()) !== null,
+ },
+];
+
+export const getAdminServiceHealth = createCachedHealthReader(() =>
+ collectServiceHealth(probes),
+);
diff --git a/skill/erika/SKILL.md b/skill/erika/SKILL.md
index bf3b72e..66087e5 100644
--- a/skill/erika/SKILL.md
+++ b/skill/erika/SKILL.md
@@ -65,32 +65,11 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
- Build all UI exclusively using ShadCN components.
- Rely on the built-in component styling; it's already well-designed.
-- Do not add custom CSS or excessive utility classes unless absolutely necessary.
+- Do not add hard-code CSS or excessive utility classes unless absolutely necessary.
+- Do not use primary colors for everything use it sparingly to highlight important elements. Use secondary colors for less critical elements.
- Avoid excessive inline styles on HTML elements (e.g., ``). Keep styling clean and minimal to prevent an "AI slop" appearance.
-## 6. Type Checking
-
-**Use lightweight tools for validation. Avoid full builds.**
-
-- Do not run `bun build` or `npm run build` solely to check for type errors.
-- Use `npx tsc --noEmit` instead for fast and focused type validation.
-
-## 7. Docker Compose
-
-**Standardize on Docker Compose. Avoid standalone Docker commands.**
-
-- Use Docker Compose exclusively for container management.
-- Map external ports explicitly to the internal application port `3000` (e.g., `"8710:3000"`).
-- Keep the `docker-compose.yml` file clean and well-organized.
-
-## 8. Commands
-
-**Avoid inline code execution.**
-
-- Do not pass code strings directly to execution tools (e.g., `bun -e 'console.log("hello world")'`).
-- Always write scripts to dedicated files before executing them.
-
-## 9. Commit Messages
+## 6. Commit Messages
**Use Conventional Commits with a scope.**
@@ -106,28 +85,6 @@ Examples:
Prefer common types such as `feat`, `fix`, `test`, `docs`, `refactor`, `chore`, and `style`. Use a concise scope that names the affected area, such as `form`, `auth`, `webhook`, `leaderboard`, or `ui`.
-## 10. Database
-
-**Use standard tools for external databases.**
-
-- Assume the use of an external database for persistent storage.
-- Use Drizzle ORM exclusively for database interactions.
-
-## 11. Explicit Approval for Plans
-
-**Achieve alignment before execution.**
-
-- Ensure the implementation plan is completely clear and mutually understood.
-- Do not begin executing the plan until explicit approval (e.g., "let's do it") is given by the user.
-
-## 12. Communication Style
-
-**Be concise and professional. No fluff.**
-
-- Keep responses short, direct, and straightforward.
-- Do not use emojis, unnecessary pleasantries, or filler words.
-- Maintain a clean and simple text format.
-
---
## Reference Files
@@ -140,6 +97,3 @@ Load only the reference files relevant to the task:
| Shared libraries, follower fetchers, and database schema files | `references/libraries-and-data.md` |
| App Router routes, server actions, and API endpoints | `references/routes-and-actions.md` |
| UI components, config, Docker, and infrastructure files | `references/components-and-infra.md` |
-| Auth model, access control, and performance-sensitive behavior | `references/behavior.md` |
-
-Keep detailed project maps in these references rather than expanding `SKILL.md`.
diff --git a/skill/erika/references/project.md b/skill/erika/references/project.md
index 1318628..274815f 100644
--- a/skill/erika/references/project.md
+++ b/skill/erika/references/project.md
@@ -15,25 +15,3 @@ Erika is a Next.js website for the content creator "Erika!". It has two main fea
- **UI**: ShadCN components + Tailwind CSS
- **Fonts**: Anuphan for Thai, Geist Sans for Latin
- **Deployment**: Docker Compose + Nginx reverse proxy
-
-## Environment Variables
-
-| Variable | Purpose |
-|---|---|
-| `DATABASE_URL` | PostgreSQL connection string |
-| `NEXTAUTH_SECRET` | Secret for signing NextAuth sessions |
-| `NEXTAUTH_URL` | Canonical URL of the site, such as `https://erika.sudloh.com` |
-| `REDIS_URL` | Redis connection string, used for follower-count caching and server-sent events |
-| `BASE_URL` | Public base URL, used in metadata/SEO and generated links |
-| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | Discord OAuth app credentials for user login |
-| `ADMIN_DISCORD_IDS` | Comma-separated Discord user IDs with admin access |
-| `CRON_SECRET` | Secret for the automated `/api/cron/tiktok` polling endpoint |
-| `DISCORD_BOT_TOKEN` | Bot token used to fetch Discord server member count |
-| `DISCORD_GUILD_ID` | Discord server ID to fetch member count from |
-| `YOUTUBE_API_KEY` / `YOUTUBE_CHANNEL_ID` | YouTube Data API credentials for subscriber count |
-| `ROBLOX_USER_ID` | Roblox user ID to fetch follower count |
-| `TIKTOK_CLIENT_KEY` / `TIKTOK_CLIENT_SECRET` | TikTok Official API credentials |
-| `TIKTOK_ACCESS_TOKEN` / `TIKTOK_REFRESH_TOKEN` / `TIKTOK_OPEN_ID` | TikTok OAuth tokens from one-time setup at `/api/auth/tiktok` |
-| `YOUTUBE_CLIENT_ID` / `YOUTUBE_CLIENT_SECRET` | YouTube OAuth application credentials |
-| `YOUTUBE_REFRESH_TOKEN` | YouTube OAuth refresh token from `/api/auth/youtube` |
-| `VIDEO_UPLOAD_DIR` | Optional override for the mounted video directory; defaults to `/nfs/erika` |
diff --git a/skill/erika/references/routes-and-actions.md b/skill/erika/references/routes-and-actions.md
deleted file mode 100644
index 1dd8eac..0000000
--- a/skill/erika/references/routes-and-actions.md
+++ /dev/null
@@ -1,70 +0,0 @@
-# Routes And Actions
-
-## Root And Global
-
-| File | Purpose |
-|---|---|
-| `app/layout.tsx` | Root layout with fonts, theme provider, tooltip provider, toaster, page progress, and SEO metadata |
-| `app/page.tsx` | Linktree landing page with avatar, social links, follower counts, and video background |
-| `app/globals.css` | Global CSS styles |
-
-## `/form` User-Facing Forms
-
-| File | Purpose |
-|---|---|
-| `app/form/page.tsx` | Lists open forms for users |
-| `app/form/layout.tsx` | Form layout with header, login state, theme toggle, and login dialog |
-| `app/form/client.tsx` | Client-side form components and submission UX |
-| `app/form/[id]/page.tsx` | Individual submission page; enforces Discord role access |
-| `app/form/[id]/layout.tsx` | Layout for individual form pages |
-
-## `/admin` Admin Panel
-
-| File | Purpose |
-|---|---|
-| `app/admin/layout.tsx` | Centralized admin guard for all `/admin/*` routes |
-| `app/admin/page.tsx` | Admin dashboard homepage with Form and Backend cards |
-
-## `/admin/form` Form Management
-
-| File | Purpose |
-|---|---|
-| `app/admin/form/page.tsx` | Admin form list with create/edit/delete actions |
-| `app/admin/form/actions.ts` | Admin-protected form CRUD and settings actions |
-| `app/admin/form/[id]/layout.tsx` | Form editor layout with form-specific sidebar |
-| `app/admin/form/[id]/page.tsx` | Admin form editor page |
-| `app/admin/form/[id]/client.tsx` | Client-side question builder, drag-and-drop, and settings |
-| `app/admin/form/[id]/result/` | Admin form submission results |
-| `app/admin/form/[id]/extra/` | Discord webhook templates and role-based access control settings |
-
-## `/admin/backend` Backend Settings
-
-| File | Purpose |
-|---|---|
-| `app/admin/backend/page.tsx` | Backend settings page; auto-discovers `lib/followers/` platform files and reads cached counts |
-| `app/admin/backend/client.tsx` | Platform cards with cached counts and refetch buttons |
-| `app/admin/backend/actions.ts` | `refetchPlatform()` dynamically imports fetchers and updates Redis cache |
-
-## `/discord`
-
-| File | Purpose |
-|---|---|
-| `app/discord/page.tsx` | Redirects to the Discord server invite |
-
-## `/api`
-
-| File | Purpose |
-|---|---|
-| `app/api/auth/[...nextauth]/route.ts` | NextAuth.js route with Discord OAuth provider |
-| `app/api/auth/tiktok/route.ts` | Starts TikTok OAuth setup |
-| `app/api/auth/tiktok/callback/route.ts` | TikTok OAuth callback handler |
-| `app/api/cron/tiktok/route.ts` | TikTok polling endpoint; checks TikWM and notifies Discord |
-| `app/api/upload/route.ts` | Admin-only file upload endpoint |
-
-## `app/actions/` Server Actions
-
-| File | Purpose |
-|---|---|
-| `app/actions/discord.ts` | Admin-protected Discord role loading |
-| `app/actions/questions.ts` | Admin-protected question CRUD within a form |
-| `app/actions/submissions.ts` | Form submission, update, deletion, and webhook notification actions |