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
27 changes: 20 additions & 7 deletions app/(ui)/deck/[id]/history/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ import { DeckHistoryList } from "@/app/_components/deck/deck-history-list";

interface DeckHistoryPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ revision?: string }>;
}

async function DeckHistoryContent({ id }: { id: string }) {
async function DeckHistoryContent({
id,
highlightId,
}: {
id: string;
highlightId?: string | undefined;
}) {
const [deck, session] = await Promise.all([
prisma.deck.findUnique({
where: { id },
Expand Down Expand Up @@ -40,21 +47,25 @@ async function DeckHistoryContent({ id }: { id: string }) {
{deck.name} · History
</h1>
<p className="text-sm text-muted-foreground">
Changes to this deck, newest first. Edits within 5 minutes of each
other are grouped into a single revision.
Changes to this deck, newest first. Edits by the same player within
5 minutes are grouped into a single revision.
</p>
</div>

<DeckHistoryList
deckId={deck.id}
revisions={revisions}
isOwner={isOwner}
highlightId={highlightId}
/>
</div>
);
}

export default function DeckHistoryPage({ params }: DeckHistoryPageProps) {
export default function DeckHistoryPage({
params,
searchParams,
}: DeckHistoryPageProps) {
return (
<div className="px-4 md:px-8 py-6 max-w-[1000px] mx-auto">
<Suspense
Expand All @@ -65,17 +76,19 @@ export default function DeckHistoryPage({ params }: DeckHistoryPageProps) {
</div>
}
>
<HistoryLoader params={params} />
<HistoryLoader params={params} searchParams={searchParams} />
</Suspense>
</div>
);
}

async function HistoryLoader({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ revision?: string }>;
}) {
const { id } = await params;
return <DeckHistoryContent id={id} />;
const [{ id }, { revision }] = await Promise.all([params, searchParams]);
return <DeckHistoryContent id={id} highlightId={revision} />;
}
99 changes: 38 additions & 61 deletions app/_components/deck/deck-history-list.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,26 @@
"use client";

import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { revertDeckRevision } from "@/app/_actions/deck/revisions";
import type { RevisionView } from "@/app/_actions/deck/revisions";
import { deltaKey } from "@/lib/deck/revision";
import { groupDeltasByZone } from "@/lib/deck/group-deltas";
import type { Zone } from "@/lib/generated/prisma/enums";
import { RevisionDiff } from "@/app/_components/deck/revision-diff";

interface DeckHistoryListProps {
deckId: string;
revisions: RevisionView[];
isOwner: boolean;
highlightId?: string | undefined;
}

const ZONE_LABEL: Record<Zone, string> = {
MAINBOARD: "Mainboard",
SIDEBOARD: "Sideboard",
CONSIDERING: "Considering",
COMMANDER: "Commander",
COMPANION: "Companion",
};

export function DeckHistoryList({
deckId,
revisions,
isOwner,
highlightId,
}: DeckHistoryListProps) {
if (revisions.length === 0) {
return (
Expand All @@ -46,6 +38,7 @@ export function DeckHistoryList({
deckId={deckId}
revision={r}
isOwner={isOwner}
isHighlighted={r.id === highlightId}
/>
))}
</ul>
Expand All @@ -56,19 +49,24 @@ function RevisionCard({
deckId,
revision,
isOwner,
isHighlighted,
}: {
deckId: string;
revision: RevisionView;
isOwner: boolean;
isHighlighted: boolean;
}) {
const cardRef = useRef<HTMLLIElement>(null);

useEffect(() => {
if (!isHighlighted) return;
cardRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [isHighlighted]);

const updatedAt = useMemo(
() => new Date(revision.updatedAt),
[revision.updatedAt],
);
const grouped = useMemo(
() => groupDeltasByZone(revision.changes),
[revision.changes],
);
const [selected, setSelected] = useState<Set<string>>(new Set());

const toggle = (key: string, checked: boolean) => {
Expand All @@ -83,12 +81,20 @@ function RevisionCard({
const clearSelection = () => setSelected(new Set());

return (
<li className="rounded-md border bg-card">
<li
ref={cardRef}
className={
isHighlighted
? "rounded-md border bg-card ring-2 ring-primary border-primary"
: "rounded-md border bg-card"
}
>
<header className="flex items-center justify-between gap-4 px-4 py-3 border-b">
<div className="flex flex-col gap-0.5">
<time
dateTime={updatedAt.toISOString()}
title={updatedAt.toLocaleString()}
suppressHydrationWarning
className="text-sm font-medium"
>
{relativeTime(updatedAt)}
Expand All @@ -108,50 +114,21 @@ function RevisionCard({
)}
</header>

<div className="flex flex-col gap-3 px-4 py-3">
{grouped.map(({ zone, deltas }) => (
<div key={zone} className="flex flex-col gap-1">
{grouped.length > 1 && (
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{ZONE_LABEL[zone]}
</div>
)}
<ul className="flex flex-col gap-0.5 text-sm">
{deltas.map((d) => {
const key = deltaKey(d);
return (
<li
key={key}
className="flex items-center gap-2 tabular-nums"
>
{isOwner && (
<Checkbox
aria-label={`Select ${d.cardName || `card ${d.cardId}`} change for partial revert`}
checked={selected.has(key)}
onCheckedChange={(checked) => toggle(key, checked)}
/>
)}
<span
className={
d.delta > 0
? "text-emerald-600 dark:text-emerald-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium"
}
>
{d.delta > 0 ? `+${d.delta}` : d.delta}
</span>
<span>{d.cardName || `Card #${d.cardId}`}</span>
{d.category && (
<span className="text-xs text-muted-foreground">
({d.category})
</span>
)}
</li>
);
})}
</ul>
</div>
))}
<div className="px-4 py-3">
<RevisionDiff
deltas={revision.changes}
renderRowStart={
isOwner
? (d, key) => (
<Checkbox
aria-label={`Select ${d.cardName || `card ${d.cardId}`} change for partial revert`}
checked={selected.has(key)}
onCheckedChange={(checked) => toggle(key, checked)}
/>
)
: undefined
}
/>
</div>
</li>
);
Expand Down
61 changes: 61 additions & 0 deletions app/_components/deck/revision-diff.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import { deltaKey, type RevisionDelta } from "@/lib/deck/revision";
import { groupDeltasByZone } from "@/lib/deck/group-deltas";
import type { Zone } from "@/lib/generated/prisma/enums";

const ZONE_LABEL: Record<Zone, string> = {
MAINBOARD: "Mainboard",
SIDEBOARD: "Sideboard",
CONSIDERING: "Considering",
COMMANDER: "Commander",
COMPANION: "Companion",
};

export function RevisionDiff({
deltas,
renderRowStart,
}: {
deltas: readonly RevisionDelta[];
renderRowStart?: ((delta: RevisionDelta, key: string) => ReactNode) | undefined;
}) {
const grouped = groupDeltasByZone(deltas);

return (
<div className="flex flex-col gap-3">
{grouped.map(({ zone, deltas: zoneDeltas }) => (
<div key={zone} className="flex flex-col gap-1">
{grouped.length > 1 && (
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{ZONE_LABEL[zone]}
</div>
)}
<ul className="flex flex-col gap-0.5 text-sm">
{zoneDeltas.map((d) => {
const key = deltaKey(d);
return (
<li key={key} className="flex items-center gap-2 tabular-nums">
{renderRowStart?.(d, key)}
<span
className={
d.delta > 0
? "text-emerald-600 dark:text-emerald-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium"
}
>
{d.delta > 0 ? `+${d.delta}` : d.delta}
</span>
<span>{d.cardName || `Card #${d.cardId}`}</span>
{d.category && (
<span className="text-xs text-muted-foreground">
({d.category})
</span>
)}
</li>
);
})}
</ul>
</div>
))}
</div>
);
}
2 changes: 1 addition & 1 deletion app/_components/home/deck-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function DeckStrip({
.
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,360px))] gap-2">
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-2">
{decks.map((d, i) => (
<DeckRow key={d.id} deck={d} priority={i === 0} />
))}
Expand Down
41 changes: 41 additions & 0 deletions app/_components/home/feed-row-expand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import { useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import {
Collapsible,
CollapsiblePanel,
CollapsibleTrigger,
} from "@/components/ui/collapsible";

export function FeedRowExpand({
deckName,
summary,
children,
}: {
deckName: string;
summary: ReactNode;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);

return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{summary}</span>
<CollapsibleTrigger
className="relative shrink-0 rounded-sm p-0.5 hover:bg-muted hover:text-foreground transition-colors"
aria-label={`${open ? "Hide" : "Show"} changes to ${deckName}`}
>
<ChevronDown
className={`size-3.5 transition-transform ${open ? "rotate-180" : ""}`}
aria-hidden
/>
</CollapsibleTrigger>
</div>
<CollapsiblePanel className="relative">
<div className="pt-2">{children}</div>
</CollapsiblePanel>
</Collapsible>
);
}
15 changes: 5 additions & 10 deletions app/_components/home/home-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type Format, type Visibility } from "@/lib/generated/prisma/enums";
import { HomeGreeting } from "./home-greeting";
import { DeckStrip, type DeckStripItem } from "./deck-strip";
import { FeaturedDecks, FeaturedDecksSkeleton } from "./featured-decks";
import { UpdatesFeed, UpdatesFeedSkeleton } from "./updates-feed";

interface HomeViewProps {
userId: string;
Expand Down Expand Up @@ -44,17 +45,11 @@ export async function HomeView({ userId, username }: HomeViewProps) {
<FeaturedDecks />
</Suspense>

{/* Two-column: Trending cards + placeholder activity */}
{/* Two-column: updates feed + reserved slot for trending cards */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1fr] gap-8 mb-14">
{/* Activity feed placeholder — TODO: implement when activity query exists */}
<div>
<div className="mb-4 text-[10.5px] font-semibold uppercase tracking-[0.14em] text-muted-foreground font-mono">
Activity
</div>
<div className="border border-border rounded-sm overflow-hidden text-sm text-muted-foreground p-8 text-center">
Activity feed coming soon.
</div>
</div>
<Suspense fallback={<UpdatesFeedSkeleton />}>
<UpdatesFeed userId={userId} />
</Suspense>
</div>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion app/_components/home/landing-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function RecentDeckStripSkeleton() {
<div className="h-3.5 w-24 rounded bg-muted animate-pulse" />
<div className="h-3.5 w-24 rounded bg-muted animate-pulse" />
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,360px))] gap-2">
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-2">
{Array.from({ length: STRIP_LIMIT }).map((_, i) => (
<div
key={i}
Expand Down
Loading
Loading