feat: multi-user reading progress & backoffice impersonation

- Scope all reading progress (books, series, stats) by user via
  Option<Extension<AuthUser>> — admin sees aggregate, read token sees own data
- Fix duplicate book rows when admin views lists (IS NOT NULL guard on JOIN)
- Add X-As-User header support: admin can impersonate any user from backoffice
- UserSwitcher dropdown in nav header (persisted via as_user_id cookie)
- Per-user filter pills on "Currently reading" and "Recently read" dashboard sections
- Inline username editing (UsernameEdit component with optimistic update)
- PATCH /admin/users/:id endpoint to rename a user
- Unassigned read tokens row in users table
- Komga sync now requires a user_id — reading progress attributed to selected user
- Migration 0051: add user_id column to komga_sync_reports
- Nav breakpoints: icons-only from md, labels from xl, hamburger until md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 12:47:58 +01:00
parent 232ecdda41
commit bc796f4ee5
22 changed files with 1326 additions and 152 deletions

View File

@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import type { CurrentlyReadingItem, RecentlyReadItem } from "@/lib/api";
import { getBookCoverUrl } from "@/lib/api";
function FilterPills({ usernames, selected, allLabel, onSelect }: {
usernames: string[];
selected: string | null;
allLabel: string;
onSelect: (u: string | null) => void;
}) {
if (usernames.length <= 1) return null;
return (
<div className="flex flex-wrap gap-1.5 mb-3">
<button
onClick={() => onSelect(null)}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
selected === null
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{allLabel}
</button>
{usernames.map((u) => (
<button
key={u}
onClick={() => onSelect(u === selected ? null : u)}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
selected === u
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{u}
</button>
))}
</div>
);
}
export function CurrentlyReadingList({
items,
allLabel,
emptyLabel,
pageProgressTemplate,
}: {
items: CurrentlyReadingItem[];
allLabel: string;
emptyLabel: string;
/** Template with {{current}} and {{total}} placeholders */
pageProgressTemplate: string;
}) {
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
const [selected, setSelected] = useState<string | null>(null);
const filtered = selected ? items.filter((i) => i.username === selected) : items;
return (
<div>
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
{filtered.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
) : (
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{filtered.slice(0, 8).map((book) => {
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
return (
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
{book.username && usernames.length > 1 && (
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
</div>
<p className="text-[10px] text-muted-foreground mt-0.5">{pageProgressTemplate.replace("{{current}}", String(book.current_page)).replace("{{total}}", String(book.page_count))}</p>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
export function RecentlyReadList({
items,
allLabel,
emptyLabel,
}: {
items: RecentlyReadItem[];
allLabel: string;
emptyLabel: string;
}) {
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
const [selected, setSelected] = useState<string | null>(null);
const filtered = selected ? items.filter((i) => i.username === selected) : items;
return (
<div>
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
{filtered.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
) : (
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{filtered.map((book) => (
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
{book.username && usernames.length > 1 && (
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useOptimistic, useTransition } from "react";
interface TokenUserSelectProps {
tokenId: string;
currentUserId?: string;
users: { id: string; username: string }[];
action: (formData: FormData) => Promise<void>;
noUserLabel: string;
}
export function TokenUserSelect({ tokenId, currentUserId, users, action, noUserLabel }: TokenUserSelectProps) {
const [optimisticValue, setOptimisticValue] = useOptimistic(currentUserId ?? "");
const [, startTransition] = useTransition();
return (
<select
value={optimisticValue}
onChange={(e) => {
const newValue = e.target.value;
startTransition(async () => {
setOptimisticValue(newValue);
const fd = new FormData();
fd.append("id", tokenId);
fd.append("user_id", newValue);
await action(fd);
});
}}
className="flex h-8 rounded-md border border-input bg-background px-2 py-0 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">{noUserLabel}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { useState, useTransition, useRef, useEffect } from "react";
import type { UserDto } from "@/lib/api";
export function UserSwitcher({
users,
activeUserId,
setActiveUserAction,
}: {
users: UserDto[];
activeUserId: string | null;
setActiveUserAction: (formData: FormData) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [, startTransition] = useTransition();
const ref = useRef<HTMLDivElement>(null);
const activeUser = users.find((u) => u.id === activeUserId) ?? null;
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function select(userId: string | null) {
setOpen(false);
startTransition(async () => {
const fd = new FormData();
fd.append("user_id", userId ?? "");
await setActiveUserAction(fd);
});
}
if (users.length === 0) return null;
const isImpersonating = activeUserId !== null;
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
isImpersonating
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/15"
: "border-border/60 bg-muted/40 text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
{isImpersonating ? (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
)}
<span className="max-w-[80px] truncate hidden sm:inline">
{activeUser ? activeUser.username : "Admin"}
</span>
<svg className="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-full mt-1.5 w-44 rounded-lg border border-border/60 bg-popover shadow-lg z-50 overflow-hidden py-1">
<button
onClick={() => select(null)}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
!isImpersonating
? "bg-accent text-foreground font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Admin
{!isImpersonating && (
<svg className="w-3.5 h-3.5 ml-auto text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<div className="h-px bg-border/60 my-1" />
{users.map((user) => (
<button
key={user.id}
onClick={() => select(user.id)}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
activeUserId === user.id
? "bg-accent text-foreground font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span className="truncate">{user.username}</span>
{activeUserId === user.id && (
<svg className="w-3.5 h-3.5 ml-auto text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useOptimistic, useTransition, useRef, useState } from "react";
export function UsernameEdit({
userId,
currentUsername,
action,
}: {
userId: string;
currentUsername: string;
action: (formData: FormData) => Promise<void>;
}) {
const [optimisticUsername, setOptimisticUsername] = useOptimistic(currentUsername);
const [editing, setEditing] = useState(false);
const [, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
function startEdit() {
setEditing(true);
setTimeout(() => inputRef.current?.select(), 0);
}
function submit(value: string) {
const trimmed = value.trim();
if (!trimmed || trimmed === currentUsername) {
setEditing(false);
return;
}
setEditing(false);
startTransition(async () => {
setOptimisticUsername(trimmed);
const fd = new FormData();
fd.append("id", userId);
fd.append("username", trimmed);
await action(fd);
});
}
if (editing) {
return (
<input
ref={inputRef}
defaultValue={optimisticUsername}
className="text-sm font-medium text-foreground bg-background border border-border rounded px-2 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary w-36"
autoFocus
onBlur={(e) => submit(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit((e.target as HTMLInputElement).value);
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return (
<button
onClick={startEdit}
className="flex items-center gap-1.5 group/edit text-left"
title="Modifier"
>
<span className="text-sm font-medium text-foreground">{optimisticUsername}</span>
<svg
className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover/edit:opacity-100 transition-opacity shrink-0"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
);
}