feat : update
This commit is contained in:
@@ -6,6 +6,7 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
LayoutDashboardIcon,
|
||||
BookOpenIcon,
|
||||
LogOutIcon,
|
||||
PlusIcon,
|
||||
UserPlusIcon,
|
||||
@@ -29,6 +30,15 @@ export function AdminHeader() {
|
||||
<span className="hidden sm:inline">ไกด์ช่องเกนชินไม่ใช่เกมมือถือ</span>
|
||||
</Link>
|
||||
<nav aria-label="Admin navigation" className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={pathname === "/admin/glossary" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href="/admin/glossary" />}
|
||||
>
|
||||
<BookOpenIcon data-icon="inline-start" />
|
||||
<span className="hidden sm:inline">Glossary</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={pathname === "/admin/register" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Character = { key: string; name: string; element: string | null; rarity: number | null; imageUrl: string };
|
||||
type Character = { key: string; name: string; aliases: string[]; element: string | null; rarity: number | null; imageUrl: string };
|
||||
|
||||
export function CreateGuideFlow({ characters, existingKeys }: { characters: Character[]; existingKeys: string[] }) {
|
||||
const router = useRouter();
|
||||
@@ -28,7 +28,12 @@ export function CreateGuideFlow({ characters, existingKeys }: { characters: Char
|
||||
const [overview, setOverview] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
const existing = useMemo(() => new Set(existingKeys), [existingKeys]);
|
||||
const filtered = characters.filter((character) => character.name.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
const filtered = characters.filter((character) =>
|
||||
[character.name, ...character.aliases]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("th")
|
||||
.includes(query.trim().toLocaleLowerCase("th")),
|
||||
);
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { UsersIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useAdminPresence } from "@/hooks/use-presence";
|
||||
|
||||
export function EditorPresence({ guideId }: { guideId: string }) {
|
||||
const presence = useAdminPresence(guideId);
|
||||
const editors = presence?.editorsByGuide[guideId] ?? [];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1" aria-live="polite">
|
||||
<Badge variant="secondary">
|
||||
<UsersIcon data-icon="inline-start" />
|
||||
Editing {editors.length || (presence ? 0 : "—")}
|
||||
</Badge>
|
||||
{editors.map((editor) => (
|
||||
<Badge key={editor.userId} variant="outline">{editor.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,16 @@ import {
|
||||
ArrowUpIcon,
|
||||
Columns3Icon,
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
Rows3Icon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import Spreadsheet, { type Point } from "react-spreadsheet";
|
||||
import { useMemo, useState } from "react";
|
||||
import Spreadsheet, { type ColumnIndicatorProps, type DataEditorProps, type Point } from "react-spreadsheet";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
|
||||
import { RichTextContent } from "@/components/rich-text-content";
|
||||
import { RichTextEditor } from "@/components/rich-text-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -44,17 +47,56 @@ import {
|
||||
moveExtraSheetColumn,
|
||||
moveExtraSheetRow,
|
||||
nextExtraSheetColumnLabel,
|
||||
updateExtraSheetCell,
|
||||
type ExtraSheet,
|
||||
type ExtraSheetMatrix,
|
||||
} from "@/lib/guides/extra-sheet";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
|
||||
type DeleteTarget = "column" | "row" | null;
|
||||
|
||||
function LiteralDataViewer({ cell }: { cell?: { value?: unknown } }) {
|
||||
return (
|
||||
<span className="Spreadsheet__data-viewer Spreadsheet__data-viewer--preserve-breaks">
|
||||
{String(cell?.value ?? "")}
|
||||
</span>
|
||||
<div className="Spreadsheet__data-viewer Spreadsheet__data-viewer--preserve-breaks">
|
||||
<RichTextContent html={normalizeExtraRichText(String(cell?.value ?? ""))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDataEditor({
|
||||
cell,
|
||||
row,
|
||||
column,
|
||||
exitEditMode,
|
||||
onEdit,
|
||||
}: DataEditorProps<{ value: string }> & {
|
||||
onEdit: (point: Point, value: string) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
onEdit({ row, column }, String(cell?.value ?? ""));
|
||||
exitEditMode();
|
||||
}, [cell?.value, column, exitEditMode, onEdit, row]);
|
||||
return <LiteralDataViewer cell={cell} />;
|
||||
}
|
||||
|
||||
function EditableColumnIndicator({
|
||||
column,
|
||||
label,
|
||||
selected,
|
||||
onRename,
|
||||
}: ColumnIndicatorProps & { onRename: (column: number) => void }) {
|
||||
return (
|
||||
<th className={selected ? "Spreadsheet__header Spreadsheet__header--selected" : "Spreadsheet__header"}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => onRename(column)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +117,8 @@ export function ExtraSheetEditor({
|
||||
const [columnLabel, setColumnLabel] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget>(null);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [cellDialogOpen, setCellDialogOpen] = useState(false);
|
||||
const [cellDraft, setCellDraft] = useState("");
|
||||
const matrix = useMemo(() => extraSheetToMatrix(value), [value]);
|
||||
const activeColumn = value.columns[active.column];
|
||||
const activeRow = value.rows[active.row];
|
||||
@@ -89,12 +133,14 @@ export function ExtraSheetEditor({
|
||||
setColumnLabel(nextExtraSheetColumnLabel(value.columns));
|
||||
setLabelDialogOpen(true);
|
||||
};
|
||||
const openRenameColumn = () => {
|
||||
if (!activeColumn) return;
|
||||
const openRenameColumn = useCallback((columnIndex = active.column) => {
|
||||
const column = value.columns[columnIndex];
|
||||
if (!column) return;
|
||||
setActive((current) => ({ ...current, column: columnIndex }));
|
||||
setRenaming(true);
|
||||
setColumnLabel(activeColumn.label);
|
||||
setColumnLabel(column.label);
|
||||
setLabelDialogOpen(true);
|
||||
};
|
||||
}, [active.column, value.columns]);
|
||||
const saveColumnLabel = () => {
|
||||
if (labelInvalid) return;
|
||||
if (renaming) {
|
||||
@@ -115,6 +161,22 @@ export function ExtraSheetEditor({
|
||||
}
|
||||
setLabelDialogOpen(false);
|
||||
};
|
||||
const openCellEditorAt = useCallback((point: Point, content?: string) => {
|
||||
setActive(point);
|
||||
const column = value.columns[point.column];
|
||||
const row = value.rows[point.row];
|
||||
setCellDraft(normalizeExtraRichText(
|
||||
content ?? (column && row ? row.cells[column.id] ?? "" : ""),
|
||||
));
|
||||
setCellDialogOpen(true);
|
||||
}, [value.columns, value.rows]);
|
||||
const openActiveCellEditor = () => openCellEditorAt(active);
|
||||
const columnIndicator = useCallback((props: ColumnIndicatorProps) => (
|
||||
<EditableColumnIndicator {...props} onRename={openRenameColumn} />
|
||||
), [openRenameColumn]);
|
||||
const dataEditor = useCallback((props: DataEditorProps<{ value: string }>) => (
|
||||
<DialogDataEditor {...props} onEdit={openCellEditorAt} />
|
||||
), [openCellEditorAt]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3" aria-label="ตารางข้อมูลเสริม">
|
||||
@@ -136,6 +198,10 @@ export function ExtraSheetEditor({
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
เพิ่มคอลัมน์
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={openActiveCellEditor}>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
แก้ไขเซลล์
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button type="button" variant="outline" size="sm" />}
|
||||
@@ -187,7 +253,7 @@ export function ExtraSheetEditor({
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{activeColumn?.label}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={openRenameColumn}>แก้ไขชื่อ</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openRenameColumn()}>แก้ไขชื่อ</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!activeColumn || active.column === 0}
|
||||
onClick={() => {
|
||||
@@ -230,7 +296,9 @@ export function ExtraSheetEditor({
|
||||
className="extra-sheet-grid"
|
||||
data={matrix}
|
||||
columnLabels={value.columns.map((column) => column.label)}
|
||||
ColumnIndicator={columnIndicator}
|
||||
DataViewer={LiteralDataViewer}
|
||||
DataEditor={dataEditor}
|
||||
onActivate={setActive}
|
||||
onChange={(next) => onChange(matrixToExtraSheet(
|
||||
value,
|
||||
@@ -240,6 +308,24 @@ export function ExtraSheetEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={cellDialogOpen} onOpenChange={setCellDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>แก้ไขเซลล์</DialogTitle>
|
||||
<DialogDescription>
|
||||
แถว {active.row + 1} · {activeColumn?.label ?? "คอลัมน์"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RichTextEditor value={cellDraft} onChange={setCellDraft} showBubbleMenu={false} />
|
||||
<DialogFooter showCloseButton>
|
||||
<Button type="button" onClick={() => {
|
||||
onChange(updateExtraSheetCell(value, active.row, active.column, cellDraft));
|
||||
setCellDialogOpen(false);
|
||||
}}>บันทึกเซลล์</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { SearchIcon, Trash2Icon } from "lucide-react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { addGlossaryAlias, removeGlossaryAlias } from "@/app/admin/actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { GlossaryKind } from "@/lib/glossary";
|
||||
|
||||
type CatalogItem = { key: string; name: string; imageUrl: string };
|
||||
type Alias = { id: string; kind: string; catalogKey: string; alias: string };
|
||||
|
||||
const kindLabels: Record<GlossaryKind, string> = {
|
||||
character: "Character",
|
||||
weapon: "Weapon",
|
||||
artifact: "Artifact",
|
||||
};
|
||||
|
||||
export function GlossaryManager({
|
||||
aliases,
|
||||
catalogs,
|
||||
}: {
|
||||
aliases: Alias[];
|
||||
catalogs: Record<GlossaryKind, CatalogItem[]>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [kind, setKind] = useState<GlossaryKind>("character");
|
||||
const [item, setItem] = useState<CatalogItem | null>(null);
|
||||
const [alias, setAlias] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const itemNames = useMemo(() => new Map(
|
||||
Object.entries(catalogs).flatMap(([catalogKind, items]) =>
|
||||
items.map((catalogItem) => [`${catalogKind}:${catalogItem.key}`, catalogItem.name] as const),
|
||||
),
|
||||
), [catalogs]);
|
||||
const visibleAliases = aliases.filter((entry) =>
|
||||
`${entry.alias} ${itemNames.get(`${entry.kind}:${entry.catalogKey}`) ?? entry.catalogKey}`
|
||||
.toLocaleLowerCase("th")
|
||||
.includes(query.trim().toLocaleLowerCase("th")),
|
||||
);
|
||||
|
||||
const submit = () => startTransition(async () => {
|
||||
const result = await addGlossaryAlias({ kind, catalogKey: item?.key, alias });
|
||||
if (result.status === "error") {
|
||||
toast.error(result.message);
|
||||
return;
|
||||
}
|
||||
setAlias("");
|
||||
toast.success("เพิ่ม Shortcut แล้ว");
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>เพิ่ม Shortcut</CardTitle>
|
||||
<CardDescription>ผูกคำค้นแบบย่อกับรายการจาก Catalog</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>ประเภท</FieldLabel>
|
||||
<Select value={kind} onValueChange={(value) => {
|
||||
setKind(value as GlossaryKind);
|
||||
setItem(null);
|
||||
}}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{(Object.keys(kindLabels) as GlossaryKind[]).map((value) => (
|
||||
<SelectItem key={value} value={value}>{kindLabels[value]}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>รายการ</FieldLabel>
|
||||
<Combobox
|
||||
items={catalogs[kind]}
|
||||
value={item}
|
||||
onValueChange={setItem}
|
||||
itemToStringLabel={(value) => value.name}
|
||||
itemToStringValue={(value) => value.name}
|
||||
>
|
||||
<ComboboxInput placeholder="ค้นหารายการ…" showClear />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(catalogItem) => (
|
||||
<ComboboxItem key={catalogItem.key} value={catalogItem}>
|
||||
<span className="relative size-8 shrink-0 overflow-hidden rounded-md bg-muted">
|
||||
<Image src={catalogItem.imageUrl} alt="" fill className="object-contain" />
|
||||
</span>
|
||||
{catalogItem.name}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="glossary-alias">Shortcut</FieldLabel>
|
||||
<Input id="glossary-alias" value={alias} maxLength={120} onChange={(event) => setAlias(event.target.value)} onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && item && alias.trim()) submit();
|
||||
}} />
|
||||
</Field>
|
||||
<Button disabled={pending || !item || !alias.trim()} onClick={submit}>
|
||||
{pending ? <Spinner data-icon="inline-start" /> : null}
|
||||
เพิ่ม Shortcut
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Glossary</CardTitle>
|
||||
<CardDescription>{aliases.length} shortcuts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="glossary-search">ค้นหา</FieldLabel>
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input id="glossary-search" className="pl-9" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
</Field>
|
||||
{visibleAliases.map((entry) => {
|
||||
const name = itemNames.get(`${entry.kind}:${entry.catalogKey}`);
|
||||
return (
|
||||
<div key={entry.id} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{entry.alias}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">{name ?? entry.catalogKey}</p>
|
||||
</div>
|
||||
<Badge variant={name ? "secondary" : "outline"}>{name ? kindLabels[entry.kind as GlossaryKind] : "Unavailable"}</Badge>
|
||||
<Button variant="ghost" size="icon-sm" disabled={pending} aria-label={`ลบ ${entry.alias}`} onClick={() => startTransition(async () => {
|
||||
const result = await removeGlossaryAlias(entry.id);
|
||||
if (result.status === "error") toast.error(result.message);
|
||||
else {
|
||||
toast.success("ลบ Shortcut แล้ว");
|
||||
router.refresh();
|
||||
}
|
||||
})}><Trash2Icon /></Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -127,10 +127,12 @@ import {
|
||||
targetStatDefaults,
|
||||
targetStatIconName,
|
||||
targetStatOptions,
|
||||
statPriorityColorClass,
|
||||
} from "@/lib/guides/stats";
|
||||
import { elementIconFilter } from "@/lib/catalog/element";
|
||||
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import { isCustomTravelerKey } from "@/lib/catalog/travelers";
|
||||
import {
|
||||
applyPresetFields,
|
||||
applyTeamFields,
|
||||
@@ -170,6 +172,7 @@ import { cn } from "@/lib/utils";
|
||||
type CatalogItem = {
|
||||
key: string;
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
imageUrl: string;
|
||||
rarity?: number | null;
|
||||
weaponType?: string | null;
|
||||
@@ -200,7 +203,7 @@ type Guide = {
|
||||
type WeaponValue = {
|
||||
id: string;
|
||||
weaponKey: string;
|
||||
refinement: number;
|
||||
refinement: string;
|
||||
overallPercent: string | null;
|
||||
personalPercent: string | null;
|
||||
note: string | null;
|
||||
@@ -393,6 +396,12 @@ function CatalogPicker({
|
||||
onValueChange={(next) => onChange(next?.key ?? "")}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
filter={(item, query) =>
|
||||
[item.name, ...(item.aliases ?? [])]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("th")
|
||||
.includes(query.trim().toLocaleLowerCase("th"))
|
||||
}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxInput
|
||||
@@ -437,6 +446,7 @@ function StatSelect({
|
||||
max,
|
||||
onChange,
|
||||
elementIconUrls,
|
||||
priorityColors = false,
|
||||
}: {
|
||||
label: string;
|
||||
options: readonly string[];
|
||||
@@ -444,6 +454,7 @@ function StatSelect({
|
||||
max: number;
|
||||
onChange: (value: string[]) => void;
|
||||
elementIconUrls: Record<string, string>;
|
||||
priorityColors?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
@@ -457,7 +468,15 @@ function StatSelect({
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<StatIcons values={value} elementIconUrls={elementIconUrls} />
|
||||
<SelectValue placeholder="Select recommended stats…" />
|
||||
<SelectValue placeholder="Select recommended stats…">
|
||||
{priorityColors && value.length
|
||||
? () => value.map((selected, index) => (
|
||||
<span key={selected} className={statPriorityColorClass(index)}>
|
||||
{selected}{index < value.length - 1 ? "," : ""}
|
||||
</span>
|
||||
))
|
||||
: undefined}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
@@ -466,6 +485,9 @@ function StatSelect({
|
||||
key={option}
|
||||
value={option}
|
||||
disabled={value.length >= max && !value.includes(option)}
|
||||
className={priorityColors && value.includes(option)
|
||||
? statPriorityColorClass(value.indexOf(option))
|
||||
: undefined}
|
||||
>
|
||||
<StatIcons values={[option]} elementIconUrls={elementIconUrls} />
|
||||
{option}
|
||||
@@ -1102,16 +1124,14 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
item.clientId === row.clientId
|
||||
? {
|
||||
...item,
|
||||
refinement: Number(
|
||||
next.slice(1),
|
||||
),
|
||||
refinement: next.slice(1),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((refinement) => (
|
||||
{["1", "2", "3", "4", "5", "1-5"].map((refinement) => (
|
||||
<ToggleGroupItem
|
||||
className={SELECTED_TOGGLE_CLASS}
|
||||
key={refinement}
|
||||
@@ -1246,7 +1266,7 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
{
|
||||
clientId: createClientId("weapon"),
|
||||
weaponKey: "",
|
||||
refinement: 1,
|
||||
refinement: "1",
|
||||
overallPercent: "",
|
||||
personalPercent: "",
|
||||
note: "",
|
||||
@@ -1766,6 +1786,7 @@ function ArtifactEditor({
|
||||
max={substatOptions.length}
|
||||
onChange={setSubstats}
|
||||
elementIconUrls={elementIconUrls}
|
||||
priorityColors
|
||||
/>
|
||||
<FieldSet>
|
||||
<FieldLegend>Target Stats</FieldLegend>
|
||||
@@ -2257,9 +2278,9 @@ function TeamEditor({
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
|
||||
const filteredTeams = useMemo(() => {
|
||||
const characterNames = new Map(characters.map((item) => [item.key, item.name]));
|
||||
const weaponNames = new Map(weapons.map((item) => [item.key, item.name]));
|
||||
const artifactNames = new Map(artifacts.map((item) => [item.key, item.name]));
|
||||
const characterNames = new Map(characters.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||||
const weaponNames = new Map(weapons.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||||
const artifactNames = new Map(artifacts.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||||
|
||||
return teams
|
||||
.map((team, teamIndex) => ({ team, teamIndex }))
|
||||
@@ -2267,18 +2288,18 @@ function TeamEditor({
|
||||
team: [team.title],
|
||||
character: team.members.flatMap((member) => {
|
||||
const name = characterNames.get(member.characterKey);
|
||||
return name ? [name] : [];
|
||||
return name ?? [];
|
||||
}),
|
||||
weapon: team.members.flatMap((member) => {
|
||||
const name = member.weaponKey
|
||||
? weaponNames.get(member.weaponKey)
|
||||
: undefined;
|
||||
return name ? [name] : [];
|
||||
return name ?? [];
|
||||
}),
|
||||
artifact: team.members.flatMap((member) =>
|
||||
[member.artifactKey, member.secondArtifactKey].flatMap((key) => {
|
||||
const name = key ? artifactNames.get(key) : undefined;
|
||||
return name ? [name] : [];
|
||||
return name ?? [];
|
||||
}),
|
||||
),
|
||||
}, searchQuery, searchTarget));
|
||||
@@ -2796,6 +2817,9 @@ function TeamEditor({
|
||||
weapon.weaponType,
|
||||
),
|
||||
);
|
||||
const selectedWeapon = compatibleWeapons.find(
|
||||
(weapon) => weapon.key === member.weaponKey,
|
||||
);
|
||||
return (
|
||||
<SortableTeamMember
|
||||
key={`${team.clientId}-member-${memberIndex}`}
|
||||
@@ -2814,7 +2838,11 @@ function TeamEditor({
|
||||
</CardDescription>
|
||||
<CardAction className="flex items-center gap-1">
|
||||
{character ? (
|
||||
<Avatar>
|
||||
<Avatar className={rarityGradientClass(
|
||||
isCustomTravelerKey(character.key)
|
||||
? 5
|
||||
: character.rarity,
|
||||
)}>
|
||||
<AvatarImage
|
||||
src={character.imageUrl}
|
||||
alt={character.name}
|
||||
@@ -2826,6 +2854,18 @@ function TeamEditor({
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : null}
|
||||
{selectedWeapon ? (
|
||||
<Avatar className={rarityGradientClass(selectedWeapon.rarity)}>
|
||||
<AvatarImage
|
||||
src={selectedWeapon.imageUrl}
|
||||
alt={selectedWeapon.name}
|
||||
className="object-contain"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{selectedWeapon.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : null}
|
||||
{memberDragHandle}
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useAdminPresence } from "@/hooks/use-presence";
|
||||
|
||||
type GuideCard = {
|
||||
id: string;
|
||||
@@ -65,6 +66,7 @@ export function StructuredDashboard({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const presence = useAdminPresence(null);
|
||||
const catalogReady = Boolean(catalogVersion);
|
||||
const updateAvailable = Boolean(
|
||||
catalogVersion &&
|
||||
@@ -153,7 +155,12 @@ export function StructuredDashboard({
|
||||
{guide.overview}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Badge variant="secondary">v{guide.version}</Badge>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="secondary">v{guide.version}</Badge>
|
||||
{(presence?.editorsByGuide[guide.id] ?? []).map((editor) => (
|
||||
<Badge key={editor.userId} variant="outline">{editor.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
Reference in New Issue
Block a user