mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
466 lines
13 KiB
TypeScript
466 lines
13 KiB
TypeScript
import type { DragEndEvent } from "@dnd-kit/core";
|
|
import {
|
|
DndContext,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
TouchSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { Search as SearchIcon } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useFetcher, useLoaderData } from "react-router";
|
|
import { SendouButton } from "~/components/elements/Button";
|
|
import { Input } from "~/components/Input";
|
|
import { MainSlotIcon } from "~/components/icons/MainSlot";
|
|
import { SideSlotIcon } from "~/components/icons/SideSlot";
|
|
import { Placeholder } from "~/components/Placeholder";
|
|
import type { Tables } from "~/db/tables";
|
|
import {
|
|
ALL_WIDGETS,
|
|
defaultStoredWidget,
|
|
findWidgetById,
|
|
} from "~/features/user-page/core/widgets/portfolio";
|
|
import { USER } from "~/features/user-page/user-page-constants";
|
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
|
import { action } from "../actions/u.$identifier.edit-widgets.server";
|
|
import { WidgetSettingsForm } from "../components/WidgetSettingsForm";
|
|
import { loader } from "../loaders/u.$identifier.edit-widgets.server";
|
|
import styles from "./u.$identifier.edit-widgets.module.css";
|
|
|
|
export { action, loader };
|
|
|
|
export default function EditWidgetsPage() {
|
|
const { t } = useTranslation(["user", "common"]);
|
|
const data = useLoaderData<typeof loader>();
|
|
const isMounted = useIsMounted();
|
|
const fetcher = useFetcher();
|
|
|
|
const [selectedWidgets, setSelectedWidgets] = useState<
|
|
Array<Tables["UserWidget"]["widget"]>
|
|
>(data.currentWidgets);
|
|
const [expandedWidgetId, setExpandedWidgetId] = useState<string | null>(null);
|
|
|
|
const mainWidgets = selectedWidgets.filter((w) => {
|
|
const def = findWidgetById(w.id);
|
|
return def?.slot === "main";
|
|
});
|
|
|
|
const sideWidgets = selectedWidgets.filter((w) => {
|
|
const def = findWidgetById(w.id);
|
|
return def?.slot === "side";
|
|
});
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor),
|
|
useSensor(TouchSensor, {
|
|
activationConstraint: {
|
|
delay: 200,
|
|
tolerance: 5,
|
|
},
|
|
}),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
}),
|
|
);
|
|
|
|
const handleDragStart = () => {
|
|
setExpandedWidgetId(null);
|
|
};
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
|
|
if (!over || active.id === over.id) {
|
|
return;
|
|
}
|
|
|
|
const oldIndex = selectedWidgets.findIndex((w) => w.id === active.id);
|
|
const newIndex = selectedWidgets.findIndex((w) => w.id === over.id);
|
|
|
|
setSelectedWidgets(arrayMove(selectedWidgets, oldIndex, newIndex));
|
|
};
|
|
|
|
const addWidget = (widgetId: string) => {
|
|
const widget = findWidgetById(widgetId);
|
|
if (!widget) return;
|
|
|
|
const currentCount =
|
|
widget.slot === "main" ? mainWidgets.length : sideWidgets.length;
|
|
const maxCount =
|
|
widget.slot === "main" ? USER.MAX_MAIN_WIDGETS : USER.MAX_SIDE_WIDGETS;
|
|
|
|
if (currentCount >= maxCount) return;
|
|
|
|
const newWidget = defaultStoredWidget(widgetId);
|
|
|
|
setSelectedWidgets([...selectedWidgets, newWidget]);
|
|
|
|
const widgetDef = findWidgetById(widgetId);
|
|
if (widgetDef && "schema" in widgetDef) {
|
|
setExpandedWidgetId(widgetId);
|
|
}
|
|
};
|
|
|
|
const removeWidget = (widgetId: string) => {
|
|
setSelectedWidgets(selectedWidgets.filter((w) => w.id !== widgetId));
|
|
if (expandedWidgetId === widgetId) {
|
|
setExpandedWidgetId(null);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
fetcher.submit(
|
|
{ widgets: selectedWidgets } as unknown as Record<string, string>,
|
|
{ method: "post", encType: "application/json" },
|
|
);
|
|
};
|
|
|
|
const handleSettingsChange = (widgetId: string, settings: any) => {
|
|
setSelectedWidgets(
|
|
selectedWidgets.map((w) => (w.id === widgetId ? { ...w, settings } : w)),
|
|
);
|
|
};
|
|
|
|
const toggleExpanded = (widgetId: string) => {
|
|
setExpandedWidgetId(expandedWidgetId === widgetId ? null : widgetId);
|
|
};
|
|
|
|
if (!isMounted) {
|
|
return <Placeholder />;
|
|
}
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
<header className={styles.header}>
|
|
<h1>{t("user:widgets.editTitle")}</h1>
|
|
<div className={styles.actions}>
|
|
<SendouButton onPress={handleSubmit}>
|
|
{t("common:actions.save")}
|
|
</SendouButton>
|
|
</div>
|
|
</header>
|
|
|
|
<div className={styles.content}>
|
|
<div className={styles.grid}>
|
|
<section className={styles.selected}>
|
|
<DndContext
|
|
sensors={sensors}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SelectedWidgetsList
|
|
mainWidgets={mainWidgets}
|
|
sideWidgets={sideWidgets}
|
|
onRemoveWidget={removeWidget}
|
|
onSettingsChange={handleSettingsChange}
|
|
expandedWidgetId={expandedWidgetId}
|
|
onToggleExpanded={toggleExpanded}
|
|
/>
|
|
</DndContext>
|
|
</section>
|
|
|
|
<section className={styles.available}>
|
|
<h2>{t("user:widgets.available")}</h2>
|
|
<AvailableWidgetsList
|
|
selectedWidgets={selectedWidgets}
|
|
mainWidgets={mainWidgets}
|
|
sideWidgets={sideWidgets}
|
|
onAddWidget={addWidget}
|
|
/>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface AvailableWidgetsListProps {
|
|
selectedWidgets: Array<Tables["UserWidget"]["widget"]>;
|
|
mainWidgets: Array<Tables["UserWidget"]["widget"]>;
|
|
sideWidgets: Array<Tables["UserWidget"]["widget"]>;
|
|
onAddWidget: (widgetId: string) => void;
|
|
}
|
|
|
|
function AvailableWidgetsList({
|
|
selectedWidgets,
|
|
mainWidgets,
|
|
sideWidgets,
|
|
onAddWidget,
|
|
}: AvailableWidgetsListProps) {
|
|
const { t } = useTranslation(["user"]);
|
|
const [searchValue, setSearchValue] = useState("");
|
|
|
|
const widgetsByCategory = ALL_WIDGETS;
|
|
const categoryKeys = (
|
|
Object.keys(widgetsByCategory) as Array<keyof typeof widgetsByCategory>
|
|
).sort((a, b) => a.localeCompare(b));
|
|
|
|
const searchLower = searchValue.toLowerCase();
|
|
|
|
return (
|
|
<div>
|
|
<Input
|
|
className={styles.searchInput}
|
|
icon={<SearchIcon className={styles.searchIcon} />}
|
|
value={searchValue}
|
|
onChange={(e) => setSearchValue(e.target.value)}
|
|
placeholder={t("user:widgets.search")}
|
|
/>
|
|
{categoryKeys.map((category) => {
|
|
const filteredWidgets = widgetsByCategory[category]!.filter((widget) =>
|
|
(t(`user:widget.${widget.id}` as const) as string)
|
|
.toLowerCase()
|
|
.includes(searchLower),
|
|
);
|
|
|
|
if (filteredWidgets.length === 0) return null;
|
|
|
|
return (
|
|
<div key={category} className={styles.categoryGroup}>
|
|
<div className={styles.categoryTitle}>
|
|
{t(`user:widgets.category.${category}`)}
|
|
</div>
|
|
{filteredWidgets.map((widget) => {
|
|
const isSelected = selectedWidgets.some(
|
|
(w) => w.id === widget.id,
|
|
);
|
|
const currentCount =
|
|
widget.slot === "main"
|
|
? mainWidgets.length
|
|
: sideWidgets.length;
|
|
const maxCount =
|
|
widget.slot === "main"
|
|
? USER.MAX_MAIN_WIDGETS
|
|
: USER.MAX_SIDE_WIDGETS;
|
|
const isMaxReached = currentCount >= maxCount;
|
|
|
|
return (
|
|
<div key={widget.id} className={styles.widgetCard}>
|
|
<div className={styles.widgetHeader}>
|
|
<span className={styles.widgetName}>
|
|
{t(`user:widget.${widget.id}` as const)}
|
|
</span>
|
|
<SendouButton
|
|
size="miniscule"
|
|
variant="outlined"
|
|
onPress={() => onAddWidget(widget.id)}
|
|
isDisabled={isSelected || isMaxReached}
|
|
>
|
|
{t("user:widgets.add")}
|
|
</SendouButton>
|
|
</div>
|
|
<div className={styles.widgetFooter}>
|
|
<div className={styles.widgetSlot}>
|
|
{widget.slot === "main" ? (
|
|
<>
|
|
<MainSlotIcon size={16} />
|
|
<span>{t("user:widgets.main")}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<SideSlotIcon size={16} />
|
|
<span>{t("user:widgets.side")}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="text-xs font-bold">{"//"}</div>
|
|
<div className={styles.widgetDescription}>
|
|
{t(
|
|
`user:widgets.description.${widget.id}` as const,
|
|
widgetDescriptionParams(widget.id),
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SelectedWidgetsListProps {
|
|
mainWidgets: Array<Tables["UserWidget"]["widget"]>;
|
|
sideWidgets: Array<Tables["UserWidget"]["widget"]>;
|
|
onRemoveWidget: (widgetId: string) => void;
|
|
onSettingsChange: (widgetId: string, settings: any) => void;
|
|
expandedWidgetId: string | null;
|
|
onToggleExpanded: (widgetId: string) => void;
|
|
}
|
|
|
|
function SelectedWidgetsList({
|
|
mainWidgets,
|
|
sideWidgets,
|
|
onRemoveWidget,
|
|
onSettingsChange,
|
|
expandedWidgetId,
|
|
onToggleExpanded,
|
|
}: SelectedWidgetsListProps) {
|
|
const { t } = useTranslation(["user"]);
|
|
|
|
return (
|
|
<div className={styles.selectedWidgetsList}>
|
|
<div className={styles.slotSection}>
|
|
<div className={styles.slotHeader}>
|
|
<span className="stack horizontal xs">
|
|
<MainSlotIcon size={24} /> {t("user:widgets.mainSlot")}
|
|
</span>
|
|
<span className={styles.slotCount}>
|
|
{mainWidgets.length}/{USER.MAX_MAIN_WIDGETS}
|
|
</span>
|
|
</div>
|
|
<SortableContext items={mainWidgets.map((w) => w.id)}>
|
|
<div className={styles.widgetList}>
|
|
{mainWidgets.length === 0 ? (
|
|
<div className={styles.empty}>
|
|
{t("user:widgets.add")} {t("user:widgets.mainSlot")}
|
|
</div>
|
|
) : (
|
|
mainWidgets.map((widget) => (
|
|
<DraggableWidgetItem
|
|
key={widget.id}
|
|
widget={widget}
|
|
onRemove={onRemoveWidget}
|
|
onSettingsChange={onSettingsChange}
|
|
isExpanded={expandedWidgetId === widget.id}
|
|
onToggleExpanded={onToggleExpanded}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</SortableContext>
|
|
</div>
|
|
|
|
<div className={styles.slotSection}>
|
|
<div className={styles.slotHeader}>
|
|
<span className="stack horizontal xs">
|
|
<SideSlotIcon size={24} /> {t("user:widgets.sideSlot")}
|
|
</span>
|
|
<span className={styles.slotCount}>
|
|
{sideWidgets.length}/{USER.MAX_SIDE_WIDGETS}
|
|
</span>
|
|
</div>
|
|
<SortableContext items={sideWidgets.map((w) => w.id)}>
|
|
<div className={styles.widgetList}>
|
|
{sideWidgets.length === 0 ? (
|
|
<div className={styles.empty}>
|
|
{t("user:widgets.add")} {t("user:widgets.sideSlot")}
|
|
</div>
|
|
) : (
|
|
sideWidgets.map((widget) => (
|
|
<DraggableWidgetItem
|
|
key={widget.id}
|
|
widget={widget}
|
|
onRemove={onRemoveWidget}
|
|
onSettingsChange={onSettingsChange}
|
|
isExpanded={expandedWidgetId === widget.id}
|
|
onToggleExpanded={onToggleExpanded}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</SortableContext>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface DraggableWidgetItemProps {
|
|
widget: Tables["UserWidget"]["widget"];
|
|
onRemove: (widgetId: string) => void;
|
|
onSettingsChange: (widgetId: string, settings: any) => void;
|
|
isExpanded: boolean;
|
|
onToggleExpanded: (widgetId: string) => void;
|
|
}
|
|
|
|
function DraggableWidgetItem({
|
|
widget,
|
|
onRemove,
|
|
onSettingsChange,
|
|
isExpanded,
|
|
onToggleExpanded,
|
|
}: DraggableWidgetItemProps) {
|
|
const { t } = useTranslation(["user", "common"]);
|
|
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: widget.id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
};
|
|
|
|
const widgetDef = findWidgetById(widget.id);
|
|
const hasSettings = widgetDef && "schema" in widgetDef;
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={`${styles.draggableWidget} ${isDragging ? styles.isDragging : ""}`}
|
|
{...attributes}
|
|
>
|
|
<div className={styles.widgetHeader}>
|
|
<span className={styles.widgetName} {...listeners}>
|
|
☰ {t(`user:widget.${widget.id}` as const)}
|
|
</span>
|
|
<div className={styles.widgetActions}>
|
|
{hasSettings ? (
|
|
<SendouButton
|
|
size="miniscule"
|
|
variant="outlined"
|
|
onPress={() => onToggleExpanded(widget.id)}
|
|
>
|
|
{isExpanded
|
|
? t("common:actions.hide")
|
|
: t("common:actions.settings")}
|
|
</SendouButton>
|
|
) : null}
|
|
<SendouButton
|
|
size="miniscule"
|
|
variant="minimal-destructive"
|
|
onPress={() => onRemove(widget.id)}
|
|
>
|
|
{t("user:widgets.remove")}
|
|
</SendouButton>
|
|
</div>
|
|
</div>
|
|
|
|
{isExpanded && hasSettings ? (
|
|
<div className={styles.widgetSettings}>
|
|
<WidgetSettingsForm
|
|
widget={widget}
|
|
onSettingsChange={onSettingsChange}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const WIDGET_DESCRIPTION_PARAMS: Record<string, Record<string, unknown>> = {
|
|
"game-badges": { max: USER.GAME_BADGES_MAX },
|
|
"game-badges-small": { max: USER.GAME_BADGES_SMALL_MAX },
|
|
};
|
|
|
|
function widgetDescriptionParams(widgetId: string) {
|
|
return WIDGET_DESCRIPTION_PARAMS[widgetId];
|
|
}
|