Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/components/MaintenanceGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRouterState } from "@tanstack/react-router";
import { Wrench } from "lucide-react";
import { supabase } from "@/integrations/supabase/client";

const MAINTENANCE_SETTING_KEY = "site.maintenance_mode";

function isAdminOrAuthPath(pathname: string) {
return /^\/(?:auth|mfa-verify|dashboard|admin)(?:\/|$)/.test(pathname);
}

export function MaintenanceGate({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (state) => state.location.pathname });
const bypass = isAdminOrAuthPath(pathname);
const maintenance = useQuery({
queryKey: ["site-maintenance-mode"],
enabled: !bypass,
queryFn: async () => {
const { data, error } = await supabase
.from("site_settings")
.select("value")
.eq("key", MAINTENANCE_SETTING_KEY)
.maybeSingle();
if (error) throw error;
return data?.value === "true";
},
refetchInterval: 15_000,
retry: 1,
});

if (bypass || maintenance.isError) return children;
if (maintenance.isLoading) {
return (
<main className="flex min-h-screen items-center justify-center bg-[#0b0b14] px-6 text-sm text-white/60">
Checking CodeForge status…
</main>
);
}
if (!maintenance.data) return children;

return (
<main className="flex min-h-screen items-center justify-center bg-[#0b0b14] px-6 text-white">
<section className="w-full max-w-lg text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl border border-violet-400/20 bg-violet-400/10 text-violet-300">
<Wrench className="h-7 w-7" aria-hidden="true" />
</div>
<p className="mt-6 text-xs font-semibold uppercase tracking-[0.22em] text-violet-300">
CodeForge
</p>
<h1 className="mt-3 font-display text-3xl font-bold sm:text-4xl">We’ll be back soon</h1>
<p className="mt-4 text-sm leading-6 text-white/65">
CodeForge is temporarily unavailable while maintenance is in progress. Please check back
shortly.
</p>
</section>
</main>
);
}
7 changes: 5 additions & 2 deletions src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import appCss from "../styles.css?url";
import { AuthProvider } from "@/hooks/use-auth";
import { Toaster } from "@/components/ui/sonner";
import { EditModeProvider } from "@/contexts/EditModeContext";
import { MaintenanceGate } from "@/components/MaintenanceGate";

function NotFoundComponent() {
return (
Expand Down Expand Up @@ -136,11 +137,13 @@ function RootComponent() {
<QueryClientProvider client={queryClient}>
<AuthProvider>
<EditModeProvider>
<Outlet />
<MaintenanceGate>
<Outlet />
</MaintenanceGate>
<Toaster richColors theme="dark" />
<Analytics />
</EditModeProvider>
</AuthProvider>
</QueryClientProvider>
);
}
}
12 changes: 11 additions & 1 deletion src/routes/_authenticated/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,17 @@ function WebsiteSettings({ settings, refetch, onActivity }: { settings: Array<{
const [key, setKey] = useState(""); const [value, setValue] = useState(""); const [editingKey, setEditingKey] = useState<string | null>(null);
const save = async () => { if (!key.trim()) return toast.error("Setting key is required"); const result = editingKey ? await supabase.from("site_settings").update({ value }).eq("key", editingKey) : await supabase.from("site_settings").upsert({ key: key.trim(), value }); if (result.error) return toast.error(result.error.message); await onActivity(editingKey ? "update" : "create", "site_setting", key); toast.success("Setting saved"); setKey(""); setValue(""); setEditingKey(null); await refetch(); };
const remove = async (settingKey: string) => { if (!window.confirm(`Delete setting '${settingKey}'?`)) return; const { error } = await supabase.from("site_settings").delete().eq("key", settingKey); if (error) return toast.error(error.message); await onActivity("delete", "site_setting", settingKey); toast.success("Setting deleted"); await refetch(); };
return <Panel title="Website settings" description="Manage key/value settings used by CodeForge without changing application code."><div className="grid gap-4 rounded-2xl border border-border/30 p-4 md:grid-cols-[1fr_2fr_auto]"><Field label="Key"><Input value={key} onChange={(e) => setKey(e.target.value)} placeholder="homepage.hero_title" className="rounded-xl" /></Field><Field label="Value"><Input value={value} onChange={(e) => setValue(e.target.value)} placeholder="Setting value" className="rounded-xl" /></Field><div className="flex items-end gap-2"><Button onClick={() => void save()} className="rounded-xl">{editingKey ? "Update" : "Save"}</Button>{editingKey && <Button variant="outline" onClick={() => { setEditingKey(null); setKey(""); setValue(""); }} className="rounded-xl"><X className="h-4 w-4" /></Button>}</div></div><div className="mt-6 divide-y divide-border/30">{settings.map((s) => <div key={s.key} className="flex flex-wrap items-center justify-between gap-3 py-3"><div><div className="font-mono text-sm">{s.key}</div><div className="mt-1 max-w-3xl truncate text-sm text-muted-foreground">{formatSettingPreview(s.value)}</div></div><div className="flex gap-2"><Button size="sm" variant="outline" className="rounded-lg" onClick={() => { setEditingKey(s.key); setKey(s.key); setValue(s.value || ""); }}><Pencil className="h-3.5 w-3.5" /></Button><Button size="sm" variant="outline" className="rounded-lg text-destructive" onClick={() => void remove(s.key)}><Trash className="h-3.5 w-3.5" /></Button></div></div>)}{settings.length === 0 && <Empty label="No site settings yet." />}</div></Panel>;
const maintenanceEnabled = settings.find((setting) => setting.key === "site.maintenance_mode")?.value === "true";
const toggleMaintenance = async () => {
const nextEnabled = !maintenanceEnabled;
if (nextEnabled && !window.confirm("Turn on Maintenance Mode? Visitors will see the maintenance notice until you turn it off.")) return;
const { error } = await supabase.from("site_settings").upsert({ key: "site.maintenance_mode", value: String(nextEnabled) });
if (error) return toast.error(error.message);
await onActivity("update", "site_setting", "site.maintenance_mode");
toast.success(nextEnabled ? "Maintenance Mode is on" : "Maintenance Mode is off");
await refetch();
};
return <div className="space-y-6"><Panel title="Maintenance Mode" description="Temporarily show a maintenance notice to visitors while you work on CodeForge."><div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-border/30 p-4"><div><div className="font-medium">Status: <span className={maintenanceEnabled ? "text-amber-400" : "text-emerald-400"}>{maintenanceEnabled ? "On" : "Off"}</span></div><p className="mt-1 max-w-2xl text-sm text-muted-foreground">When on, public pages show a maintenance notice. Admin, sign-in, and MFA pages remain available so you can turn it off.</p></div><Button variant={maintenanceEnabled ? "destructive" : "default"} className="rounded-xl" onClick={() => void toggleMaintenance()}>{maintenanceEnabled ? "Turn off Maintenance Mode" : "Turn on Maintenance Mode"}</Button></div></Panel><Panel title="Website settings" description="Manage key/value settings used by CodeForge without changing application code."><div className="grid gap-4 rounded-2xl border border-border/30 p-4 md:grid-cols-[1fr_2fr_auto]"><Field label="Key"><Input value={key} onChange={(e) => setKey(e.target.value)} placeholder="homepage.hero_title" className="rounded-xl" /></Field><Field label="Value"><Input value={value} onChange={(e) => setValue(e.target.value)} placeholder="Setting value" className="rounded-xl" /></Field><div className="flex items-end gap-2"><Button onClick={() => void save()} className="rounded-xl">{editingKey ? "Update" : "Save"}</Button>{editingKey && <Button variant="outline" onClick={() => { setEditingKey(null); setKey(""); setValue(""); }} className="rounded-xl"><X className="h-4 w-4" /></Button>}</div></div><div className="mt-6 divide-y divide-border/30">{settings.map((s) => <div key={s.key} className="flex flex-wrap items-center justify-between gap-3 py-3"><div><div className="font-mono text-sm">{s.key}</div><div className="mt-1 max-w-3xl truncate text-sm text-muted-foreground">{formatSettingPreview(s.value)}</div></div><div className="flex gap-2"><Button size="sm" variant="outline" className="rounded-lg" onClick={() => { setEditingKey(s.key); setKey(s.key); setValue(s.value || ""); }}><Pencil className="h-3.5 w-3.5" /></Button><Button size="sm" variant="outline" className="rounded-lg text-destructive" onClick={() => void remove(s.key)}><Trash className="h-3.5 w-3.5" /></Button></div></div>)}{settings.length === 0 && <Empty label="No site settings yet." />}</div></Panel></div>;
}

function MediaLibrary({ files, refetch, onActivity }: { files: MediaFile[]; refetch: () => Promise<unknown>; onActivity: (a: string, r: string, id?: string) => Promise<void>; }) {
Expand Down
Loading