Files
silo-server/web/src/pages/ProfileCustomizeHome.tsx
T

314 lines
9.8 KiB
TypeScript

import { useEffect, useState } from "react";
import PageBack from "@/components/PageBack";
import ProfileSectionRow from "@/components/ProfileSectionRow";
import RecipeGalleryModal from "@/components/RecipeGallery/RecipeGalleryModal";
import RecipeConfigDrawer from "@/components/RecipeGallery/RecipeConfigDrawer";
import { api } from "@/api/client";
import type { GalleryPreset, RecipeDefinition } from "@/lib/recipes";
interface ProfileSection {
id: string;
is_custom: boolean;
section_type: string;
title: string;
hidden: boolean;
}
interface SectionsSettingsResponse {
sections?: Array<{
id: string;
is_custom?: boolean;
section_type: string;
title: string;
hidden?: boolean;
}>;
}
interface ProfileSectionsFlagResponse {
allow_profile_custom_sections?: boolean;
}
// RawOverride mirrors the JSON shape returned by GET /profile/sections —
// userstore.SectionOverride marshalled with no JSON tags, so fields appear in
// Go-PascalCase. We round-trip these through the page state so admin-section
// customizations (hide/title/etc.) and user-added rows (recipe + config + title)
// survive every save. The legacy approach of rebuilding the payload from the
// resolved `sections` view dropped overrides for any section the user hadn't
// just touched, and lost user_config / item_limit / featured on user-added rows.
interface RawOverride {
ID: string;
SectionID: string;
Position: number | null;
Hidden: boolean;
Removed: boolean;
SectionType: string;
Title: string;
Featured: boolean | null;
ItemLimit: number | null;
Config: string;
IsUserAdded: boolean;
UserSectionType: string;
UserConfig: string;
UserTitle: string;
}
interface RawOverridesResponse {
overrides?: RawOverride[];
}
function safeParseJSON(s: string): unknown {
if (!s) return undefined;
try {
return JSON.parse(s);
} catch {
return undefined;
}
}
// rawToWire converts a stored override into the snake_case shape expected by
// PUT /profile/sections (profileOverrideRequest). config / user_config are
// json.RawMessage on the server, so they must be sent as parsed JSON values.
function rawToWire(o: RawOverride) {
return {
id: o.ID || undefined,
section_id: o.SectionID,
position: o.Position ?? undefined,
hidden: o.Hidden,
removed: o.Removed,
section_type: o.SectionType,
title: o.Title,
featured: o.Featured ?? undefined,
item_limit: o.ItemLimit ?? undefined,
config: safeParseJSON(o.Config),
is_user_added: o.IsUserAdded,
user_section_type: o.UserSectionType,
user_config: safeParseJSON(o.UserConfig),
user_title: o.UserTitle,
};
}
export default function ProfileCustomizeHome() {
const [sections, setSections] = useState<ProfileSection[]>([]);
const [rawOverrides, setRawOverrides] = useState<RawOverride[]>([]);
const [galleryOpen, setGalleryOpen] = useState(false);
const [picked, setPicked] = useState<{ def: RecipeDefinition; preset: GalleryPreset } | null>(
null,
);
const [allowCustom, setAllowCustom] = useState(false);
async function load() {
// Fetch both:
// - /profile/sections/settings → resolved view (admin sections + user-added,
// including hidden) for the row list
// - /profile/sections → raw stored overrides, which we round-trip on save
// so unrelated overrides aren't dropped by full-replacement PUTs
try {
const [data, raw] = await Promise.all([
api<SectionsSettingsResponse>("/profile/sections/settings?scope=home"),
api<RawOverridesResponse>("/profile/sections?scope=home"),
]);
setSections(
(data?.sections ?? []).map((s) => ({
id: s.id,
is_custom: !!s.is_custom,
section_type: s.section_type,
title: s.title,
hidden: !!s.hidden,
})),
);
setRawOverrides(raw?.overrides ?? []);
} catch (err) {
console.error("load sections failed:", err);
setSections([]);
setRawOverrides([]);
}
}
async function loadSetting() {
try {
const j = await api<ProfileSectionsFlagResponse>("/profile/sections/flags");
setAllowCustom(!!j.allow_profile_custom_sections);
} catch {
// Setting just defaults to false on failure.
}
}
useEffect(() => {
void load();
void loadSetting();
}, []);
async function saveOverrides(
updates: Array<{ id: string; hidden?: boolean; removed?: boolean }>,
) {
// PUT /profile/sections is a full-replacement save for this profile+scope.
// Start from the existing stored overrides so unrelated customizations
// (admin-section hides, user-added recipes' config/title) are preserved,
// then mutate by id. The resolved section id matches an override's
// SectionID for admin customizations and ID for user-added rows.
const matches = (o: RawOverride, sectionID: string) =>
o.SectionID === sectionID || (o.SectionID === "" && o.ID === sectionID);
const merged: RawOverride[] = rawOverrides.map((o) => {
const u = updates.find((up) => matches(o, up.id));
if (!u) return o;
return {
...o,
Hidden: u.hidden ?? o.Hidden,
Removed: u.removed ?? o.Removed,
};
});
// For sections that have no existing override yet (admin sections still at
// their server defaults), synthesize a fresh admin-customization row.
for (const u of updates) {
if (merged.some((o) => matches(o, u.id))) continue;
const section = sections.find((s) => s.id === u.id);
if (!section || section.is_custom) continue; // user-added sections always have an override
merged.push({
ID: "",
SectionID: u.id,
Position: null,
Hidden: u.hidden ?? false,
Removed: u.removed ?? false,
SectionType: "",
Title: "",
Featured: null,
ItemLimit: null,
Config: "",
IsUserAdded: false,
UserSectionType: "",
UserConfig: "",
UserTitle: "",
});
}
try {
await api("/profile/sections", {
method: "PUT",
body: JSON.stringify({
scope: "home",
library_id: "",
overrides: merged.map(rawToWire),
}),
});
} catch (err) {
console.error("save overrides failed:", err);
}
void load();
}
async function reset() {
try {
await api("/profile/sections/reset?scope=home", { method: "DELETE" });
} catch (err) {
console.error("reset overrides failed:", err);
}
void load();
}
return (
<div className="relative mx-auto max-w-3xl p-6">
<PageBack />
<div className="mt-10 flex items-center justify-between border-b border-white/10 pb-3 sm:mt-12">
<h1 className="text-base font-semibold">Customize home</h1>
<div className="flex gap-2">
<button
type="button"
onClick={() => setGalleryOpen(true)}
className="rounded bg-indigo-600 px-3 py-1.5 text-sm text-white"
>
+ Add from Gallery
</button>
{allowCustom && (
<button type="button" className="rounded border border-white/15 px-3 py-1.5 text-sm">
+ Build Custom
</button>
)}
</div>
</div>
<div className="my-3 flex justify-end">
<button type="button" onClick={reset} className="text-xs underline opacity-65">
↻ Reset to server defaults
</button>
</div>
<div className="rounded-lg border border-white/10">
{sections.map((s) => (
<ProfileSectionRow
key={s.id}
kind={s.is_custom ? "yours" : "server-default"}
title={s.title}
sectionType={s.section_type}
hidden={s.hidden}
onHide={() => void saveOverrides([{ id: s.id, hidden: true }])}
onShow={() => void saveOverrides([{ id: s.id, hidden: false }])}
onEdit={() => {
// TODO: open the edit drawer for user-added recipes; out of scope here.
}}
onDelete={() => void saveOverrides([{ id: s.id, removed: true }])}
/>
))}
</div>
<RecipeGalleryModal
open={galleryOpen}
onClose={() => setGalleryOpen(false)}
onPick={(def, preset) => {
setGalleryOpen(false);
setPicked({ def, preset });
}}
/>
{picked && (
<RecipeConfigDrawer
def={picked.def}
preset={picked.preset}
showBulkApply={false}
onCancel={() => setPicked(null)}
onBackToGallery={() => {
setPicked(null);
setGalleryOpen(true);
}}
onAdd={async (payload) => {
// Append a new user-added row to the existing override set so admin
// customizations and prior custom sections aren't dropped by the
// full-replacement PUT.
const newRow: RawOverride = {
ID: "",
SectionID: "",
Position: null,
Hidden: false,
Removed: false,
SectionType: "",
Title: "",
Featured: payload.featured,
ItemLimit: payload.item_limit,
Config: "",
IsUserAdded: true,
UserSectionType: payload.section_type,
UserConfig: JSON.stringify(payload.config ?? {}),
UserTitle: payload.title ?? "",
};
try {
await api("/profile/sections", {
method: "PUT",
body: JSON.stringify({
scope: "home",
library_id: "",
overrides: [...rawOverrides, newRow].map(rawToWire),
}),
});
} catch (err) {
console.error("add section failed:", err);
}
setPicked(null);
void load();
}}
/>
)}
</div>
);
}