feat(m5a): channel manager, tabbed settings panel, per-user sync status

Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
This commit is contained in:
npeter83 2026-06-11 20:45:48 +02:00
parent b8a8605797
commit a8822d3935
11 changed files with 1063 additions and 45 deletions

View file

@ -0,0 +1,261 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowDown,
ArrowUp,
Eye,
EyeOff,
Plus,
RefreshCw,
Search,
X,
} from "lucide-react";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import { notify } from "../lib/notifications";
export default function Channels({
onViewChannel,
}: {
onViewChannel: (id: string, name: string) => void;
}) {
const qc = useQueryClient();
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const [q, setQ] = useState("");
const [newTag, setNewTag] = useState("");
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
};
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
const patch = useMutation({
mutationFn: (v: { id: string; body: { priority?: number; hidden?: boolean } }) =>
api.updateChannel(v.id, v.body),
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
});
const attach = useMutation({
mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
});
const detach = useMutation({
mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
});
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
invalidate();
notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` });
},
onError: () => notify({ level: "error", message: "Subscription sync failed" }),
});
const createTag = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
onSuccess: () => {
setNewTag("");
qc.invalidateQueries({ queryKey: ["tags"] });
},
});
const deleteTag = useMutation({
mutationFn: (id: number) => api.deleteTag(id),
onSuccess: () => invalidate(),
});
const channels = (channelsQuery.data ?? []).filter(
(c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase())
);
const s = statusQuery.data;
return (
<div className="p-4 max-w-4xl mx-auto">
{/* Per-user sync status */}
{s && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
<Stat label="Channels" value={s.channels_total} />
<Stat label="Recent synced" value={`${s.channels_recent_synced}/${s.channels_total}`} />
<Stat label="Full history" value={`${s.channels_deep_done}/${s.channels_total}`} />
<Stat label="My videos" value={s.my_videos.toLocaleString()} />
<Stat label="Quota left" value={s.quota_remaining_today.toLocaleString()} />
</div>
)}
{/* Toolbar */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Filter channels…"
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
/>
</div>
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
Sync subscriptions
</button>
</div>
{/* Your tags */}
<div className="flex flex-wrap items-center gap-1.5 mb-4">
<span className="text-xs uppercase tracking-wide text-muted mr-1">Your tags</span>
{userTags.map((t) => (
<span
key={t.id}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-card border border-border"
>
{t.name}
<button onClick={() => deleteTag.mutate(t.id)} className="text-muted hover:text-red-400">
<X className="w-3 h-3" />
</button>
</span>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (newTag.trim()) createTag.mutate(newTag.trim());
}}
className="inline-flex items-center gap-1"
>
<input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="new tag"
className="w-24 bg-card border border-border rounded-full px-2.5 py-1 text-xs outline-none focus:border-accent"
/>
<button type="submit" className="text-muted hover:text-accent" title="Create tag">
<Plus className="w-4 h-4" />
</button>
</form>
</div>
{/* Channel list */}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">Loading channels</div>
) : channels.length === 0 ? (
<div className="text-muted py-8">No channels.</div>
) : (
<div className="flex flex-col gap-1.5">
{channels.map((c) => (
<ChannelRow
key={c.id}
c={c}
userTags={userTags}
onView={() => onViewChannel(c.id, c.title ?? "This channel")}
onPriority={(d) => patch.mutate({ id: c.id, body: { priority: c.priority + d } })}
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
onToggleTag={(tagId) =>
c.tag_ids.includes(tagId)
? detach.mutate({ id: c.id, tagId })
: attach.mutate({ id: c.id, tagId })
}
/>
))}
</div>
)}
</div>
);
}
function Stat({ label, value }: { label: string; value: string | number }) {
return (
<span>
<span className="text-fg font-semibold">{value}</span> {label}
</span>
);
}
function SyncBadge({ ok, label }: { ok: boolean; label: string }) {
return (
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full border ${
ok ? "border-accent/40 text-accent" : "border-border text-muted"
}`}
>
{label}
</span>
);
}
function ChannelRow({
c,
userTags,
onView,
onPriority,
onHide,
onToggleTag,
}: {
c: ManagedChannel;
userTags: Tag[];
onView: () => void;
onPriority: (delta: number) => void;
onHide: () => void;
onToggleTag: (tagId: number) => void;
}) {
return (
<div
className={`flex items-center gap-3 p-2.5 rounded-xl border border-border bg-card/30 ${
c.hidden ? "opacity-60" : ""
}`}
>
<div className="flex flex-col items-center">
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" title="Raise priority">
<ArrowUp className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" title="Lower priority">
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
{c.thumbnail_url ? (
<img src={c.thumbnail_url} alt="" className="w-10 h-10 rounded-full shrink-0" />
) : (
<div className="w-10 h-10 rounded-full bg-card shrink-0" />
)}
<div className="min-w-0 flex-1">
<button onClick={onView} className="text-sm font-semibold truncate hover:text-accent block max-w-full text-left">
{c.title ?? c.id}
</button>
<div className="flex items-center gap-2 text-[11px] text-muted">
<span>{c.stored_videos.toLocaleString()} stored</span>
{c.subscriber_count != null && <span>· {c.subscriber_count.toLocaleString()} subs</span>}
</div>
<div className="flex flex-wrap items-center gap-1 mt-1">
<SyncBadge ok={c.recent_synced} label="recent" />
<SyncBadge ok={c.backfill_done} label="full" />
{userTags.map((t) => {
const on = c.tag_ids.includes(t.id);
return (
<button
key={t.id}
onClick={() => onToggleTag(t.id)}
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
on
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t.name}
</button>
);
})}
</div>
</div>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" title={c.hidden ? "Unhide" : "Hide from feed"}>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
);
}

View file

@ -1,16 +1,20 @@
import { useRef, useState } from "react";
import {
Home,
LayoutGrid,
List,
LogOut,
Moon,
Palette,
Search,
Settings,
Shield,
Sun,
Tv,
} from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import SyncStatus from "./SyncStatus";
import NotificationCenter from "./NotificationCenter";
@ -72,6 +76,9 @@ export default function Header({
setFilters,
view,
setView,
page,
setPage,
onOpenSettings,
}: {
me: Me;
theme: ThemePrefs;
@ -80,6 +87,9 @@ export default function Header({
setFilters: (f: FeedFilters) => void;
view: "grid" | "list";
setView: (v: "grid" | "list") => void;
page: Page;
setPage: (p: Page) => void;
onOpenSettings: () => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
@ -90,29 +100,39 @@ export default function Header({
return (
<header className="h-14 shrink-0 border-b border-border bg-surface/80 backdrop-blur flex items-center gap-3 px-4 z-20">
<div className="text-xl font-bold tracking-tight select-none">
<button
onClick={() => setPage("feed")}
className="text-xl font-bold tracking-tight select-none"
title="Feed"
>
Sub<span className="text-accent">feed</span>
</div>
</button>
<SyncStatus />
<div className="flex-1 max-w-xl mx-auto relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={filters.q}
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
placeholder="Search your subscriptions…"
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
/>
</div>
{page === "feed" ? (
<div className="flex-1 max-w-xl mx-auto relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={filters.q}
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
placeholder="Search your subscriptions…"
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
/>
</div>
) : (
<div className="flex-1 text-center text-sm font-semibold">Channel manager</div>
)}
<div className="flex items-center gap-1">
<IconBtn
onClick={() => setView(view === "grid" ? "list" : "grid")}
title={view === "grid" ? "List view" : "Grid view"}
>
{view === "grid" ? <List className="w-5 h-5" /> : <LayoutGrid className="w-5 h-5" />}
</IconBtn>
{page === "feed" && (
<IconBtn
onClick={() => setView(view === "grid" ? "list" : "grid")}
title={view === "grid" ? "List view" : "Grid view"}
>
{view === "grid" ? <List className="w-5 h-5" /> : <LayoutGrid className="w-5 h-5" />}
</IconBtn>
)}
<IconBtn
onClick={() => setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })}
title="Toggle dark / light"
@ -134,7 +154,13 @@ export default function Header({
<NotificationCenter filters={filters} setFilters={setFilters} />
<div className="pl-1">
<AccountMenu me={me} logout={logout} />
<AccountMenu
me={me}
logout={logout}
page={page}
setPage={setPage}
onOpenSettings={onOpenSettings}
/>
</div>
</div>
</header>
@ -151,7 +177,19 @@ function Avatar({ me, className = "" }: { me: Me; className?: string }) {
);
}
function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
function AccountMenu({
me,
logout,
page,
setPage,
onOpenSettings,
}: {
me: Me;
logout: () => void;
page: Page;
setPage: (p: Page) => void;
onOpenSettings: () => void;
}) {
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -163,6 +201,9 @@ function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
closeTimer.current = setTimeout(() => setOpen(false), 220);
}
const itemClass =
"w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition";
return (
<div
className="relative"
@ -196,13 +237,27 @@ function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
</div>
)}
<button
onClick={logout}
className="mt-2 w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
Sign out
</button>
<div className="mt-2 flex flex-col">
{page === "channels" ? (
<button onClick={() => { setPage("feed"); setOpen(false); }} className={itemClass}>
<Home className="w-4 h-4" />
Feed
</button>
) : (
<button onClick={() => { setPage("channels"); setOpen(false); }} className={itemClass}>
<Tv className="w-4 h-4" />
Channels
</button>
)}
<button onClick={() => { onOpenSettings(); setOpen(false); }} className={itemClass}>
<Settings className="w-4 h-4" />
Settings
</button>
<button onClick={logout} className={itemClass}>
<LogOut className="w-4 h-4" />
Sign out
</button>
</div>
</div>
)}
</div>

View file

@ -0,0 +1,312 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Monitor, Pause, Play, RefreshCw, User, X } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api";
import {
configureNotifications,
getNotifSettings,
notify,
type NotifSettings,
} from "../lib/notifications";
type TabId = "appearance" | "notifications" | "sync" | "account";
const TABS: { id: TabId; label: string; icon: typeof Monitor }[] = [
{ id: "appearance", label: "Appearance", icon: Monitor },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "sync", label: "Sync", icon: RefreshCw },
{ id: "account", label: "Account", icon: User },
];
export default function SettingsPanel({
me,
theme,
setTheme,
view,
setView,
onClose,
}: {
me: Me;
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;
view: "grid" | "list";
setView: (v: "grid" | "list") => void;
onClose: () => void;
}) {
const [tab, setTab] = useState<TabId>("appearance");
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-40 flex justify-end">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative w-[min(92vw,440px)] h-full bg-surface border-l border-border shadow-2xl flex flex-col animate-[fadeIn_0.15s_ease]">
<div className="flex items-center justify-between px-4 h-14 border-b border-border shrink-0">
<div className="text-base font-semibold">Settings</div>
<button onClick={onClose} className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex gap-1 px-3 py-2 border-b border-border shrink-0 overflow-x-auto">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition ${
tab === t.id ? "bg-card text-fg" : "text-muted hover:text-fg"
}`}
>
<t.icon className="w-4 h-4" />
{t.label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4">
{tab === "appearance" && (
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
)}
{tab === "notifications" && <Notifications />}
{tab === "sync" && <Sync me={me} />}
{tab === "account" && <Account me={me} />}
</div>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
{children}
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex items-center justify-between gap-3 py-1.5 text-sm">
<span>{label}</span>
{children}
</label>
);
}
function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}
const PERF_KEY = "subfeed.perfMode";
function Appearance({
theme,
setTheme,
view,
setView,
}: {
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;
view: "grid" | "list";
setView: (v: "grid" | "list") => void;
}) {
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
useEffect(() => {
document.documentElement.dataset.perf = perf ? "1" : "";
}, [perf]);
function togglePerf(v: boolean) {
setPerf(v);
localStorage.setItem(PERF_KEY, v ? "1" : "0");
api.savePrefs({ performanceMode: v }).catch(() => {});
}
return (
<>
<Section title="Color scheme">
<div className="grid grid-cols-4 gap-2">
{SCHEMES.map((s) => (
<button
key={s.id}
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
title={s.name}
className={`h-9 rounded-lg border-2 transition ${
theme.scheme === s.id ? "border-fg" : "border-transparent"
}`}
style={{ background: s.swatch }}
/>
))}
</div>
</Section>
<Section title="Display">
<Row label="Dark mode">
<Switch
checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
</Row>
<Row label="List view">
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
</Row>
<Row label="Performance mode">
<Switch checked={perf} onChange={togglePerf} />
</Row>
</Section>
<Section title="Text size">
<input
type="range"
min={0.9}
max={1.3}
step={0.02}
value={theme.fontScale}
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
className="w-full accent-accent"
/>
<div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div>
</Section>
</>
);
}
function Notifications() {
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
function update(p: Partial<NotifSettings>) {
const next = { ...cfg, ...p };
setCfg(next);
configureNotifications(next);
api.savePrefs({ notifications: next }).catch(() => {});
}
return (
<Section title="Notifications">
<Row label="Sound on attention-needing alerts">
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
</Row>
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">
<span>Auto-dismiss</span>
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
</div>
<input
type="range"
min={3000}
max={15000}
step={1000}
value={cfg.durationMs}
onChange={(e) => update({ durationMs: Number(e.target.value) })}
className="w-full accent-accent mt-1"
/>
</div>
<button
onClick={() => notify({ level: "info", message: "This is a test notification." })}
className="mt-2 text-sm text-accent hover:underline"
>
Send a test notification
</button>
</Section>
);
}
function Sync({ me }: { me: Me }) {
const qc = useQueryClient();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const s = status.data;
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` });
},
onError: () => notify({ level: "error", message: "Sync failed" }),
});
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
});
return (
<>
<Section title="My sync status">
{s ? (
<div className="space-y-1.5 text-sm">
<Row label="Channels">{s.channels_total}</Row>
<Row label="Recent synced">{`${s.channels_recent_synced}/${s.channels_total}`}</Row>
<Row label="Full history">{`${s.channels_deep_done}/${s.channels_total}`}</Row>
<Row label="My videos">{s.my_videos.toLocaleString()}</Row>
<Row label="Quota left today">{s.quota_remaining_today.toLocaleString()}</Row>
</div>
) : (
<div className="text-muted text-sm">Loading</div>
)}
</Section>
<Section title="Actions">
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
Sync subscriptions
</button>
</Section>
{me.role === "admin" && s && (
<Section title="Admin">
<button
onClick={() => pauseResume.mutate(s.paused)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent transition"
>
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{s.paused ? "Resume background sync" : "Pause background sync"}
</button>
</Section>
)}
</>
);
}
function Account({ me }: { me: Me }) {
return (
<Section title="Account">
<div className="flex items-center gap-3 mb-3">
{me.avatar_url ? (
<img src={me.avatar_url} alt="" className="w-12 h-12 rounded-full" />
) : (
<div className="w-12 h-12 rounded-full bg-card grid place-items-center font-semibold">
{(me.display_name?.[0] ?? me.email[0] ?? "?").toUpperCase()}
</div>
)}
<div className="min-w-0">
<div className="font-semibold truncate">{me.display_name ?? me.email.split("@")[0]}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
<div className="text-xs text-muted capitalize">{me.role}</div>
</div>
</div>
<p className="text-xs text-muted leading-relaxed">
Playlist editing &amp; YouTube export, and the admin approval queue, arrive in the next
phases of this milestone.
</p>
</Section>
);
}