Initial commit: Interactives site
Astro-based site with 40+ interactive math games, puzzles, and explorations. Includes admin dashboard, todo system, blog, theme/subcategory navigation, and embed pages for iframe embedding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c5926b4213
265 changed files with 51899 additions and 0 deletions
344
src/components/AdminBar.tsx
Normal file
344
src/components/AdminBar.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Shield, StickyNote, ChevronDown, ChevronUp, Save, Check,
|
||||
Loader2, Plus, Trash2, ListTodo, MessageSquare,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
isAdminLoggedIn,
|
||||
getNote,
|
||||
setNote,
|
||||
getStatusOverride,
|
||||
setStatusOverride,
|
||||
commitStatus,
|
||||
syncFromServer,
|
||||
getTodos,
|
||||
addTodo,
|
||||
toggleTodo,
|
||||
removeTodo,
|
||||
updateTodoRemark,
|
||||
type TodoItem,
|
||||
} from "@/lib/admin";
|
||||
import type { InteractiveStatus } from "@/lib/interactives";
|
||||
|
||||
interface AdminBarProps {
|
||||
slug: string;
|
||||
currentStatus: InteractiveStatus;
|
||||
}
|
||||
|
||||
const statusOptions: { value: InteractiveStatus; label: string; color: string }[] = [
|
||||
{ value: "published", label: "Published", color: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" },
|
||||
{ value: "draft", label: "Draft", color: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" },
|
||||
];
|
||||
|
||||
const TodoRow: React.FC<{
|
||||
todo: TodoItem;
|
||||
slug: string;
|
||||
onToggle: () => void;
|
||||
onRemove: () => void;
|
||||
onRemarkChange: (remark: string) => void;
|
||||
}> = ({ todo, slug, onToggle, onRemove, onRemarkChange }) => {
|
||||
const [showRemark, setShowRemark] = useState(false);
|
||||
const [remarkText, setRemarkText] = useState(todo.remark || "");
|
||||
|
||||
const handleRemarkBlur = () => {
|
||||
if (remarkText !== (todo.remark || "")) {
|
||||
onRemarkChange(remarkText);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="group/todo">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
checked={todo.done}
|
||||
onCheckedChange={onToggle}
|
||||
className="shrink-0 mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-mono text-primary/60">{todo.refId}</span>
|
||||
<span className={`text-sm ${todo.done ? "line-through text-muted-foreground" : ""}`}>
|
||||
{todo.text}
|
||||
</span>
|
||||
</div>
|
||||
{todo.done && todo.doneDate && (
|
||||
<span className="text-xs text-green-600">Done {todo.doneDate}</span>
|
||||
)}
|
||||
{todo.remark && !showRemark && (
|
||||
<p className="text-xs text-muted-foreground italic mt-0.5">{todo.remark}</p>
|
||||
)}
|
||||
{showRemark && (
|
||||
<Input
|
||||
value={remarkText}
|
||||
onChange={(e) => setRemarkText(e.target.value)}
|
||||
onBlur={handleRemarkBlur}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { handleRemarkBlur(); setShowRemark(false); } }}
|
||||
placeholder="Add a remark..."
|
||||
className="text-xs h-7 mt-1"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-0.5 opacity-0 group-hover/todo:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
onClick={() => setShowRemark((v) => !v)}
|
||||
className="p-1 text-muted-foreground hover:text-foreground"
|
||||
title="Add remark"
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminBar: React.FC<AdminBarProps> = ({ slug, currentStatus }) => {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [status, setStatus] = useState<InteractiveStatus>(currentStatus);
|
||||
const [noteText, setNoteText] = useState("");
|
||||
const [hasOverride, setHasOverride] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [todos, setTodos] = useState<TodoItem[]>([]);
|
||||
const [newTodo, setNewTodo] = useState("");
|
||||
|
||||
const refreshTodos = () => setTodos(getTodos(slug));
|
||||
|
||||
useEffect(() => {
|
||||
const admin = isAdminLoggedIn();
|
||||
setIsAdmin(admin);
|
||||
if (admin) {
|
||||
syncFromServer().finally(() => {
|
||||
const override = getStatusOverride(slug);
|
||||
if (override) {
|
||||
setStatus(override);
|
||||
setHasOverride(true);
|
||||
}
|
||||
setNoteText(getNote(slug));
|
||||
refreshTodos();
|
||||
});
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const handleStatusChange = (newStatus: InteractiveStatus) => {
|
||||
setStatus(newStatus);
|
||||
setStatusOverride(slug, newStatus);
|
||||
setHasOverride(true);
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const handleNoteChange = (value: string) => {
|
||||
setNoteText(value);
|
||||
setNote(slug, value);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
const success = await commitStatus(slug, status);
|
||||
setSaving(false);
|
||||
if (success) {
|
||||
setHasOverride(false);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTodo = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newTodo.trim()) return;
|
||||
addTodo(slug, newTodo.trim());
|
||||
setNewTodo("");
|
||||
refreshTodos();
|
||||
};
|
||||
|
||||
const handleToggleTodo = (todoId: string) => {
|
||||
toggleTodo(slug, todoId);
|
||||
refreshTodos();
|
||||
};
|
||||
|
||||
const handleRemoveTodo = (todoId: string) => {
|
||||
removeTodo(slug, todoId);
|
||||
refreshTodos();
|
||||
};
|
||||
|
||||
const handleRemarkChange = (todoId: string, remark: string) => {
|
||||
updateTodoRemark(slug, todoId, remark);
|
||||
refreshTodos();
|
||||
};
|
||||
|
||||
const isDirty = hasOverride;
|
||||
const doneCount = todos.filter((t) => t.done).length;
|
||||
const hasTodos = todos.length > 0;
|
||||
const openTodos = todos.filter((t) => !t.done);
|
||||
const doneTodos = todos.filter((t) => t.done);
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border-2 border-dashed border-primary/15 bg-primary/[0.02]">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-primary font-medium">
|
||||
<Shield className="w-4 h-4" />
|
||||
Admin
|
||||
{isDirty && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
unsaved
|
||||
</Badge>
|
||||
)}
|
||||
{saved && (
|
||||
<Badge variant="outline" className="text-xs text-green-600 border-green-300">
|
||||
saved
|
||||
</Badge>
|
||||
)}
|
||||
{noteText && (
|
||||
<StickyNote className="w-3.5 h-3.5 text-yellow-600" />
|
||||
)}
|
||||
{hasTodos && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ListTodo className="w-3.5 h-3.5" />
|
||||
{doneCount}/{todos.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
statusOptions.find((o) => o.value === status)?.color || ""
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 space-y-4">
|
||||
{/* Status control */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-2 block">
|
||||
Status
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-2">
|
||||
{statusOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleStatusChange(opt.value)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||
status === opt.value
|
||||
? `${opt.color} ring-2 ring-offset-1 ring-current`
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saving}
|
||||
className="gap-1.5 h-7 text-xs ml-2"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : saved ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<Save className="w-3 h-3" />
|
||||
)}
|
||||
{saving ? "Saving..." : saved ? "Saved" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Todos */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-2 block">
|
||||
Todos {hasTodos && <span className="text-muted-foreground/60">({doneCount}/{todos.length} done)</span>}
|
||||
</label>
|
||||
{openTodos.length > 0 && (
|
||||
<ul className="space-y-2 mb-2">
|
||||
{openTodos.map((todo) => (
|
||||
<TodoRow
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
slug={slug}
|
||||
onToggle={() => handleToggleTodo(todo.id)}
|
||||
onRemove={() => handleRemoveTodo(todo.id)}
|
||||
onRemarkChange={(r) => handleRemarkChange(todo.id, r)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{doneTodos.length > 0 && (
|
||||
<details className="mb-2">
|
||||
<summary className="text-xs text-muted-foreground cursor-pointer mb-1">
|
||||
{doneTodos.length} completed
|
||||
</summary>
|
||||
<ul className="space-y-2 opacity-60">
|
||||
{doneTodos.map((todo) => (
|
||||
<TodoRow
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
slug={slug}
|
||||
onToggle={() => handleToggleTodo(todo.id)}
|
||||
onRemove={() => handleRemoveTodo(todo.id)}
|
||||
onRemarkChange={(r) => handleRemarkChange(todo.id, r)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<form onSubmit={handleAddTodo} className="flex gap-2">
|
||||
<Input
|
||||
value={newTodo}
|
||||
onChange={(e) => setNewTodo(e.target.value)}
|
||||
placeholder="Add a todo... (use T1, T2 to reference others)"
|
||||
className="text-sm h-8"
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="outline" className="h-8 px-2" disabled={!newTodo.trim()}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-2 block">
|
||||
Notes
|
||||
</label>
|
||||
<Textarea
|
||||
value={noteText}
|
||||
onChange={(e) => handleNoteChange(e.target.value)}
|
||||
placeholder="Add private notes about this interactive..."
|
||||
rows={3}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminBar;
|
||||
Loading…
Add table
Add a link
Reference in a new issue