From bbd1ef2cd6ed7a9c162f1654047889c1df30e9d1 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 01/32] feat: add subscription group models and selection helpers --- .../src/lib/subscription-group-selection.ts | 73 +++++++++++++++ apps/web/src/types/subscription-groups.ts | 16 ++++ .../subscription-group-selection.test.ts | 90 +++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 apps/web/src/lib/subscription-group-selection.ts create mode 100644 apps/web/src/types/subscription-groups.ts create mode 100644 apps/web/tests/subscription-group-selection.test.ts diff --git a/apps/web/src/lib/subscription-group-selection.ts b/apps/web/src/lib/subscription-group-selection.ts new file mode 100644 index 00000000..970d4316 --- /dev/null +++ b/apps/web/src/lib/subscription-group-selection.ts @@ -0,0 +1,73 @@ +import type { GroupedSubscription, MembershipChange } from "../types/subscription-groups"; + +export function filterGroupChannels( + channels: GroupedSubscription[], + filter: string, + excluded: boolean, + query: string, + selected: ReadonlySet, + onlySelected: boolean, +): GroupedSubscription[] { + const search = query.trim().toLocaleLowerCase(); + return channels.filter((channel) => { + if (onlySelected) return selected.has(channel.channelUrl); + const inScope = + filter === "all" || + (filter === "ungrouped" + ? channel.groupIds.length === 0 + : channel.groupIds.includes(filter) !== excluded); + return ( + inScope && + (!search || `${channel.name} ${channel.channelUrl}`.toLocaleLowerCase().includes(search)) + ); + }); +} + +export function selectGroupResults(selected: ReadonlySet, urls: string[]): Set { + return new Set([...selected, ...urls]); +} + +export function channelMembershipChanges( + channel: GroupedSubscription, + desired: ReadonlySet, +): MembershipChange[] { + return [ + ...[...desired] + .filter((id) => !channel.groupIds.includes(id)) + .map((groupId) => ({ + groupId, + channelUrls: [channel.channelUrl], + action: "add" as const, + })), + ...channel.groupIds + .filter((id) => !desired.has(id)) + .map((groupId) => ({ + groupId, + channelUrls: [channel.channelUrl], + action: "remove" as const, + })), + ]; +} + +export function clearMembershipChanges(channels: GroupedSubscription[]): MembershipChange[] { + const urlsByGroup = new Map(); + for (const channel of channels) { + for (const groupId of channel.groupIds) { + const urls = urlsByGroup.get(groupId) ?? []; + urls.push(channel.channelUrl); + urlsByGroup.set(groupId, urls); + } + } + return [...urlsByGroup].map(([groupId, channelUrls]) => ({ + groupId, + channelUrls, + action: "remove", + })); +} + +export function subscriptionFilterParams(filter = "all"): URLSearchParams { + const params = new URLSearchParams(); + if (filter === "ungrouped") params.set("ungrouped", "true"); + else if (filter !== "all") params.set("groupId", filter); + return params; +} diff --git a/apps/web/src/types/subscription-groups.ts b/apps/web/src/types/subscription-groups.ts new file mode 100644 index 00000000..8353296b --- /dev/null +++ b/apps/web/src/types/subscription-groups.ts @@ -0,0 +1,16 @@ +import type { SubscriptionItem } from "./user"; + +export type SubscriptionGroup = { + id: string; + name: string; + channelCount: number; + createdAt: number; + updatedAt: number; +}; + +export type GroupedSubscription = SubscriptionItem & { groupIds: string[] }; +export type MembershipChange = { + groupId: string; + channelUrls: string[]; + action: "add" | "remove"; +}; diff --git a/apps/web/tests/subscription-group-selection.test.ts b/apps/web/tests/subscription-group-selection.test.ts new file mode 100644 index 00000000..826af5be --- /dev/null +++ b/apps/web/tests/subscription-group-selection.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test"; +import { + channelMembershipChanges, + clearMembershipChanges, + filterGroupChannels, + selectGroupResults, + subscriptionFilterParams, +} from "../src/lib/subscription-group-selection"; +import type { GroupedSubscription } from "../src/types/subscription-groups"; + +const channels: GroupedSubscription[] = [ + { + channelUrl: "https://youtube.com/channel/one", + name: "One", + avatarUrl: "", + subscribedAt: 0, + groupIds: ["tech", "music"], + }, + { + channelUrl: "https://youtube.com/channel/two", + name: "Two", + avatarUrl: "", + subscribedAt: 0, + groupIds: ["music"], + }, + { + channelUrl: "https://youtube.com/channel/three", + name: "Three", + avatarUrl: "", + subscribedAt: 0, + groupIds: [], + }, +]; +const names = (items: GroupedSubscription[]) => items.map((item) => item.name); + +test("Not in this group includes channels assigned to other groups", () => { + expect(names(filterGroupChannels(channels, "tech", true, "", new Set(), false))).toEqual([ + "Two", + "Three", + ]); + expect(names(filterGroupChannels(channels, "ungrouped", false, "", new Set(), false))).toEqual([ + "Three", + ]); +}); + +test("search and group filters intersect without altering selection", () => { + const selected = new Set([channels[0].channelUrl]); + expect(names(filterGroupChannels(channels, "music", false, " TWO ", selected, false))).toEqual([ + "Two", + ]); + expect([...selected]).toEqual([channels[0].channelUrl]); + const expanded = selectGroupResults(selected, [channels[1].channelUrl, channels[1].channelUrl]); + expect(expanded.size).toBe(2); + expect(expanded.has(channels[0].channelUrl)).toBe(true); +}); + +test("Show selected reveals selections hidden by both search and group", () => { + expect( + names( + filterGroupChannels( + channels, + "ungrouped", + false, + "no match", + new Set([channels[0].channelUrl]), + true, + ), + ), + ).toEqual(["One"]); +}); + +test("inline save sends only the membership difference", () => { + expect(channelMembershipChanges(channels[0], new Set(["music", "science"]))).toEqual([ + { groupId: "science", channelUrls: [channels[0].channelUrl], action: "add" }, + { groupId: "tech", channelUrls: [channels[0].channelUrl], action: "remove" }, + ]); + expect(channelMembershipChanges(channels[0], new Set(channels[0].groupIds))).toEqual([]); +}); + +test("remove all groups targets only selected channel memberships", () => { + expect(clearMembershipChanges([channels[1], channels[2]])).toEqual([ + { groupId: "music", channelUrls: [channels[1].channelUrl], action: "remove" }, + ]); +}); + +test("feed and subscription filters match the server contract", () => { + expect(subscriptionFilterParams("all").toString()).toBe(""); + expect(subscriptionFilterParams("ungrouped").toString()).toBe("ungrouped=true"); + expect(subscriptionFilterParams("tech").toString()).toBe("groupId=tech"); +}); From 99e7231cd7c23aaceb67342fe87503121d73084f Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 02/32] feat: add subscription group API client and batch writes --- apps/web/src/lib/api-subscription-groups.ts | 83 ++++++++++++++++ .../web/tests/api-subscription-groups.test.ts | 97 +++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 apps/web/src/lib/api-subscription-groups.ts create mode 100644 apps/web/tests/api-subscription-groups.test.ts diff --git a/apps/web/src/lib/api-subscription-groups.ts b/apps/web/src/lib/api-subscription-groups.ts new file mode 100644 index 00000000..0383a9dd --- /dev/null +++ b/apps/web/src/lib/api-subscription-groups.ts @@ -0,0 +1,83 @@ +import type { + GroupedSubscription, + MembershipChange, + SubscriptionGroup, +} from "../types/subscription-groups"; +import type { SubscriptionItem } from "../types/user"; +import { ApiError } from "./api"; +import { authed, authedJson } from "./authed"; +import { API_BASE } from "./env"; +import { subscriptionFilterParams } from "./subscription-group-selection"; + +const GROUPS_URL = `${API_BASE}/subscriptions/groups`; + +export function fetchSubscriptionGroups(): Promise { + return authedJson(GROUPS_URL); +} + +export function fetchGroupMemberships(): Promise { + return authedJson(`${API_BASE}/subscriptions/group-memberships`); +} + +export function fetchFilteredSubscriptions(filter: string): Promise { + return authedJson(`${API_BASE}/subscriptions?${subscriptionFilterParams(filter)}`); +} + +async function groupRequest(path: string, method: string, body?: unknown): Promise { + const response = await authed(`${GROUPS_URL}${path}`, { + method, + ...(body === undefined + ? {} + : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + }); + if (!response.ok) { + const error: unknown = await response.json().catch(() => null); + const code = + error && typeof error === "object" && "code" in error && typeof error.code === "string" + ? error.code + : null; + throw new ApiError("Subscription group request failed", response.status, code); + } + return response; +} + +export async function createSubscriptionGroup(name: string): Promise { + const response = await groupRequest("", "POST", { name: name.trim() }); + return response.json(); +} + +export async function renameSubscriptionGroup(id: string, name: string): Promise { + await groupRequest(`/${encodeURIComponent(id)}`, "PUT", { name: name.trim() }); +} + +export async function deleteSubscriptionGroup(id: string): Promise { + await groupRequest(`/${encodeURIComponent(id)}`, "DELETE"); +} + +export class MembershipUpdateError extends Error { + readonly failedUrls: string[]; + constructor(failedUrls: string[]) { + super("Some subscription group changes could not be saved"); + this.failedUrls = failedUrls; + } +} + +export async function updateGroupMemberships(changes: MembershipChange[]): Promise { + const failed = new Set(); + for (const change of changes) { + const urls = [...new Set(change.channelUrls)]; + for (let offset = 0; offset < urls.length; offset += 500) { + const channelUrls = urls.slice(offset, offset + 500); + try { + await groupRequest( + `/${encodeURIComponent(change.groupId)}/channels`, + change.action === "add" ? "PUT" : "DELETE", + { channelUrls }, + ); + } catch { + for (const url of channelUrls) failed.add(url); + } + } + } + if (failed.size > 0) throw new MembershipUpdateError([...failed]); +} diff --git a/apps/web/tests/api-subscription-groups.test.ts b/apps/web/tests/api-subscription-groups.test.ts new file mode 100644 index 00000000..d61b2313 --- /dev/null +++ b/apps/web/tests/api-subscription-groups.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { ApiError } from "../src/lib/api"; +import { + createSubscriptionGroup, + deleteSubscriptionGroup, + fetchGroupMemberships, + MembershipUpdateError, + renameSubscriptionGroup, + updateGroupMemberships, +} from "../src/lib/api-subscription-groups"; +import { useAuthStore } from "../src/stores/auth-store"; + +const originalFetch = globalThis.fetch; +type Call = { url: string; method: string; body: Record }; +let calls: Call[] = []; +let failGroup = ""; +beforeEach(() => { + calls = []; + failGroup = ""; + useAuthStore.getState().setToken("subscription-groups-test"); + globalThis.fetch = async (input, init) => { + const url = String(input); + calls.push({ + url, + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? JSON.parse(init.body) : {}, + }); + if (failGroup && url.includes(failGroup)) + return Response.json( + { error: "Conflict", code: "subscription_group_name_conflict" }, + { status: 409 }, + ); + if (!init?.method) return Response.json([]); + if (init.method === "POST") return Response.json({ id: "new", name: "Tech" }, { status: 201 }); + return new Response(null, { status: 204 }); + }; +}); +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); +}); + +test("membership projection uses the dedicated endpoint", async () => { + expect(await fetchGroupMemberships()).toEqual([]); + expect(calls[0].url).toBe("/api/subscriptions/group-memberships"); +}); + +test("batch writes deduplicate and split at the 500-channel API limit", async () => { + const urls = Array.from({ length: 1001 }, (_, index) => `https://youtube.com/channel/${index}`); + await updateGroupMemberships([ + { groupId: "tech", action: "add", channelUrls: [...urls, urls[0]] }, + ]); + expect(calls.map((call) => (call.body.channelUrls as string[]).length)).toEqual([500, 500, 1]); + expect(calls.every((call) => call.method === "PUT" && call.url.endsWith("/tech/channels"))).toBe( + true, + ); +}); + +test("failed groups report affected channels while other groups are attempted", async () => { + failGroup = "/failed/"; + try { + await updateGroupMemberships([ + { groupId: "failed", action: "remove", channelUrls: ["one", "two"] }, + { groupId: "ok", action: "remove", channelUrls: ["two"] }, + ]); + throw new Error("Expected a partial failure"); + } catch (error) { + expect(error).toBeInstanceOf(MembershipUpdateError); + if (error instanceof MembershipUpdateError) expect(error.failedUrls).toEqual(["one", "two"]); + } + expect(calls.length).toBe(2); + expect(calls.every((call) => call.method === "DELETE" && call.url.includes("/groups/"))).toBe( + true, + ); +}); + +test("empty membership changes do not make requests", async () => { + await updateGroupMemberships([{ groupId: "tech", action: "add", channelUrls: [] }]); + expect(calls).toEqual([]); +}); + +test("group CRUD handles bodyless success and preserves backend error codes", async () => { + await createSubscriptionGroup(" Tech "); + await renameSubscriptionGroup("new", " Science "); + await deleteSubscriptionGroup("new"); + expect(calls.map((call) => call.method)).toEqual(["POST", "PUT", "DELETE"]); + expect(calls[0].body).toEqual({ name: "Tech" }); + expect(calls[1].body).toEqual({ name: "Science" }); + failGroup = "/groups"; + try { + await createSubscriptionGroup("Tech"); + throw new Error("Expected a duplicate-name failure"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + if (error instanceof ApiError) expect(error.code).toBe("subscription_group_name_conflict"); + } +}); From 88f9fe3bd00af60bb06ffa99a8fea8d58a0cb612 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 03/32] feat: translate subscription group management messages --- apps/web/messages/de.json | 69 ++++++++++++++++++++++++++++++++++++++- apps/web/messages/en.json | 69 ++++++++++++++++++++++++++++++++++++++- apps/web/messages/fr.json | 69 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 204 insertions(+), 3 deletions(-) diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index ea3e739c..5849b748 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1325,5 +1325,72 @@ "ui_remote_diagnostics": "Diagnose", "ui_copy_diagnostics": "Diagnose kopieren", "ui_diagnostics_copied": "Kopiert", - "ui_remote_diagnostics_hint": "Teile diese Zeilen in einem Fehlerbericht. Sie enthalten keine Passwörter und keine Cookie-Werte." + "ui_remote_diagnostics_hint": "Teile diese Zeilen in einem Fehlerbericht. Sie enthalten keine Passwörter und keine Cookie-Werte.", + "sg_add": "Hinzufügen", + "sg_add_groups": "Gruppen hinzufügen", + "sg_added": "{count} Kanäle zu {group} hinzugefügt.", + "sg_all_channels": "Alle Kanäle", + "sg_all_subscriptions": "Alle Abonnements", + "sg_back_channels": "Zurück zu den Kanälen", + "sg_back_to_results": "Zurück zu den Ergebnissen", + "sg_change_filters": "Wähle eine andere Gruppe oder ändere deine Suche.", + "sg_channel_count": "{count} Kanäle", + "sg_channel_saved": "Gruppen für {channel} aktualisiert.", + "sg_channels": "Abonnierte Kanäle", + "sg_clear": "Auswahl aufheben", + "sg_clear_confirmation": "Alle Gruppenzugehörigkeiten der {count} ausgewählten Kanäle entfernen, auch außerhalb dieser Ansicht? Du bleibst bei ihnen abonniert.", + "sg_delete_confirmation": "Die Gruppe und die Zugehörigkeiten von {count} Kanälen werden gelöscht. Die Kanäle und deine Abonnements bleiben bestehen.", + "sg_delete_group": "Gruppe löschen", + "sg_delete_named": "„{group}“ löschen?", + "sg_duplicate_name": "Eine Gruppe mit diesem Namen existiert bereits. Wähle einen anderen Namen.", + "sg_edit_named": "Gruppen für {channel} bearbeiten", + "sg_empty_feed": "Noch keine Videos in dieser Ansicht.", + "sg_empty_subscriptions": "Abonniere zuerst Kanäle und organisiere sie dann hier. Gruppen kannst du bereits erstellen.", + "sg_feed_filter": "Abonnements nach Gruppe filtern", + "sg_group_actions": "Aktionen für {group}", + "sg_group_created": "{group} erstellt.", + "sg_group_deleted": "{group} gelöscht. Deine Abonnements bleiben bestehen.", + "sg_group_filters": "Kanäle nach Gruppe filtern", + "sg_group_renamed": "Gruppe umbenannt.", + "sg_groups": "Gruppen", + "sg_hidden_selected": "{count} außerhalb dieser Ansicht", + "sg_in_group": "In Gruppe", + "sg_invalid_name": "Der Gruppenname muss zwischen 1 und 100 Zeichen lang sein.", + "sg_load_error": "Deine Gruppen konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", + "sg_load_more": "Mehr anzeigen · {shown} von {total}", + "sg_loading": "Gruppen und Kanäle werden geladen…", + "sg_manage_groups": "Gruppen verwalten", + "sg_manager_description": "Organisiere deine Abonnements. Ein Kanal kann mehreren Gruppen angehören.", + "sg_membership_filter": "Gruppenzugehörigkeit filtern", + "sg_memberships": "Gruppen des Kanals", + "sg_memberships_cleared": "Gruppenzugehörigkeiten entfernt. Du bleibst bei diesen Kanälen abonniert.", + "sg_new_group": "Neue Gruppe", + "sg_no_channel_match": "Keine Kanäle in dieser Ansicht", + "sg_no_group_match": "Keine passenden Gruppen. Erstelle eine Gruppe in der Seitenleiste.", + "sg_no_groups": "Erstelle oben deine erste Gruppe. Kanäle können auch ohne Gruppe bleiben.", + "sg_not_in_group": "Nicht in Gruppe", + "sg_outside_named": "Nicht in {group}", + "sg_partial_failure": "Änderungen für {count} Kanäle konnten nicht gespeichert werden. Einige Änderungen waren möglicherweise erfolgreich. Prüfe die aktualisierten Gruppen und versuche es erneut.", + "sg_remove": "Entfernen", + "sg_remove_all": "Aus allen Gruppen entfernen", + "sg_remove_named": "{group} entfernen", + "sg_removed": "{count} Kanäle aus {group} entfernt.", + "sg_rename_group": "Gruppe umbenennen", + "sg_retry": "Erneut versuchen", + "sg_save_error": "Änderungen konnten nicht gespeichert werden. Prüfe deine Verbindung und versuche es erneut.", + "sg_saving": "Änderungen werden gespeichert…", + "sg_search_channels": "Kanäle suchen", + "sg_search_groups": "Gruppen suchen", + "sg_select_hint": "Kanäle zur gemeinsamen Bearbeitung auswählen", + "sg_select_results": "{count} Ergebnisse auswählen", + "sg_selected": "{count} ausgewählt", + "sg_show_selected": "Auswahl anzeigen", + "sg_target_group": "Zielgruppe", + "sg_select_one_result": "1 Ergebnis auswählen", + "sg_one_channel": "1 Kanal", + "sg_added_one": "1 Kanal zu {group} hinzugefügt.", + "sg_removed_one": "1 Kanal aus {group} entfernt.", + "sg_clear_one_confirmation": "Alle Gruppenzugehörigkeiten des ausgewählten Kanals entfernen, auch außerhalb dieser Ansicht? Du bleibst bei ihm abonniert.", + "sg_delete_one_confirmation": "Die Gruppe und die Zugehörigkeit von 1 Kanal werden gelöscht. Der Kanal und dein Abonnement bleiben bestehen.", + "sg_partial_one_failure": "Änderungen für 1 Kanal konnten nicht gespeichert werden. Einige Änderungen waren möglicherweise erfolgreich. Prüfe die aktualisierten Gruppen und versuche es erneut." } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 5217c739..502f457d 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1325,5 +1325,72 @@ "ui_remote_diagnostics": "Diagnostics", "ui_copy_diagnostics": "Copy diagnostics", "ui_diagnostics_copied": "Copied", - "ui_remote_diagnostics_hint": "Share these lines in a bug report. They contain no passwords and no cookie values." + "ui_remote_diagnostics_hint": "Share these lines in a bug report. They contain no passwords and no cookie values.", + "sg_add": "Add", + "sg_add_groups": "Add groups", + "sg_added": "Added {count} channels to {group}.", + "sg_all_channels": "All channels", + "sg_all_subscriptions": "All subscriptions", + "sg_back_channels": "Back to channels", + "sg_back_to_results": "Back to results", + "sg_change_filters": "Try another group or change your search.", + "sg_channel_count": "{count} channels", + "sg_channel_saved": "Updated groups for {channel}.", + "sg_channels": "Subscribed channels", + "sg_clear": "Clear selection", + "sg_clear_confirmation": "Remove all group memberships from {count} selected channels, including any outside this view? You will stay subscribed to them.", + "sg_delete_confirmation": "This deletes the group and removes its memberships for {count} channels. The channels and your subscriptions will be kept.", + "sg_delete_group": "Delete group", + "sg_delete_named": "Delete “{group}”?", + "sg_duplicate_name": "A group with this name already exists. Choose another name.", + "sg_edit_named": "Edit groups for {channel}", + "sg_empty_feed": "No videos in this view yet.", + "sg_empty_subscriptions": "Subscribe to channels first, then organize them here. You can already create groups.", + "sg_feed_filter": "Filter subscriptions by group", + "sg_group_actions": "Actions for {group}", + "sg_group_created": "Created {group}.", + "sg_group_deleted": "Deleted {group}. Your subscriptions are unchanged.", + "sg_group_filters": "Filter channels by group", + "sg_group_renamed": "Group renamed.", + "sg_groups": "Groups", + "sg_hidden_selected": "{count} outside this view", + "sg_in_group": "In group", + "sg_invalid_name": "Use a group name between 1 and 100 characters.", + "sg_load_error": "Your groups could not be loaded. Check your connection and try again.", + "sg_load_more": "Show more · {shown} of {total}", + "sg_loading": "Loading your groups and channels…", + "sg_manage_groups": "Manage groups", + "sg_manager_description": "Organize your subscriptions. A channel can belong to more than one group.", + "sg_membership_filter": "Group membership filter", + "sg_memberships": "Channel groups", + "sg_memberships_cleared": "Group memberships removed. You are still subscribed to these channels.", + "sg_new_group": "New group", + "sg_no_channel_match": "No channels in this view", + "sg_no_group_match": "No matching groups. Create a group in the sidebar.", + "sg_no_groups": "Create your first group above. Leaving channels ungrouped is fine too.", + "sg_not_in_group": "Not in group", + "sg_outside_named": "Not in {group}", + "sg_partial_failure": "Changes for {count} channels could not be saved. Some changes may have succeeded. Review the updated memberships and retry.", + "sg_remove": "Remove", + "sg_remove_all": "Remove from all groups", + "sg_remove_named": "Remove {group}", + "sg_removed": "Removed {count} channels from {group}.", + "sg_rename_group": "Rename group", + "sg_retry": "Try again", + "sg_save_error": "Changes could not be saved. Check your connection and try again.", + "sg_saving": "Saving changes…", + "sg_search_channels": "Search channels", + "sg_search_groups": "Search groups", + "sg_select_hint": "Select channels to edit in bulk", + "sg_select_results": "Select {count} results", + "sg_selected": "{count} selected", + "sg_show_selected": "Show selected", + "sg_target_group": "Target group", + "sg_select_one_result": "Select 1 result", + "sg_one_channel": "1 channel", + "sg_added_one": "Added 1 channel to {group}.", + "sg_removed_one": "Removed 1 channel from {group}.", + "sg_clear_one_confirmation": "Remove all group memberships from the selected channel, even if it is outside this view? You will stay subscribed to it.", + "sg_delete_one_confirmation": "This deletes the group and its membership for 1 channel. The channel and your subscription will be kept.", + "sg_partial_one_failure": "Changes for 1 channel could not be saved. Some changes may have succeeded. Review the updated memberships and retry." } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index dbd0be19..d5920593 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1325,5 +1325,72 @@ "ui_remote_diagnostics": "Diagnostics", "ui_copy_diagnostics": "Copier les diagnostics", "ui_diagnostics_copied": "Copié", - "ui_remote_diagnostics_hint": "Partage ces lignes dans un rapport de bug. Elles ne contiennent ni mot de passe ni valeur de cookie." + "ui_remote_diagnostics_hint": "Partage ces lignes dans un rapport de bug. Elles ne contiennent ni mot de passe ni valeur de cookie.", + "sg_add": "Ajouter", + "sg_add_groups": "Ajouter des groupes", + "sg_added": "{count} chaînes ajoutées à {group}.", + "sg_all_channels": "Toutes les chaînes", + "sg_all_subscriptions": "Tous les abonnements", + "sg_back_channels": "Retour aux chaînes", + "sg_back_to_results": "Retour aux résultats", + "sg_change_filters": "Essayez un autre groupe ou modifiez votre recherche.", + "sg_channel_count": "{count} chaînes", + "sg_channel_saved": "Groupes de {channel} mis à jour.", + "sg_channels": "Chaînes suivies", + "sg_clear": "Effacer la sélection", + "sg_clear_confirmation": "Retirer les {count} chaînes sélectionnées de tous leurs groupes, y compris celles hors de cette vue ? Vous resterez abonné à ces chaînes.", + "sg_delete_confirmation": "Le groupe et ses appartenances pour {count} chaînes seront supprimés. Les chaînes et vos abonnements seront conservés.", + "sg_delete_group": "Supprimer le groupe", + "sg_delete_named": "Supprimer « {group} » ?", + "sg_duplicate_name": "Un groupe porte déjà ce nom. Choisissez un autre nom.", + "sg_edit_named": "Modifier les groupes de {channel}", + "sg_empty_feed": "Aucune vidéo dans cette vue pour le moment.", + "sg_empty_subscriptions": "Abonnez-vous à des chaînes, puis organisez-les ici. Vous pouvez déjà créer des groupes.", + "sg_feed_filter": "Filtrer les abonnements par groupe", + "sg_group_actions": "Actions pour {group}", + "sg_group_created": "Groupe {group} créé.", + "sg_group_deleted": "Groupe {group} supprimé. Vos abonnements sont conservés.", + "sg_group_filters": "Filtrer les chaînes par groupe", + "sg_group_renamed": "Groupe renommé.", + "sg_groups": "Groupes", + "sg_hidden_selected": "{count} hors de cette vue", + "sg_in_group": "Dans le groupe", + "sg_invalid_name": "Le nom du groupe doit contenir entre 1 et 100 caractères.", + "sg_load_error": "Impossible de charger vos groupes. Vérifiez votre connexion et réessayez.", + "sg_load_more": "Afficher plus · {shown} sur {total}", + "sg_loading": "Chargement des groupes et des chaînes…", + "sg_manage_groups": "Gérer les groupes", + "sg_manager_description": "Organisez vos abonnements. Une chaîne peut appartenir à plusieurs groupes.", + "sg_membership_filter": "Filtre d’appartenance au groupe", + "sg_memberships": "Groupes de la chaîne", + "sg_memberships_cleared": "Appartenances aux groupes supprimées. Vous restez abonné à ces chaînes.", + "sg_new_group": "Nouveau groupe", + "sg_no_channel_match": "Aucune chaîne dans cette vue", + "sg_no_group_match": "Aucun groupe correspondant. Créez un groupe dans le volet latéral.", + "sg_no_groups": "Créez votre premier groupe ci-dessus. Les chaînes peuvent aussi rester sans groupe.", + "sg_not_in_group": "Hors du groupe", + "sg_outside_named": "Hors de {group}", + "sg_partial_failure": "Les modifications de {count} chaînes n’ont pas pu être enregistrées. Certaines ont pu réussir. Vérifiez les groupes actualisés et réessayez.", + "sg_remove": "Retirer", + "sg_remove_all": "Retirer de tous les groupes", + "sg_remove_named": "Retirer {group}", + "sg_removed": "{count} chaînes retirées de {group}.", + "sg_rename_group": "Renommer le groupe", + "sg_retry": "Réessayer", + "sg_save_error": "Impossible d’enregistrer les modifications. Vérifiez votre connexion et réessayez.", + "sg_saving": "Enregistrement…", + "sg_search_channels": "Rechercher des chaînes", + "sg_search_groups": "Rechercher des groupes", + "sg_select_hint": "Sélectionnez des chaînes à modifier ensemble", + "sg_select_results": "Sélectionner les {count} résultats", + "sg_selected": "{count} sélectionnés", + "sg_show_selected": "Voir la sélection", + "sg_target_group": "Groupe cible", + "sg_select_one_result": "Sélectionner le résultat", + "sg_one_channel": "1 chaîne", + "sg_added_one": "1 chaîne ajoutée à {group}.", + "sg_removed_one": "1 chaîne retirée de {group}.", + "sg_clear_one_confirmation": "Retirer la chaîne sélectionnée de tous ses groupes, même si elle est hors de cette vue ? Vous resterez abonné à cette chaîne.", + "sg_delete_one_confirmation": "Le groupe et son appartenance pour 1 chaîne seront supprimés. La chaîne et votre abonnement seront conservés.", + "sg_partial_one_failure": "Les modifications de 1 chaîne n’ont pas pu être enregistrées. Certaines ont pu réussir. Vérifiez les groupes actualisés et réessayez." } From 5493f96a9d08d6e84bc92407fc7835ec38c96f2f Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 04/32] feat: load group memberships and refresh subscription caches --- apps/web/src/hooks/use-subscription-groups.ts | 87 +++++++++++++++++++ apps/web/src/hooks/use-subscriptions.ts | 27 ++++-- apps/web/src/lib/profile-query-cache.ts | 2 + apps/web/tests/profile-query-cache.test.ts | 6 ++ 4 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/hooks/use-subscription-groups.ts diff --git a/apps/web/src/hooks/use-subscription-groups.ts b/apps/web/src/hooks/use-subscription-groups.ts new file mode 100644 index 00000000..8bbf236e --- /dev/null +++ b/apps/web/src/hooks/use-subscription-groups.ts @@ -0,0 +1,87 @@ +import { type UseQueryResult, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRef, useState } from "react"; +import { ApiError } from "../lib/api"; +import { + fetchGroupMemberships, + fetchSubscriptionGroups, + MembershipUpdateError, +} from "../lib/api-subscription-groups"; +import { m } from "../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../types/subscription-groups"; +import { useAuth } from "./use-auth"; + +const GROUPS_KEY = ["subscription-groups"]; +const GROUP_MEMBERSHIPS_KEY = ["subscription-group-memberships"]; + +export function useSubscriptionGroups(): UseQueryResult { + const { authReady, isAuthed, me } = useAuth(); + return useQuery({ + queryKey: [...GROUPS_KEY, me?.id], + queryFn: fetchSubscriptionGroups, + enabled: authReady && isAuthed, + staleTime: 60_000, + }); +} + +export function useGroupMemberships(): UseQueryResult { + const { authReady, isAuthed, me } = useAuth(); + return useQuery({ + queryKey: [...GROUP_MEMBERSHIPS_KEY, me?.id], + queryFn: fetchGroupMemberships, + enabled: authReady && isAuthed, + staleTime: 60_000, + }); +} + +type GroupActions = { + busy: boolean; + error: string | null; + notice: string | null; + clearError: () => void; + run: (action: () => Promise, success: string) => Promise; +}; + +export function useGroupActions(): GroupActions { + const client = useQueryClient(); + const lock = useRef(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + async function run(action: () => Promise, success: string): Promise { + if (lock.current) return false; + lock.current = true; + setBusy(true); + setError(null); + setNotice(null); + let succeeded = false; + try { + await action(); + succeeded = true; + setNotice(success); + } catch (cause) { + setError( + cause instanceof MembershipUpdateError + ? cause.failedUrls.length === 1 + ? m.sg_partial_one_failure() + : m.sg_partial_failure({ count: cause.failedUrls.length }) + : cause instanceof ApiError && cause.code === "subscription_group_name_conflict" + ? m.sg_duplicate_name() + : cause instanceof ApiError && cause.code === "subscription_group_invalid_name" + ? m.sg_invalid_name() + : m.sg_save_error(), + ); + } finally { + await Promise.all( + [GROUPS_KEY, GROUP_MEMBERSHIPS_KEY, ["subscriptions"], ["subscription-feed"]].map( + (queryKey) => client.invalidateQueries({ queryKey }), + ), + ); + lock.current = false; + setBusy(false); + } + return succeeded; + } + + return { busy, error, notice, clearError: () => setError(null), run }; +} diff --git a/apps/web/src/hooks/use-subscriptions.ts b/apps/web/src/hooks/use-subscriptions.ts index 08a9b5c7..840c3036 100644 --- a/apps/web/src/hooks/use-subscriptions.ts +++ b/apps/web/src/hooks/use-subscriptions.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchFilteredSubscriptions } from "../lib/api-subscription-groups"; import { fetchSubscriptions, subscribe, unsubscribe } from "../lib/api-user"; import { normalizeChannelUrl } from "../lib/channel-url"; import type { SubscriptionItem } from "../types/user"; @@ -23,13 +24,13 @@ function dedupeSubscriptions(data: SubscriptionItem[]): SubscriptionItem[] { return output; } -export function useSubscriptions() { +export function useSubscriptions(filter = "all") { const qc = useQueryClient(); const { authReady, isAuthed } = useAuth(); const query = useQuery({ - queryKey: SUBSCRIPTIONS_KEY, - queryFn: fetchSubscriptions, + queryKey: filter === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, filter], + queryFn: () => (filter === "all" ? fetchSubscriptions() : fetchFilteredSubscriptions(filter)), enabled: authReady && isAuthed, select: dedupeSubscriptions, staleTime: 5 * 60 * 1000, @@ -44,12 +45,28 @@ export function useSubscriptions() { channelUrl: normalizeChannelUrl(item.channelUrl), }); }, - onSuccess: () => qc.invalidateQueries({ queryKey: SUBSCRIPTIONS_KEY }), + onSuccess: () => + Promise.all( + [ + SUBSCRIPTIONS_KEY, + ["subscription-groups"], + ["subscription-group-memberships"], + ["subscription-feed"], + ].map((queryKey) => qc.invalidateQueries({ queryKey })), + ), }); const remove = useMutation({ mutationFn: (channelUrl: string) => (isAuthed ? unsubscribe(channelUrl) : Promise.resolve()), - onSuccess: () => qc.invalidateQueries({ queryKey: SUBSCRIPTIONS_KEY }), + onSuccess: () => + Promise.all( + [ + SUBSCRIPTIONS_KEY, + ["subscription-groups"], + ["subscription-group-memberships"], + ["subscription-feed"], + ].map((queryKey) => qc.invalidateQueries({ queryKey })), + ), }); function isSubscribed(channelUrl: string): boolean { diff --git a/apps/web/src/lib/profile-query-cache.ts b/apps/web/src/lib/profile-query-cache.ts index d37e9e2a..51ee7b60 100644 --- a/apps/web/src/lib/profile-query-cache.ts +++ b/apps/web/src/lib/profile-query-cache.ts @@ -30,6 +30,8 @@ const PROFILE_QUERIES = new Set([ "shorts-recommendations", "shorts-subscriptions-fallback", "subscription-feed", + "subscription-groups", + "subscription-group-memberships", "subscriptions", "watch-later", "watch-recommendations", diff --git a/apps/web/tests/profile-query-cache.test.ts b/apps/web/tests/profile-query-cache.test.ts index d468af04..5225ca51 100644 --- a/apps/web/tests/profile-query-cache.test.ts +++ b/apps/web/tests/profile-query-cache.test.ts @@ -15,12 +15,18 @@ test("refreshes mounted subscriptions without replacing their observer or media const media = { url: "playing-video" }; client.setQueryData(["stream", "playing-video"], media); client.setQueryData(["history"], ["original-video"]); + client.setQueryData(["subscription-groups", "original-profile"], ["old-group"]); + client.setQueryData(["subscription-group-memberships", "original-profile"], ["old-membership"]); client.setQueryData(["search-panel-videos", 0], ["original-recommendation"]); profile = "Test"; const refresh = resetProfileQueries(client); expect(observer.getCurrentResult().data).toBeUndefined(); expect(client.getQueryData(["history"])).toBeUndefined(); + expect(client.getQueryData(["subscription-groups", "original-profile"])).toBeUndefined(); + expect( + client.getQueryData(["subscription-group-memberships", "original-profile"]), + ).toBeUndefined(); expect(client.getQueryData(["search-panel-videos", 0])).toBeUndefined(); expect(client.getQueryData(["stream", "playing-video"])).toBe(media); await refresh; From 658cc634ed7859014977f853007fb13168f5fe27 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 05/32] feat: add group forms and destructive action confirmations --- .../group-confirm-dialog.tsx | 64 +++++++++++++++++ .../subscription-groups/group-name-form.tsx | 71 +++++++++++++++++++ apps/web/src/styles/subscription-groups.css | 55 ++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/group-confirm-dialog.tsx create mode 100644 apps/web/src/components/subscription-groups/group-name-form.tsx create mode 100644 apps/web/src/styles/subscription-groups.css diff --git a/apps/web/src/components/subscription-groups/group-confirm-dialog.tsx b/apps/web/src/components/subscription-groups/group-confirm-dialog.tsx new file mode 100644 index 00000000..74ae52fa --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-confirm-dialog.tsx @@ -0,0 +1,64 @@ +import { useEffect, useId, useRef } from "react"; +import { m } from "../../paraglide/messages.js"; + +type Props = { + title: string; + description: string; + confirmLabel: string; + onConfirm: () => void; + onCancel: () => void; +}; + +export function GroupConfirmDialog({ + title, + description, + confirmLabel, + onConfirm, + onCancel, +}: Props): React.JSX.Element { + const dialog = useRef(null); + const cancel = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + useEffect(() => { + const previousFocus = document.activeElement; + const element = dialog.current; + element?.showModal(); + cancel.current?.focus(); + return () => { + element?.close(); + if (previousFocus instanceof HTMLElement) previousFocus.focus(); + }; + }, []); + return ( + { + event.preventDefault(); + onCancel(); + }} + className="m-auto w-[min(28rem,90vw)] border border-border-strong bg-surface p-5 text-fg backdrop:bg-black/60" + > +

+ {title} +

+

+ {description} +

+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/subscription-groups/group-name-form.tsx b/apps/web/src/components/subscription-groups/group-name-form.tsx new file mode 100644 index 00000000..20573efc --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-name-form.tsx @@ -0,0 +1,71 @@ +import { Check, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { m } from "../../paraglide/messages.js"; + +type Props = { + initialName?: string; + busy: boolean; + onSave: (name: string) => Promise; + onCancel?: () => void; +}; + +export function GroupNameForm({ + initialName = "", + busy, + onSave, + onCancel, +}: Props): React.JSX.Element { + const [name, setName] = useState(initialName); + const input = useRef(null); + useEffect(() => { + if (initialName) { + input.current?.focus(); + input.current?.select(); + } + }, [initialName]); + return ( +
{ + if (event.key === "Escape" && onCancel && !busy) { + event.preventDefault(); + onCancel(); + } + }} + onSubmit={async (event) => { + event.preventDefault(); + if (name.trim() && (await onSave(name.trim()))) setName(""); + }} + > + setName(event.target.value)} + disabled={busy} + className="h-9 min-w-0 flex-1 border border-border-strong bg-app px-2 text-sm text-fg placeholder:text-fg-muted" + /> + + {onCancel && ( + + )} +
+ ); +} diff --git a/apps/web/src/styles/subscription-groups.css b/apps/web/src/styles/subscription-groups.css new file mode 100644 index 00000000..6cd7e7ea --- /dev/null +++ b/apps/web/src/styles/subscription-groups.css @@ -0,0 +1,55 @@ +@reference "../index.css"; + +@layer components { + .sg-button { + @apply inline-flex min-h-9 items-center justify-center gap-1.5 border border-border-strong px-3 py-1.5 text-xs font-medium text-fg transition-colors hover:bg-surface-strong disabled:cursor-not-allowed disabled:opacity-40; + } + + .sg-membership-toggle { + @apply bg-app; + } + + .sg-membership-toggle:enabled { + @apply cursor-pointer border-fg-muted bg-surface-strong hover:border-fg hover:bg-surface-soft; + } + + .sg-membership-toggle:enabled[aria-pressed="true"] { + @apply border-fg bg-fg text-app hover:bg-fg-strong; + } + + .sg-chip { + @apply border border-border-strong px-2 py-1 text-xs text-fg-muted; + } + + .sg-menu-item { + @apply px-3 py-2 text-left text-xs hover:bg-surface-strong disabled:opacity-40; + } +} + +.sg-manager :is(button, input, select, a):focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 3px; +} + +.sg-manager :is(input, select) { + caret-color: var(--color-accent); +} + +.sg-combobox:focus-within { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.sg-manager .sg-combobox input:focus-visible { + outline: none; +} + +.sg-manager ::selection { + background: var(--color-accent); + color: var(--color-app); +} + +.sg-manager { + scrollbar-color: var(--color-border-strong) var(--color-surface); + scroll-margin-top: 4rem; +} From f55c78f63d578e86d2559ee051229ca7098a7bd4 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 06/32] feat: add subscription group sidebar and group actions --- .../group-sidebar-item.tsx | 114 ++++++++++++++++++ .../subscription-groups/group-sidebar.tsx | 70 +++++++++++ 2 files changed, 184 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/group-sidebar-item.tsx create mode 100644 apps/web/src/components/subscription-groups/group-sidebar.tsx diff --git a/apps/web/src/components/subscription-groups/group-sidebar-item.tsx b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx new file mode 100644 index 00000000..2bba5dd1 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx @@ -0,0 +1,114 @@ +import { MoreHorizontal } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupNameForm } from "./group-name-form"; + +type Props = { + group: SubscriptionGroup; + active: boolean; + disabled: boolean; + onSelect: () => void; + onRename: (name: string) => Promise; + onCancelRename: () => void; + onDelete: () => void; +}; + +export function GroupSidebarItem({ + group, + active, + disabled, + onSelect, + onRename, + onCancelRename, + onDelete, +}: Props): React.JSX.Element { + const [renaming, setRenaming] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const actionButton = useRef(null); + const wasRenaming = useRef(false); + useEffect(() => { + if (wasRenaming.current && !renaming) actionButton.current?.focus(); + wasRenaming.current = renaming; + }, [renaming]); + if (renaming) + return ( + { + setRenaming(false); + onCancelRename(); + }} + onSave={async (name) => { + const saved = await onRename(name); + if (saved) setRenaming(false); + return saved; + }} + /> + ); + return ( +
+ + + {menuOpen && ( +
{ + if (event.key === "Escape") { + setMenuOpen(false); + actionButton.current?.focus(); + } + }} + > + + +
+ )} +
+ ); +} diff --git a/apps/web/src/components/subscription-groups/group-sidebar.tsx b/apps/web/src/components/subscription-groups/group-sidebar.tsx new file mode 100644 index 00000000..0cdb5cbc --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-sidebar.tsx @@ -0,0 +1,70 @@ +import { Inbox, Users } from "lucide-react"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupNameForm } from "./group-name-form"; +import { GroupSidebarItem } from "./group-sidebar-item"; + +type Props = { + groups: SubscriptionGroup[]; + total: number; + ungrouped: number; + filter: string; + disabled: boolean; + onFilter: (value: string) => void; + onCreate: (name: string) => Promise; + onRename: (id: string, name: string) => Promise; + onCancelRename: () => void; + onDelete: (group: SubscriptionGroup) => void; +}; + +export function GroupSidebar(props: Props): React.JSX.Element { + return ( + + ); +} From b2c1387d7f26a6e39d95fbf2169b0b713cbd1c4b Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 07/32] feat: add searchable inline group membership editing --- .../channel-group-editor.tsx | 63 +++++++++ .../subscription-groups/group-combobox.tsx | 112 ++++++++++++++++ apps/web/src/hooks/use-group-combobox.ts | 120 ++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/channel-group-editor.tsx create mode 100644 apps/web/src/components/subscription-groups/group-combobox.tsx create mode 100644 apps/web/src/hooks/use-group-combobox.ts diff --git a/apps/web/src/components/subscription-groups/channel-group-editor.tsx b/apps/web/src/components/subscription-groups/channel-group-editor.tsx new file mode 100644 index 00000000..cf495e3b --- /dev/null +++ b/apps/web/src/components/subscription-groups/channel-group-editor.tsx @@ -0,0 +1,63 @@ +import { m } from "../../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupCombobox } from "./group-combobox"; + +type Props = { + channel: GroupedSubscription; + groups: SubscriptionGroup[]; + desired: ReadonlySet; + onChange: (ids: Set) => void; + busy: boolean; + onSave: (ids: Set) => void; + onCancel: () => void; +}; + +export function ChannelGroupEditor({ + channel, + groups, + desired, + onChange, + busy, + onSave, + onCancel, +}: Props): React.JSX.Element { + const valid = new Set([...desired].filter((id) => groups.some((group) => group.id === id))); + const changed = + valid.size !== channel.groupIds.length || channel.groupIds.some((id) => !valid.has(id)); + function toggle(id: string): void { + const next = new Set(desired); + if (next.has(id)) next.delete(id); + else next.add(id); + onChange(next); + } + return ( +
{ + event.preventDefault(); + if (changed && !busy) onSave(valid); + }} + onKeyDown={(event) => { + if (event.key === "Escape" && !busy) { + event.preventDefault(); + onCancel(); + } + }} + > +
+ + + +
+
+ ); +} diff --git a/apps/web/src/components/subscription-groups/group-combobox.tsx b/apps/web/src/components/subscription-groups/group-combobox.tsx new file mode 100644 index 00000000..882d0a90 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-combobox.tsx @@ -0,0 +1,112 @@ +import { ChevronDown, Plus, X } from "lucide-react"; +import { useGroupCombobox } from "../../hooks/use-group-combobox"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; + +type Props = { + groups: SubscriptionGroup[]; + selected: ReadonlySet; + disabled: boolean; + onToggle: (id: string) => void; +}; + +export function GroupCombobox({ groups, selected, disabled, onToggle }: Props): React.JSX.Element { + const combo = useGroupCombobox(groups, selected, onToggle); + const expanded = combo.open && !disabled; + return ( +
{ + if (!event.currentTarget.contains(event.relatedTarget)) combo.setOpen(false); + }} + > +
+
+ {combo.chosen.map((group) => ( + + ))} + = 0 ? `${combo.listId}-${combo.active}` : undefined + } + autoComplete="off" + placeholder={m.sg_add_groups()} + value={combo.query} + onChange={(event) => combo.search(event.target.value)} + onClick={() => combo.setOpen(true)} + onKeyDown={combo.onKeyDown} + className="h-6 w-20 min-w-16 flex-1 bg-transparent px-1 text-xs text-fg placeholder:text-fg-muted outline-none" + /> +
+ +
+ {expanded && ( +
+ {combo.matches.map((group, index) => ( + + ))} + {combo.matches.length === 0 && ( +

+ {m.sg_no_group_match()} +

+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/hooks/use-group-combobox.ts b/apps/web/src/hooks/use-group-combobox.ts new file mode 100644 index 00000000..ab9c7584 --- /dev/null +++ b/apps/web/src/hooks/use-group-combobox.ts @@ -0,0 +1,120 @@ +import { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import type { SubscriptionGroup } from "../types/subscription-groups"; + +export function useGroupCombobox( + groups: SubscriptionGroup[], + selected: ReadonlySet, + onToggle: (id: string) => void, +): { + input: React.RefObject; + root: React.RefObject; + list: React.RefObject; + listId: string; + query: string; + open: boolean; + active: number; + placement: { above: boolean; height: number }; + matches: SubscriptionGroup[]; + chosen: SubscriptionGroup[]; + setOpen: (open: boolean) => void; + search: (query: string) => void; + choose: (id: string) => void; + onKeyDown: (event: KeyboardEvent) => void; +} { + const input = useRef(null); + const root = useRef(null); + const list = useRef(null); + const listId = useId(); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [highlighted, setHighlighted] = useState(0); + const [placement, setPlacement] = useState({ above: false, height: 224 }); + const chosen = groups.filter((group) => selected.has(group.id)); + const matches = groups.filter( + (group) => + !selected.has(group.id) && + group.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()), + ); + const active = Math.min(highlighted, matches.length - 1); + useLayoutEffect(() => { + if (chosen.length > 0 && document.activeElement === input.current) { + input.current?.scrollIntoView({ block: "nearest" }); + } + }, [chosen.length]); + useLayoutEffect(() => { + if (!open) return; + function position(): void { + const bounds = root.current?.getBoundingClientRect(); + if (!bounds) return; + const below = window.innerHeight - bounds.bottom - 12; + const above = bounds.top - 68; + const flip = below < Math.min(224, Math.max(40, matches.length * 36)) && above > below; + setPlacement({ above: flip, height: Math.max(40, Math.min(224, flip ? above : below)) }); + } + position(); + window.addEventListener("resize", position); + window.addEventListener("scroll", position, true); + return () => { + window.removeEventListener("resize", position); + window.removeEventListener("scroll", position, true); + }; + }, [open, matches.length]); + useEffect(() => { + if (open) list.current?.children[active]?.scrollIntoView({ block: "nearest" }); + }, [active, open]); + function search(value: string): void { + setQuery(value); + setHighlighted(0); + setOpen(true); + } + function choose(id: string): void { + onToggle(id); + setQuery(""); + setHighlighted(0); + input.current?.focus(); + } + function onKeyDown(event: KeyboardEvent): void { + if (event.nativeEvent.isComposing) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + setOpen(true); + setHighlighted( + !open + ? event.key === "ArrowDown" + ? 0 + : Math.max(0, matches.length - 1) + : Math.max( + 0, + Math.min(matches.length - 1, active + (event.key === "ArrowDown" ? 1 : -1)), + ), + ); + } else if (event.key === "Enter") { + event.preventDefault(); + if (open && matches[active]) choose(matches[active].id); + else setOpen(true); + } else if (event.key === "Escape" && open) { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + } else if (event.key === "Backspace" && !query && chosen.length) { + event.preventDefault(); + onToggle(chosen[chosen.length - 1].id); + } + } + return { + input, + root, + list, + listId, + query, + open, + active, + placement, + matches, + chosen, + setOpen, + search, + choose, + onKeyDown, + }; +} From 7147e696cf863e2bfb365ea28a4d81c45e5d512e Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 08/32] feat: manage channel selection and group edit drafts --- apps/web/src/hooks/use-group-manager.ts | 179 ++++++++++++++++++++++ apps/web/src/hooks/use-group-selection.ts | 47 ++++++ 2 files changed, 226 insertions(+) create mode 100644 apps/web/src/hooks/use-group-manager.ts create mode 100644 apps/web/src/hooks/use-group-selection.ts diff --git a/apps/web/src/hooks/use-group-manager.ts b/apps/web/src/hooks/use-group-manager.ts new file mode 100644 index 00000000..9461b0f6 --- /dev/null +++ b/apps/web/src/hooks/use-group-manager.ts @@ -0,0 +1,179 @@ +import { useState } from "react"; +import { deleteSubscriptionGroup, updateGroupMemberships } from "../lib/api-subscription-groups"; +import { + clearMembershipChanges, + filterGroupChannels, + selectGroupResults, +} from "../lib/subscription-group-selection"; +import { m } from "../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../types/subscription-groups"; +import { useGroupSelection } from "./use-group-selection"; +import { useGroupActions } from "./use-subscription-groups"; + +type State = { + actions: ReturnType; + activeFilter: string; + activeGroup: SubscriptionGroup | undefined; + excluded: boolean; + query: string; + chosen: GroupedSubscription[]; + validSelected: Set; + visible: GroupedSubscription[]; + hiddenCount: number; + filterName: string; + disabled: boolean; + editing: string | null; + drafts: ReadonlyMap>; + onlySelected: boolean; + confirmationProps: { title: string; description: string; confirmLabel: string } | null; + setExcluded: (value: boolean) => void; + setQuery: (value: string) => void; + selectResults: () => void; + setOnlySelected: (value: boolean) => void; + setDraft: (url: string, ids: Set) => void; + setConfirmation: (value: SubscriptionGroup | "clear" | null) => void; + changeFilter: (value: string) => void; + toggle: (url: string) => void; + clearSelection: () => void; + bulk: (groupId: string, action: "add" | "remove") => Promise; + confirm: () => Promise; +}; + +export function useGroupManager( + groups: SubscriptionGroup[], + channels: GroupedSubscription[], +): State { + const actions = useGroupActions(); + const [filter, setFilter] = useState("all"); + const [excluded, setExcluded] = useState(false); + const [query, setQuery] = useState(""); + const selection = useGroupSelection(channels); + const [onlySelected, setOnlySelected] = useState(false); + const [confirmation, setConfirmation] = useState(null); + const { chosen, selected: validSelected, drafts, setDraft } = selection; + const activeGroup = groups.find((group) => group.id === filter); + const activeFilter = activeGroup || filter === "ungrouped" ? filter : "all"; + const visible = filterGroupChannels( + channels, + activeFilter, + excluded, + query, + validSelected, + onlySelected, + ); + const hiddenCount = + chosen.length - visible.filter((channel) => validSelected.has(channel.channelUrl)).length; + const filterName = + activeGroup?.name ?? + (activeFilter === "ungrouped" ? m.groups_preview_ungrouped() : m.sg_all_channels()); + const editing = chosen.length === 1 ? chosen[0].channelUrl : null; + const disabled = actions.busy; + + function toggle(url: string): void { + actions.clearError(); + selection.toggle(url); + } + function changeFilter(value: string): void { + actions.clearError(); + setFilter(value); + setExcluded(false); + setOnlySelected(false); + } + function clearSelection(): void { + actions.clearError(); + selection.select(new Set()); + setOnlySelected(false); + } + async function bulk(groupId: string, action: "add" | "remove"): Promise { + const channelUrls = chosen + .filter((channel) => channel.groupIds.includes(groupId) === (action === "remove")) + .map((channel) => channel.channelUrl); + const name = groups.find((group) => group.id === groupId)?.name ?? ""; + const message = + action === "add" + ? channelUrls.length === 1 + ? m.sg_added_one({ group: name }) + : m.sg_added({ count: channelUrls.length, group: name }) + : channelUrls.length === 1 + ? m.sg_removed_one({ group: name }) + : m.sg_removed({ count: channelUrls.length, group: name }); + if ( + await actions.run(() => updateGroupMemberships([{ groupId, channelUrls, action }]), message) + ) + clearSelection(); + } + async function confirm(): Promise { + const pending = confirmation; + setConfirmation(null); + if (pending === "clear") { + if ( + await actions.run( + () => updateGroupMemberships(clearMembershipChanges(chosen)), + m.sg_memberships_cleared(), + ) + ) + clearSelection(); + } else if (pending) { + if ( + (await actions.run( + () => deleteSubscriptionGroup(pending.id), + m.sg_group_deleted({ group: pending.name }), + )) && + activeFilter === pending.id + ) + changeFilter("all"); + } + } + + const confirmationProps = confirmation + ? { + title: + confirmation === "clear" + ? m.sg_remove_all() + : m.sg_delete_named({ group: confirmation.name }), + description: + confirmation === "clear" + ? chosen.length === 1 + ? m.sg_clear_one_confirmation() + : m.sg_clear_confirmation({ count: chosen.length }) + : confirmation.channelCount === 1 + ? m.sg_delete_one_confirmation() + : m.sg_delete_confirmation({ count: confirmation.channelCount }), + confirmLabel: confirmation === "clear" ? m.sg_remove_all() : m.sg_delete_group(), + } + : null; + return { + actions, + activeFilter, + activeGroup, + excluded, + query, + chosen, + validSelected, + visible, + hiddenCount, + filterName, + disabled, + editing, + drafts, + onlySelected, + confirmationProps, + setExcluded, + setQuery, + selectResults: () => + selection.select( + selectGroupResults( + validSelected, + visible.map((channel) => channel.channelUrl), + ), + ), + setOnlySelected, + setDraft, + setConfirmation, + changeFilter, + toggle, + clearSelection, + bulk, + confirm, + }; +} diff --git a/apps/web/src/hooks/use-group-selection.ts b/apps/web/src/hooks/use-group-selection.ts new file mode 100644 index 00000000..44046339 --- /dev/null +++ b/apps/web/src/hooks/use-group-selection.ts @@ -0,0 +1,47 @@ +import { useState } from "react"; +import type { GroupedSubscription } from "../types/subscription-groups"; + +type Selection = { + urls: ReadonlySet; + drafts: ReadonlyMap>; +}; + +function selectUrls(current: Selection, urls: ReadonlySet): Selection { + return { + urls, + drafts: new Map([...current.drafts].filter(([url]) => urls.has(url))), + }; +} + +export function useGroupSelection(channels: GroupedSubscription[]): { + chosen: GroupedSubscription[]; + selected: Set; + drafts: Selection["drafts"]; + select: (urls: Set) => void; + toggle: (url: string) => void; + setDraft: (url: string, ids: Set) => void; +} { + const [state, setState] = useState({ urls: new Set(), drafts: new Map() }); + const chosen = channels.filter((channel) => state.urls.has(channel.channelUrl)); + return { + chosen, + selected: new Set(chosen.map((channel) => channel.channelUrl)), + drafts: state.drafts, + select: (urls) => setState((current) => selectUrls(current, urls)), + toggle: (url) => { + setState((current) => { + const urls = new Set(current.urls); + if (urls.has(url)) urls.delete(url); + else urls.add(url); + return selectUrls(current, urls); + }); + }, + setDraft: (url, ids) => { + setState((current) => + current.urls.has(url) + ? { ...current, drafts: new Map(current.drafts).set(url, ids) } + : current, + ); + }, + }; +} From 496762933190ca19b67c79028f22361dd59a0bd5 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 09/32] feat: show group editors for single selected channel rows --- .../group-channel-list.tsx | 60 ++++++++++++ .../subscription-groups/group-channel-row.tsx | 98 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/group-channel-list.tsx create mode 100644 apps/web/src/components/subscription-groups/group-channel-row.tsx diff --git a/apps/web/src/components/subscription-groups/group-channel-list.tsx b/apps/web/src/components/subscription-groups/group-channel-list.tsx new file mode 100644 index 00000000..10689a51 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-channel-list.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { m } from "../../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupChannelRow } from "./group-channel-row"; + +type Props = { + channels: GroupedSubscription[]; + groups: SubscriptionGroup[]; + selected: ReadonlySet; + editing: string | null; + drafts: ReadonlyMap>; + busy: boolean; + onToggle: (url: string) => void; + onDraft: (url: string, ids: Set) => void; + onCancel: () => void; + onSave: (channel: GroupedSubscription, ids: Set) => Promise; +}; + +export function GroupChannelList(props: Props): React.JSX.Element { + const [limit, setLimit] = useState(50); + if (props.channels.length === 0) + return ( +
+

{m.sg_no_channel_match()}

+

{m.sg_change_filters()}

+
+ ); + return ( + <> +
    + {props.channels.slice(0, limit).map((channel) => ( + props.onToggle(channel.channelUrl)} + onDraft={(ids) => props.onDraft(channel.channelUrl, ids)} + onCancel={props.onCancel} + onSave={(ids) => props.onSave(channel, ids)} + /> + ))} +
+ {props.channels.length > limit && ( + + )} + + ); +} diff --git a/apps/web/src/components/subscription-groups/group-channel-row.tsx b/apps/web/src/components/subscription-groups/group-channel-row.tsx new file mode 100644 index 00000000..d03a3dbc --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-channel-row.tsx @@ -0,0 +1,98 @@ +import { useId, useRef } from "react"; +import { proxyImage } from "../../lib/proxy"; +import { m } from "../../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../../types/subscription-groups"; +import { ChannelAvatar } from "../channel-avatar"; +import { ChannelRouteLink } from "../channel-route-link"; +import { ChannelGroupEditor } from "./channel-group-editor"; + +type Props = { + channel: GroupedSubscription; + groups: SubscriptionGroup[]; + selected: boolean; + editing: boolean; + draft: ReadonlySet | undefined; + disabled: boolean; + busy: boolean; + onSelect: () => void; + onDraft: (ids: Set) => void; + onCancel: () => void; + onSave: (ids: Set) => Promise; +}; + +export function GroupChannelRow(props: Props): React.JSX.Element { + const { channel, groups, editing } = props; + const selectionId = useId(); + const checkbox = useRef(null); + const memberships = groups.filter((group) => channel.groupIds.includes(group.id)); + function cancel(): void { + props.onCancel(); + checkbox.current?.focus(); + } + return ( +
  • + +
    + + +
    + + {channel.name} + +

    + {channel.channelUrl.replace(/^https?:\/\/(www\.)?/, "")} +

    +
    + {editing ? ( + { + if (await props.onSave(ids)) cancel(); + }} + /> + ) : ( +
    + {memberships.length > 0 ? ( + memberships.map((group) => ( + + {group.name} + + )) + ) : ( + {m.sg_add_groups()} + )} +
    + )} +
    +
  • + ); +} From 46edab7e6556176cd01cc3f53c7dee1f9e73c485 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 10/32] feat: add group membership filters and bulk action toolbar --- .../subscription-groups/group-toolbar.tsx | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/group-toolbar.tsx diff --git a/apps/web/src/components/subscription-groups/group-toolbar.tsx b/apps/web/src/components/subscription-groups/group-toolbar.tsx new file mode 100644 index 00000000..fe1fc90b --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-toolbar.tsx @@ -0,0 +1,161 @@ +import { ArrowLeftRight, Search, X } from "lucide-react"; +import { useState } from "react"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; + +type Props = { + groups: SubscriptionGroup[]; + defaultTarget: string; + filterName: string; + isGroup: boolean; + excluded: boolean; + query: string; + selectedCount: number; + hiddenCount: number; + resultCount: number; + allSelected: boolean; + onlySelected: boolean; + disabled: boolean; + busy: boolean; + onQuery: (value: string) => void; + onExcluded: (value: boolean) => void; + onSelectResults: () => void; + onClear: () => void; + onOnlySelected: () => void; + onBulk: (groupId: string, action: "add" | "remove") => void; + onRemoveAll: () => void; +}; + +export function GroupToolbar(props: Props): React.JSX.Element { + const [target, setTarget] = useState(props.defaultTarget); + const validTarget = props.groups.some((group) => group.id === target) ? target : ""; + return ( +
    +
    + + + +
    +
    +
    +

    + {props.busy + ? m.sg_saving() + : props.selectedCount > 0 + ? m.sg_selected({ count: props.selectedCount }) + : m.sg_select_hint()} +

    + {props.hiddenCount > 0 && ( +

    + {m.sg_hidden_selected({ count: props.hiddenCount })} +

    + )} +
    + {(props.selectedCount > 0 || props.onlySelected) && ( + + )} + + + + + +
    +
    + + {props.onlySelected + ? m.sg_show_selected() + : props.isGroup && props.excluded + ? m.sg_outside_named({ group: props.filterName }) + : props.filterName} + + + {props.resultCount === 1 + ? m.sg_one_channel() + : m.sg_channel_count({ count: props.resultCount })} + +
    +
    + ); +} From 358224f6057a1861860842d6297200dc3f4139a4 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 11/32] feat: assemble the subscription group management page --- .../subscription-groups/group-manager.tsx | 184 ++++++++++++++++++ apps/web/src/lib/auth-routes.ts | 2 + apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/subscriptions_.groups.tsx | 12 ++ 4 files changed, 219 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/group-manager.tsx create mode 100644 apps/web/src/routes/subscriptions_.groups.tsx diff --git a/apps/web/src/components/subscription-groups/group-manager.tsx b/apps/web/src/components/subscription-groups/group-manager.tsx new file mode 100644 index 00000000..4e223ae3 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-manager.tsx @@ -0,0 +1,184 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import { useMemo } from "react"; +import { useGroupManager } from "../../hooks/use-group-manager"; +import { useGroupMemberships, useSubscriptionGroups } from "../../hooks/use-subscription-groups"; +import { + createSubscriptionGroup, + renameSubscriptionGroup, + updateGroupMemberships, +} from "../../lib/api-subscription-groups"; +import { channelMembershipChanges } from "../../lib/subscription-group-selection"; +import { m } from "../../paraglide/messages.js"; +import { GroupChannelList } from "./group-channel-list"; +import { GroupConfirmDialog } from "./group-confirm-dialog"; +import { GroupSidebar } from "./group-sidebar"; +import { GroupToolbar } from "./group-toolbar"; +import "../../styles/subscription-groups.css"; + +export function GroupManager(): React.JSX.Element { + const groupsQuery = useSubscriptionGroups(); + const channelsQuery = useGroupMemberships(); + const groups = useMemo( + () => [...(groupsQuery.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [groupsQuery.data], + ); + const channels = useMemo( + () => [...(channelsQuery.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [channelsQuery.data], + ); + const { + actions, + activeFilter, + activeGroup, + excluded, + query, + chosen, + validSelected, + visible, + hiddenCount, + filterName, + disabled, + editing, + drafts, + onlySelected, + confirmationProps, + setExcluded, + setQuery, + selectResults, + setOnlySelected, + setDraft, + setConfirmation, + changeFilter, + toggle, + clearSelection, + bulk, + confirm, + } = useGroupManager(groups, channels); + return ( +
    +
    +
    +

    {m.sg_manage_groups()}

    +

    {m.sg_manager_description()}

    +
    + + + {m.sg_back_channels()} + +
    + {groupsQuery.isPending || channelsQuery.isPending ? ( +

    + {m.sg_loading()} +

    + ) : (groupsQuery.isError && !groupsQuery.data) || + (channelsQuery.isError && !channelsQuery.data) ? ( +
    +

    {m.sg_load_error()}

    + +
    + ) : ( +
    + channel.groupIds.length === 0).length} + filter={activeFilter} + disabled={disabled} + onFilter={changeFilter} + onCreate={(name) => + actions.run(() => createSubscriptionGroup(name), m.sg_group_created({ group: name })) + } + onRename={(id, name) => + actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed()) + } + onDelete={setConfirmation} + onCancelRename={actions.clearError} + /> +
    + 0 && + visible.every((channel) => validSelected.has(channel.channelUrl)) + } + onlySelected={onlySelected} + disabled={disabled} + busy={actions.busy} + onQuery={setQuery} + onExcluded={setExcluded} + onSelectResults={selectResults} + onClear={clearSelection} + onOnlySelected={() => setOnlySelected(!onlySelected)} + onBulk={bulk} + onRemoveAll={() => setConfirmation("clear")} + /> + {actions.error && ( +

    + {actions.error} +

    + )} + {actions.notice && ( +

    + {actions.notice} +

    + )} + {channels.length === 0 ? ( +
    +

    {m.ui_no_subscriptions_yet_2()}

    +

    {m.sg_empty_subscriptions()}

    +
    + ) : ( + + actions.run( + () => updateGroupMemberships(channelMembershipChanges(channel, ids)), + m.sg_channel_saved({ channel: channel.name }), + ) + } + /> + )} +
    +
    + )} + {confirmationProps && ( + setConfirmation(null)} + onConfirm={confirm} + /> + )} +
    + ); +} diff --git a/apps/web/src/lib/auth-routes.ts b/apps/web/src/lib/auth-routes.ts index cc9a7111..5fc6fda6 100644 --- a/apps/web/src/lib/auth-routes.ts +++ b/apps/web/src/lib/auth-routes.ts @@ -7,6 +7,7 @@ export type RedirectTarget = | "/profile" | "/settings" | "/subscriptions" + | "/subscriptions/groups" | "/youtube-session" | `/youtube-session?returnTo=${string}` | `/shorts?v=${string}`; @@ -61,6 +62,7 @@ export function sanitizeRedirect(value: string | undefined): RedirectTarget { if (value === "/profile") return "/profile"; if (value === "/settings") return "/settings"; if (value === "/subscriptions") return "/subscriptions"; + if (value === "/subscriptions/groups") return "/subscriptions/groups"; if (value === "/youtube-session") return "/youtube-session"; if (value === "/playlists" || value.startsWith("/playlists/")) return "/playlists"; return "/"; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f0df2748..7304c128 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -41,6 +41,7 @@ import { Route as ImportYoutubeRouteImport } from './routes/import/youtube' import { Route as PlaylistsIdRouteImport } from './routes/playlists_.$id' import { Route as ShortsVideoIdRouteImport } from './routes/shorts_.$videoId' import { Route as SubscriptionsChannelsRouteImport } from './routes/subscriptions_.channels' +import { Route as SubscriptionsGroupsRouteImport } from './routes/subscriptions_.groups' import { Route as AuthOidcCallbackRouteImport } from './routes/auth.oidc.callback' const IndexRoute = IndexRouteImport.update({ @@ -204,6 +205,11 @@ const SubscriptionsChannelsRoute = SubscriptionsChannelsRouteImport.update({ path: '/subscriptions/channels', getParentRoute: () => rootRouteImport, } as any) +const SubscriptionsGroupsRoute = SubscriptionsGroupsRouteImport.update({ + id: '/subscriptions_/groups', + path: '/subscriptions/groups', + getParentRoute: () => rootRouteImport, +} as any) const AuthOidcCallbackRoute = AuthOidcCallbackRouteImport.update({ id: '/auth/oidc/callback', path: '/auth/oidc/callback', @@ -242,6 +248,7 @@ export interface FileRoutesByFullPath { '/playlists/$id': typeof PlaylistsIdRoute '/shorts/$videoId': typeof ShortsVideoIdRoute '/subscriptions/channels': typeof SubscriptionsChannelsRoute + '/subscriptions/groups': typeof SubscriptionsGroupsRoute '/import/': typeof ImportIndexRoute '/auth/oidc/callback': typeof AuthOidcCallbackRoute } @@ -276,6 +283,7 @@ export interface FileRoutesByTo { '/playlists/$id': typeof PlaylistsIdRoute '/shorts/$videoId': typeof ShortsVideoIdRoute '/subscriptions/channels': typeof SubscriptionsChannelsRoute + '/subscriptions/groups': typeof SubscriptionsGroupsRoute '/import': typeof ImportIndexRoute '/auth/oidc/callback': typeof AuthOidcCallbackRoute } @@ -312,6 +320,7 @@ export interface FileRoutesById { '/playlists_/$id': typeof PlaylistsIdRoute '/shorts_/$videoId': typeof ShortsVideoIdRoute '/subscriptions_/channels': typeof SubscriptionsChannelsRoute + '/subscriptions_/groups': typeof SubscriptionsGroupsRoute '/import/': typeof ImportIndexRoute '/auth/oidc/callback': typeof AuthOidcCallbackRoute } @@ -349,6 +358,7 @@ export interface FileRouteTypes { | '/playlists/$id' | '/shorts/$videoId' | '/subscriptions/channels' + | '/subscriptions/groups' | '/import/' | '/auth/oidc/callback' fileRoutesByTo: FileRoutesByTo @@ -383,6 +393,7 @@ export interface FileRouteTypes { | '/playlists/$id' | '/shorts/$videoId' | '/subscriptions/channels' + | '/subscriptions/groups' | '/import' | '/auth/oidc/callback' id: @@ -418,6 +429,7 @@ export interface FileRouteTypes { | '/playlists_/$id' | '/shorts_/$videoId' | '/subscriptions_/channels' + | '/subscriptions_/groups' | '/import/' | '/auth/oidc/callback' fileRoutesById: FileRoutesById @@ -452,6 +464,7 @@ export interface RootRouteChildren { PlaylistsIdRoute: typeof PlaylistsIdRoute ShortsVideoIdRoute: typeof ShortsVideoIdRoute SubscriptionsChannelsRoute: typeof SubscriptionsChannelsRoute + SubscriptionsGroupsRoute: typeof SubscriptionsGroupsRoute AuthOidcCallbackRoute: typeof AuthOidcCallbackRoute } @@ -681,6 +694,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SubscriptionsChannelsRouteImport parentRoute: typeof rootRouteImport } + '/subscriptions_/groups': { + id: '/subscriptions_/groups' + path: '/subscriptions/groups' + fullPath: '/subscriptions/groups' + preLoaderRoute: typeof SubscriptionsGroupsRouteImport + parentRoute: typeof rootRouteImport + } '/auth/oidc/callback': { id: '/auth/oidc/callback' path: '/auth/oidc/callback' @@ -736,6 +756,7 @@ const rootRouteChildren: RootRouteChildren = { PlaylistsIdRoute: PlaylistsIdRoute, ShortsVideoIdRoute: ShortsVideoIdRoute, SubscriptionsChannelsRoute: SubscriptionsChannelsRoute, + SubscriptionsGroupsRoute: SubscriptionsGroupsRoute, AuthOidcCallbackRoute: AuthOidcCallbackRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/subscriptions_.groups.tsx b/apps/web/src/routes/subscriptions_.groups.tsx new file mode 100644 index 00000000..102ea529 --- /dev/null +++ b/apps/web/src/routes/subscriptions_.groups.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { GroupManager } from "../components/subscription-groups/group-manager"; +import { useAuth } from "../hooks/use-auth"; + +function SubscriptionGroupsPage(): React.JSX.Element { + const { me } = useAuth(); + return ; +} + +export const Route = createFileRoute("/subscriptions_/groups")({ + component: SubscriptionGroupsPage, +}); From 6696598ee6856c146d525518d6870ba4c1ecf9b1 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 12/32] feat: integrate group filters into subscription views --- .../components/portability-import-panel.tsx | 21 ++++++++- .../components/subscription-group-filter.tsx | 28 +++++++++++ .../src/components/subscriptions-header.tsx | 11 ++++- apps/web/src/hooks/use-subscription-feed.ts | 16 +++++-- apps/web/src/lib/api-user.ts | 5 +- apps/web/src/routes/subscriptions.tsx | 46 ++++++++++++------ .../src/routes/subscriptions_.channels.tsx | 47 +++++++++++++------ 7 files changed, 137 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/components/subscription-group-filter.tsx diff --git a/apps/web/src/components/portability-import-panel.tsx b/apps/web/src/components/portability-import-panel.tsx index 683e6e67..5cafaa7f 100644 --- a/apps/web/src/components/portability-import-panel.tsx +++ b/apps/web/src/components/portability-import-panel.tsx @@ -1,4 +1,5 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { ArchiveRestore, FileUp } from "lucide-react"; import { type DragEvent, useEffect, useMemo, useRef, useState } from "react"; import { usePersistedPortabilityJob } from "../hooks/use-persisted-portability-job"; @@ -63,13 +64,21 @@ export function PortabilityImportPanel({ formats }: { formats: PortabilityFormat useEffect(() => { const state = job.data?.state ?? null; if (state === "completed" && previousState.current !== "completed") { + for (const key of [ + "subscription-groups", + "subscription-group-memberships", + "subscriptions", + "subscription-feed", + ]) { + void queryClient.invalidateQueries({ queryKey: [key] }); + } const count = Object.values(job.data?.result ?? {}).reduce((sum, value) => sum + value, 0); setToast( `${m.portability_import_completed()}: ${count.toLocaleString()} ${m.portability_items()}`, ); } previousState.current = state; - }, [job.data?.result, job.data?.state]); + }, [job.data?.result, job.data?.state, queryClient]); useEffect(() => { if (!job.missing || !jobId) return; @@ -183,6 +192,16 @@ export function PortabilityImportPanel({ formats }: { formats: PortabilityFormat {job.data && ["completed", "failed", "cancelled"].includes(job.data.state) && (
    + {job.data.state === "completed" && + ((job.data.result?.subscriptions ?? 0) > 0 || + (job.data.result?.subscriptionGroups ?? 0) > 0) && ( + + {m.sg_manage_groups()} + + )} +
    ) : ( <> {visible.length === 0 && ( @@ -95,9 +114,25 @@ function SubscriptionsPage() { )} {isFetchingNextPage && } + {isFetchNextPageError && ( +
    +

    {m.subscriptions_feed_next_page_error()}

    + +
    + )} )} diff --git a/apps/web/tests/subscription-feed-errors.test.tsx b/apps/web/tests/subscription-feed-errors.test.tsx new file mode 100644 index 00000000..10628220 --- /dev/null +++ b/apps/web/tests/subscription-feed-errors.test.tsx @@ -0,0 +1,124 @@ +import { afterEach, expect, test } from "bun:test"; +import { InfiniteQueryObserver, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SUBSCRIPTION_FEED_KEY, useSubscriptionFeed } from "../src/hooks/use-subscription-feed"; +import { SUBSCRIPTIONS_KEY } from "../src/hooks/use-subscriptions"; +import { fetchSubscriptionFeed } from "../src/lib/api-user"; +import { useAuthStore } from "../src/stores/auth-store"; +import type { SubscriptionFeedPage, VideoItem } from "../src/types/api"; + +const originalFetch = globalThis.fetch; +const clients: QueryClient[] = []; +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); + for (const client of clients.splice(0)) client.clear(); +}); + +function readFeed(client: QueryClient): ReturnType { + let state: ReturnType | undefined; + function ReadFeed(): null { + state = useSubscriptionFeed(); + return null; + } + renderToStaticMarkup( + + + , + ); + if (!state) throw new Error("Feed hook did not render"); + return state; +} + +function setup() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + client.setQueryData(SUBSCRIPTIONS_KEY, []); + useAuthStore.getState().setToken("feed-error-test"); + const observer = new InfiniteQueryObserver(client, { + queryKey: SUBSCRIPTION_FEED_KEY, + queryFn: ({ pageParam }) => fetchSubscriptionFeed(pageParam), + initialPageParam: null as string | null, + getNextPageParam: (last: SubscriptionFeedPage) => last.nextpage ?? undefined, + }); + return { client, observer }; +} + +function video(id: string): VideoItem { + return { + id, + url: `https://www.youtube.com/watch?v=${id}`, + title: id, + thumbnailUrl: "", + uploaderName: "Test channel", + uploaderUrl: "", + uploaderAvatarUrl: "", + uploaderVerified: false, + duration: 100, + viewCount: 1, + uploadDate: "", + uploaded: 0, + streamType: "VIDEO_STREAM", + isLive: false, + isPostLive: false, + isLiveContent: false, + requiresMembership: false, + isShortFormContent: false, + shortDescription: null, + }; +} + +test("a failed next page preserves videos and retries the same cursor", async () => { + const { client, observer } = setup(); + let failNext = true; + const cursors: Array = []; + globalThis.fetch = async (input) => { + const cursor = new URL(String(input), "https://fixture.invalid").searchParams.get("cursor"); + cursors.push(cursor); + if (cursor && failNext) return Response.json({ error: "Unavailable" }, { status: 503 }); + return Response.json({ + videos: [video(cursor ? "second" : "first")], + nextpage: cursor ? null : "page-2", + }); + }; + await observer.refetch(); + await observer.fetchNextPage(); + const failed = readFeed(client); + expect(failed.streams.map((item) => item.title)).toEqual(["first"]); + expect(failed.isLoadingError).toBe(false); + expect(failed.isFetchNextPageError).toBe(true); + expect(failed.hasNextPage).toBe(true); + + failNext = false; + await failed.fetchNextPage(); + const recovered = readFeed(client); + expect(recovered.streams.map((item) => item.title)).toEqual(["first", "second"]); + expect(recovered.isFetchNextPageError).toBe(false); + expect(recovered.hasNextPage).toBe(false); + expect(cursors).toEqual([null, "page-2", "page-2"]); +}); + +test("an initial-load failure exposes recovery without a pagination error", async () => { + const { client, observer } = setup(); + globalThis.fetch = async () => Response.json({ error: "Unavailable" }, { status: 503 }); + await observer.refetch(); + const failed = readFeed(client); + expect(failed.isLoadingError).toBe(true); + expect(failed.isFetchNextPageError).toBe(false); + expect(failed.streams).toEqual([]); + globalThis.fetch = async () => Response.json({ videos: [video("first")], nextpage: null }); + await failed.refetch(); + expect(readFeed(client).isLoadingError).toBe(false); +}); + +test("a failed background refresh retains cached feed content", async () => { + const { client, observer } = setup(); + globalThis.fetch = async () => Response.json({ videos: [video("first")], nextpage: null }); + await observer.refetch(); + globalThis.fetch = async () => Response.json({ error: "Unavailable" }, { status: 503 }); + await observer.refetch(); + const failed = readFeed(client); + expect(failed.isLoadingError).toBe(false); + expect(failed.isFetchNextPageError).toBe(false); + expect(failed.streams.map((item) => item.title)).toEqual(["first"]); +}); From 9cc1cf774443ec083719d0cb73cdcc19727a0d61 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:46:07 +0900 Subject: [PATCH 15/32] test: add varied subscription group fixture data --- scripts/fixtures/subscription-groups-data.ts | 165 ++++++++++++++++++ scripts/fixtures/subscription-groups-state.ts | 112 ++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 scripts/fixtures/subscription-groups-data.ts create mode 100644 scripts/fixtures/subscription-groups-state.ts diff --git a/scripts/fixtures/subscription-groups-data.ts b/scripts/fixtures/subscription-groups-data.ts new file mode 100644 index 00000000..68df83d8 --- /dev/null +++ b/scripts/fixtures/subscription-groups-data.ts @@ -0,0 +1,165 @@ +import type { VideoItem } from "../../apps/web/src/types/api"; +import type { + GroupedSubscription, + SubscriptionGroup, +} from "../../apps/web/src/types/subscription-groups"; + +const GROUP_NAMES = [ + "Tech", + "Video essays", + "Music", + "Science", + "Cooking", + "Gaming", + "News", + "Design", + "DIY & making", + "Travel", + "History", + "Photography", + "Podcasts", + "Fitness", + "Languages", + "Space", + "Weekend watchlist — documentaries and deep dives", + "To explore", +]; +const ORIGINAL_NAMES = [ + "Lemnos Life", + "Mental Outlaw", + "Jack Rhysider", + "Network Chuck", + "Fireship", + "Veritasium", + "Smarter Every Day", + "Behoops", + "Red Shirts", + "Annie Bramley", + "KEXP", + "NPR Music", + "Dorian Me", + "Arcade Sound", + "Cooking Comically", + "Technology Connections", + "The B1M", + "Noclip", + "Asianometry", + "PBS Space Time", + "DW News", +]; +const SUBJECTS = [ + "Analog", + "Architecture", + "Astronomy", + "Baking", + "Cinema", + "Circuit", + "Design", + "Ecology", + "History", + "Indie Games", + "Jazz", + "Language", + "Ocean", + "Photography", + "Robotics", +]; +const FORMATS = [ + "Lab", + "Notebook", + "Journal", + "Workshop", + "Studio", + "Archive", + "Field Notes", + "Weekly", + "Collective", +]; +const SPECIAL_NAMES = [ + "A", + "Café des idées", + "京都の小さな工房", + "서울 디자인 스튜디오", + "حكايات العلوم", + "Atlas Workshop — repairing, rebuilding and understanding everyday machines one project at a time", +]; +const EPOCH = Date.UTC(2026, 8, 1); + +export function makeGroups(): SubscriptionGroup[] { + return GROUP_NAMES.map((name, index) => ({ + id: `10000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, + name, + channelCount: 0, + createdAt: EPOCH, + updatedAt: EPOCH, + })); +} + +export function makeChannels(groups: SubscriptionGroup[]): GroupedSubscription[] { + return Array.from({ length: 150 }, (_, index) => { + const generated = index - ORIGINAL_NAMES.length; + const name = + ORIGINAL_NAMES[index] ?? + SPECIAL_NAMES[generated] ?? + `${SUBJECTS[generated % SUBJECTS.length]} ${FORMATS[Math.floor(generated / SUBJECTS.length)]}`; + const memberships = new Set(); + if (index % 7 !== 0) { + memberships.add(index % 16); + if (index % 2 === 0) memberships.add(0); + if (index % 3 !== 0) memberships.add((index + 5) % 16); + if (index % 4 === 0) memberships.add((index + 9) % 16); + if (index % 11 === 0) memberships.add(16); + } + if (index === 26) { + memberships.clear(); + for (let group = 0; group < 10; group++) memberships.add(group); + } + return { + channelUrl: `https://www.youtube.com/channel/UCfixture${String(index).padStart(15, "0")}`, + name, + avatarUrl: index % 13 === 0 ? "" : `/api/__qa/avatar/${index}.svg`, + subscribedAt: EPOCH - index * 86_400_000, + groupIds: [...memberships].map((group) => groups[group].id), + }; + }); +} + +export function makeVideos(channels: GroupedSubscription[]): VideoItem[] { + return Array.from({ length: 300 }, (_, index) => { + const channel = channels[index % channels.length]; + const id = `mock${String(index).padStart(7, "0")}`; + return { + id, + url: `https://www.youtube.com/watch?v=${id}`, + title: `${channel.name}: ${index < channels.length ? "A closer look" : "Behind the scenes"}`, + thumbnailUrl: `/api/__qa/thumbnail/${index}.svg`, + uploaderName: channel.name, + uploaderUrl: channel.channelUrl, + uploaderAvatarUrl: channel.avatarUrl, + uploaderVerified: index % 8 === 0, + duration: 240 + ((index * 137) % 5400), + viewCount: 850 + index * 12437, + uploaded: EPOCH - index * 3_600_000, + uploadDate: "2026-09-01", + streamType: "VIDEO_STREAM", + isLive: false, + isPostLive: false, + isLiveContent: false, + requiresMembership: false, + isShortFormContent: false, + shortDescription: "Illustrative local fixture video; playback is not available.", + }; + }); +} + +export function fixtureImage(kind: string, index: number): Response { + const hue = (index * 47) % 360; + const thumbnail = kind === "thumbnail"; + const width = thumbnail ? 640 : 80; + const height = thumbnail ? 360 : 80; + const label = thumbnail ? `Preview ${String(index + 1).padStart(3, "0")}` : String(index + 1); + return new Response( + `${label}`, + { headers: { "Content-Type": "image/svg+xml" } }, + ); +} diff --git a/scripts/fixtures/subscription-groups-state.ts b/scripts/fixtures/subscription-groups-state.ts new file mode 100644 index 00000000..7f25c34c --- /dev/null +++ b/scripts/fixtures/subscription-groups-state.ts @@ -0,0 +1,112 @@ +import { makeChannels, makeGroups, makeVideos } from "./subscription-groups-data"; + +type FailureRule = { path: string; method: string; count: number; query?: string }; +type Fixture = { + groups: ReturnType; + channels: ReturnType; + videos: ReturnType; + writes: Array<{ path: string; method: string; body: unknown }>; + failure: FailureRule | null; +}; + +export function createFixture(): Fixture { + const groups = makeGroups(); + const channels = makeChannels(groups); + return { + groups, + channels, + videos: makeVideos(channels), + writes: [], + failure: null, + }; +} + +export function groupCounts(state: Fixture): Fixture["groups"] { + return state.groups.map((group) => ({ + ...group, + channelCount: state.channels.filter((channel) => channel.groupIds.includes(group.id)).length, + })); +} + +export function filteredChannels(state: Fixture, url: URL): Fixture["channels"] { + const groupId = url.searchParams.get("groupId"); + return state.channels.filter((channel) => + groupId + ? channel.groupIds.includes(groupId) + : url.searchParams.get("ungrouped") === "true" + ? channel.groupIds.length === 0 + : true, + ); +} + +function error(message: string, status: number, code?: string): Response { + return Response.json({ error: message, code }, { status }); +} + +export async function writeGroup( + state: Fixture, + request: Request, + path: string, +): Promise { + const id = path.split("/")[3]; + const group = state.groups.find((item) => item.id === id); + if (id && !group) return error("Group not found", 404, "subscription_group_not_found"); + const body: unknown = + request.method === "DELETE" && !path.endsWith("/channels") ? {} : await request.json(); + state.writes.push({ path, method: request.method, body }); + if (!body || typeof body !== "object") return error("Invalid request body", 400); + if (path.endsWith("/channels")) { + if (!("channelUrls" in body) || !Array.isArray(body.channelUrls)) + return error("Invalid channels", 400); + const urls: unknown[] = body.channelUrls; + if (!urls.length || urls.length > 500 || urls.some((url) => typeof url !== "string")) { + return error("Specify 1 to 500 channel URLs", 400); + } + if (request.method !== "PUT" && request.method !== "DELETE") + return error("Invalid method", 405); + if (urls.some((url) => !state.channels.some((channel) => channel.channelUrl === url))) { + return error("Subscription not found", 404, "subscription_not_found"); + } + state.channels = state.channels.map((channel) => + !urls.includes(channel.channelUrl) + ? channel + : { + ...channel, + groupIds: + request.method === "PUT" + ? [...new Set([...channel.groupIds, id])] + : channel.groupIds.filter((groupId) => groupId !== id), + }, + ); + } else if (request.method === "POST" || request.method === "PUT") { + const name = "name" in body && typeof body.name === "string" ? body.name.trim() : ""; + if (!name || name.length > 100) + return error("Invalid name", 400, "subscription_group_invalid_name"); + if ( + state.groups.some((item) => item.id !== id && item.name.toLowerCase() === name.toLowerCase()) + ) { + return error("Duplicate name", 409, "subscription_group_name_conflict"); + } + if (group) { + group.name = name; + group.updatedAt = Date.now(); + } else { + const created = { + id: crypto.randomUUID(), + name, + channelCount: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + state.groups.push(created); + return Response.json(created, { status: 201 }); + } + } else if (request.method === "DELETE" && group) { + state.groups = state.groups.filter((item) => item.id !== id); + state.channels = state.channels.map((channel) => ({ + ...channel, + groupIds: channel.groupIds.filter((groupId) => groupId !== id), + })); + } else return error("Invalid method", 405); + return new Response(null, { status: 204 }); +} From 06bf5cecbdefff84f57d64a4c148a54869bca650 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:46:07 +0900 Subject: [PATCH 16/32] test: serve a reproducible subscription group preview --- docs/subscription-groups-fixture.md | 43 +++++++ docs/subscription-groups-ux.md | 2 + package.json | 1 + scripts/fixtures/subscription-groups.ts | 161 ++++++++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 docs/subscription-groups-fixture.md create mode 100644 scripts/fixtures/subscription-groups.ts diff --git a/docs/subscription-groups-fixture.md b/docs/subscription-groups-fixture.md new file mode 100644 index 00000000..b6e4fcf0 --- /dev/null +++ b/docs/subscription-groups-fixture.md @@ -0,0 +1,43 @@ +# Local subscription-group testing fixture + +Run the real frontend against an in-memory API with **150 channels, 18 groups and 300 videos**. The seed includes overlapping memberships, ungrouped channels, an empty group, a channel in ten groups, long names, non-Latin names, and missing avatars. All images are generated locally. Video cards are illustrative; playback is unavailable. + +From the repository root, start the API: + +```sh +bun run dev:groups-fixture +``` + +In another terminal, start the frontend: + +```sh +VITE_DEV_PROXY_TARGET=http://127.0.0.1:9876 VITE_API_URL=/api bun run dev --host 127.0.0.1 --strictPort +``` + +Open [Manage groups](http://127.0.0.1:5173/subscriptions/groups), [Channels](http://127.0.0.1:5173/subscriptions/channels?group=all), or [Videos](http://127.0.0.1:5173/subscriptions?group=all). If prompted to sign in, any nonempty identifier and password work with this local fixture; use dummy values. The profile menu identifies the local fixture. + +The fixture binds only to `127.0.0.1:9876` and never forwards requests. CRUD and membership changes exist only in memory. Restarting the API restores the seed. To reset without restarting, then reload the browser: + +```sh +curl -X POST http://127.0.0.1:9876/__qa/reset +``` + +## Pagination failure and retry + +Open Videos and wait for the first page to load. Before scrolling to the bottom, inject four failures to exhaust the client's automatic retries on the next page: + +```sh +curl http://127.0.0.1:9876/__qa/fail -X POST \ + -H 'Content-Type: application/json' \ + --data '{"path":"/subscriptions/feed","query":"cursor=","method":"GET","count":4}' +``` + +Scroll to the bottom. Existing cards should remain visible, with an error and Retry button below them. Retry should append the next page without removing or duplicating earlier cards. The same endpoint accepts a group-membership path with `method: "PUT"` and `count: 1` to exercise partial-save recovery. + +## Suggested manager checks + +- Show more from 50 to 100 to 150 channels; Select results selects all matching channels, including unloaded rows. +- Select channels across group filters; compare In group / Not in group and Show selected. +- Search for `Atlas` to inspect a long channel name and ten memberships in the combobox. +- Test the Ungrouped and empty To explore filters, and create or rename a group. +- Inspect current memberships, group counts, mutation logs and dataset totals at `http://127.0.0.1:9876/__qa/state`. diff --git a/docs/subscription-groups-ux.md b/docs/subscription-groups-ux.md index 290fd216..491989e9 100644 --- a/docs/subscription-groups-ux.md +++ b/docs/subscription-groups-ux.md @@ -44,6 +44,8 @@ The [manager stylesheet](../apps/web/src/styles/subscription-groups.css) consoli ## Verification scope +- Feed error regression: 346 tests passed after adding coverage for initial-load, pagination, and background-refresh failures. Chromium retained all 30 loaded cards after an injected next-page failure; keyboard Retry appended 30 more distinct cards and cleared the error. `check`, `knip`, `sherif`, the production build and the whitespace check passed. +- A [reproducible local fixture](subscription-groups-fixture.md) now provides 150 channels, 18 groups and 300 videos, including 109 channels in multiple groups and 22 ungrouped channels. The 50-to-100-row manager expansion was checked in Chromium; fixture CRUD and filtering were checked in memory. - Final automated checks after defaulting the bulk target from the sidebar: 343 tests passed; `check`, `knip`, `sherif`, the production build and `git diff --check` passed. - Bulk-target browser checks confirmed Tech and Science sidebar selections populate the target, a manual Music override survives inversion and search, and All channels/Ungrouped reset the target. Existing channel selections were preserved throughout. - Row-selection checks covered background clicks, checkbox keyboard activation, highlighted rows in both themes, one/two/21 selections, hidden selections, retained drafts across filtering and multiple selection, Cancel, successful Save, and failed-save retry. Exactly one globally selected channel exposed an editor when visible. The local fixture's original memberships were restored after testing. diff --git a/package.json b/package.json index 4e314410..2b1f364f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "localize": "bun run --cwd apps/web localize", "localization:report": "node scripts/localization-report.mjs", "dev": "bun run --cwd apps/web dev", + "dev:groups-fixture": "bun scripts/fixtures/subscription-groups.ts", "build": "bun run --cwd apps/web build", "check": "bun run localize && node scripts/check-localization.mjs && biome check .", "format": "biome format --write .", diff --git a/scripts/fixtures/subscription-groups.ts b/scripts/fixtures/subscription-groups.ts new file mode 100644 index 00000000..10229224 --- /dev/null +++ b/scripts/fixtures/subscription-groups.ts @@ -0,0 +1,161 @@ +import { fixtureImage } from "./subscription-groups-data"; +import { + createFixture, + filteredChannels, + groupCounts, + writeGroup, +} from "./subscription-groups-state"; + +let state = createFixture(); +const me = { + id: "fixture-user", + role: "user", + publicUsername: "Local fixture · 150 channels", + bio: null, + avatarUrl: null, + avatarType: null, + avatarCode: null, +}; +const empty = (): Response => new Response(null, { status: 204 }); + +Bun.serve({ + hostname: "127.0.0.1", + port: 9876, + async fetch(request): Promise { + const url = new URL(request.url); + const path = url.pathname; + const method = request.method; + if (path === "/__qa/state") { + return Response.json({ + ...state, + groups: groupCounts(state), + summary: { + channels: state.channels.length, + groups: state.groups.length, + videos: state.videos.length, + ungrouped: state.channels.filter((channel) => channel.groupIds.length === 0).length, + multipleGroups: state.channels.filter((channel) => channel.groupIds.length > 1).length, + }, + }); + } + if (path === "/__qa/reset" && method === "POST") { + state = createFixture(); + return empty(); + } + if (path === "/__qa/fail" && method === "POST") { + const rule: unknown = await request.json(); + if ( + !rule || + typeof rule !== "object" || + !("path" in rule) || + typeof rule.path !== "string" || + !("method" in rule) || + typeof rule.method !== "string" || + !("count" in rule) || + !Number.isInteger(rule.count) || + Number(rule.count) < 0 + ) { + return Response.json( + { error: "Expected path, method and nonnegative count" }, + { status: 400 }, + ); + } + state.failure = { + path: rule.path, + method: rule.method, + count: Number(rule.count), + query: "query" in rule && typeof rule.query === "string" ? rule.query : undefined, + }; + return empty(); + } + const failure = state.failure; + if ( + failure && + failure.count > 0 && + method === failure.method && + path.includes(failure.path) && + (!failure.query || url.search.includes(failure.query)) + ) { + failure.count--; + return Response.json({ error: "Simulated fixture failure" }, { status: 503 }); + } + const image = path.match(/^\/__qa\/(avatar|thumbnail)\/(\d+)\.svg$/); + if (image) return fixtureImage(image[1], Number(image[2])); + if (path === "/instance") + return Response.json({ + guestAllowed: true, + youtubeRemoteLoginEnabled: false, + parentalControlsEnabled: false, + }); + if (path === "/auth/register/status") + return Response.json({ allowRegistration: false, bootstrapAvailable: false }); + if (path === "/auth/oidc/status") + return Response.json({ + enabled: false, + providerName: null, + localLoginEnabled: true, + autoRedirect: false, + }); + if (path === "/auth/login" || path === "/auth/refresh") + return Response.json({ accessToken: "local-fixture-only" }); + if (path === "/auth/me") return Response.json(me); + if (path === "/profiles") + return Response.json({ + profiles: [ + { ...me, name: me.publicUsername, isActive: true, isDefault: true, lastUsedAt: 0 }, + ], + activeProfileId: me.id, + defaultProfileId: me.id, + }); + if (path === "/subscriptions/group-memberships") return Response.json(state.channels); + if (path === "/subscriptions/groups" && method === "GET") + return Response.json(groupCounts(state)); + if (path.startsWith("/subscriptions/groups") && method !== "GET") + return writeGroup(state, request, path); + const groupId = url.searchParams.get("groupId"); + if ( + path.startsWith("/subscriptions") && + groupId && + !state.groups.some((group) => group.id === groupId) + ) { + return Response.json( + { error: "Group not found", code: "subscription_group_not_found" }, + { status: 404 }, + ); + } + if (path === "/subscriptions") return Response.json(filteredChannels(state, url)); + if (path === "/subscriptions/feed") { + const channels = new Set(filteredChannels(state, url).map((channel) => channel.channelUrl)); + const videos = state.videos.filter((video) => channels.has(video.uploaderUrl)); + const offset = Math.max(0, Number(url.searchParams.get("cursor")) || 0); + const limit = Math.max(1, Math.min(30, Number(url.searchParams.get("limit")) || 30)); + return Response.json({ + videos: videos.slice(offset, offset + limit), + nextpage: offset + limit < videos.length ? String(offset + limit) : null, + generation: 1, + generatedAt: Date.now(), + refreshing: false, + }); + } + if (path === "/settings") + return Response.json({ + captionStyles: {}, + sponsorBlockCategoryActions: {}, + defaultService: 0, + defaultLandingPage: "/", + volume: 1, + hideSubscriptionLiveStreams: false, + deArrowEnabled: false, + }); + if (path.startsWith("/streams")) + return Response.json({ error: "Fixture videos cannot be played" }, { status: 404 }); + if (path.includes("notifications")) + return Response.json({ items: [], unreadCount: 0, nextCursor: null }); + if (path === "/progress/batch") return Response.json([]); + if (method === "POST" || method === "PUT") return empty(); + return Response.json([]); + }, +}); +console.log( + "Local subscription fixture: http://127.0.0.1:9876 (150 channels, 18 groups, 300 videos)", +); From 0b4dd9a4c41e652120751a106ec068274a1b04df Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:33 +0900 Subject: [PATCH 17/32] feat: add viewport-aware subscription group pagination --- apps/web/messages/de.json | 9 ++- apps/web/messages/en.json | 9 ++- apps/web/messages/fr.json | 9 ++- .../subscription-groups/group-pagination.tsx | 56 +++++++++++++++++ apps/web/src/hooks/use-group-pagination.ts | 62 +++++++++++++++++++ apps/web/src/lib/group-pagination.ts | 28 +++++++++ apps/web/tests/group-pagination.test.ts | 59 ++++++++++++++++++ 7 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/subscription-groups/group-pagination.tsx create mode 100644 apps/web/src/hooks/use-group-pagination.ts create mode 100644 apps/web/src/lib/group-pagination.ts create mode 100644 apps/web/tests/group-pagination.test.ts diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index aa976200..58aaee5b 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1357,6 +1357,8 @@ "sg_in_group": "In Gruppe", "sg_invalid_name": "Der Gruppenname muss zwischen 1 und 100 Zeichen lang sein.", "sg_load_error": "Deine Gruppen konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", + "sg_refresh_error": "Die Gruppen konnten nicht aktualisiert werden. Die angezeigten Daten sind möglicherweise veraltet. Versuche es erneut, um die Bearbeitung fortzusetzen.", + "sg_refreshing": "Wird aktualisiert…", "sg_load_more": "Mehr anzeigen · {shown} von {total}", "sg_loading": "Gruppen und Kanäle werden geladen…", "sg_manage_groups": "Gruppen verwalten", @@ -1394,5 +1396,10 @@ "sg_delete_one_confirmation": "Die Gruppe und die Zugehörigkeit von 1 Kanal werden gelöscht. Der Kanal und dein Abonnement bleiben bestehen.", "sg_partial_one_failure": "Änderungen für 1 Kanal konnten nicht gespeichert werden. Einige Änderungen waren möglicherweise erfolgreich. Prüfe die aktualisierten Gruppen und versuche es erneut.", "subscriptions_feed_load_error": "Deine Abonnements konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", - "subscriptions_feed_next_page_error": "Weitere Videos konnten nicht geladen werden. Bereits geladene Videos bleiben verfügbar." + "subscriptions_feed_next_page_error": "Weitere Videos konnten nicht geladen werden. Bereits geladene Videos bleiben verfügbar.", + "sg_page_range": "{start}–{end} von {total}", + "sg_previous_page": "Vorherige Seite", + "sg_next_page": "Nächste Seite", + "sg_more_groups": "Weitere Gruppen ({count})", + "sg_no_group_results": "Keine Gruppen entsprechen deiner Suche." } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 8401dea2..e563b74a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1357,6 +1357,8 @@ "sg_in_group": "In group", "sg_invalid_name": "Use a group name between 1 and 100 characters.", "sg_load_error": "Your groups could not be loaded. Check your connection and try again.", + "sg_refresh_error": "Groups could not be refreshed. The data shown may be out of date. Editing is paused until you try again.", + "sg_refreshing": "Refreshing…", "sg_load_more": "Show more · {shown} of {total}", "sg_loading": "Loading your groups and channels…", "sg_manage_groups": "Manage groups", @@ -1394,5 +1396,10 @@ "sg_delete_one_confirmation": "This deletes the group and its membership for 1 channel. The channel and your subscription will be kept.", "sg_partial_one_failure": "Changes for 1 channel could not be saved. Some changes may have succeeded. Review the updated memberships and retry.", "subscriptions_feed_load_error": "Your subscriptions could not be loaded. Check your connection and try again.", - "subscriptions_feed_next_page_error": "More videos could not be loaded. Your loaded videos are still available." + "subscriptions_feed_next_page_error": "More videos could not be loaded. Your loaded videos are still available.", + "sg_page_range": "{start}–{end} of {total}", + "sg_previous_page": "Previous page", + "sg_next_page": "Next page", + "sg_more_groups": "Additional groups ({count})", + "sg_no_group_results": "No groups match your search." } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index c20f3d29..3cc6d939 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1357,6 +1357,8 @@ "sg_in_group": "Dans le groupe", "sg_invalid_name": "Le nom du groupe doit contenir entre 1 et 100 caractères.", "sg_load_error": "Impossible de charger vos groupes. Vérifiez votre connexion et réessayez.", + "sg_refresh_error": "Impossible d’actualiser les groupes. Les données affichées peuvent être obsolètes. Réessayez pour reprendre la modification.", + "sg_refreshing": "Actualisation…", "sg_load_more": "Afficher plus · {shown} sur {total}", "sg_loading": "Chargement des groupes et des chaînes…", "sg_manage_groups": "Gérer les groupes", @@ -1394,5 +1396,10 @@ "sg_delete_one_confirmation": "Le groupe et son appartenance pour 1 chaîne seront supprimés. La chaîne et votre abonnement seront conservés.", "sg_partial_one_failure": "Les modifications de 1 chaîne n’ont pas pu être enregistrées. Certaines ont pu réussir. Vérifiez les groupes actualisés et réessayez.", "subscriptions_feed_load_error": "Impossible de charger vos abonnements. Vérifiez votre connexion et réessayez.", - "subscriptions_feed_next_page_error": "Impossible de charger plus de vidéos. Les vidéos déjà chargées restent disponibles." + "subscriptions_feed_next_page_error": "Impossible de charger plus de vidéos. Les vidéos déjà chargées restent disponibles.", + "sg_page_range": "{start}–{end} sur {total}", + "sg_previous_page": "Page précédente", + "sg_next_page": "Page suivante", + "sg_more_groups": "Groupes supplémentaires ({count})", + "sg_no_group_results": "Aucun groupe ne correspond à votre recherche." } diff --git a/apps/web/src/components/subscription-groups/group-pagination.tsx b/apps/web/src/components/subscription-groups/group-pagination.tsx new file mode 100644 index 00000000..8127bd6e --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-pagination.tsx @@ -0,0 +1,56 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type { GroupPage } from "../../lib/group-pagination"; +import { m } from "../../paraglide/messages.js"; + +type Props = GroupPage & { + total: number; + label: string; + compact?: boolean; + disabled: boolean; + onPage: (page: number) => void; +}; + +export function GroupPagination(props: Props): React.JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/hooks/use-group-pagination.ts b/apps/web/src/hooks/use-group-pagination.ts new file mode 100644 index 00000000..cc8b4404 --- /dev/null +++ b/apps/web/src/hooks/use-group-pagination.ts @@ -0,0 +1,62 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { fitGroupPage, type GroupPage, groupPage } from "../lib/group-pagination"; + +type Options = { + total: number; + rowRem: number; + fallbackSize: number; + reservedRem?: number; + anchor?: number; +}; + +export function useGroupPagination({ + total, + rowRem, + fallbackSize, + reservedRem = 0, + anchor = -1, +}: Options): GroupPage & { + viewport: React.RefObject; + onPage: (page: number) => void; +} { + const viewport = useRef(null); + const [state, setState] = useState(() => groupPage(total, fallbackSize, 0)); + const current = groupPage(total, state.size, state.page); + useLayoutEffect(() => { + const element = viewport.current; + if (!element) return; + const desktop = window.matchMedia("(min-width: 1024px) and (min-height: 600px)"); + function measure(): void { + const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize); + setState((previous) => { + const next = fitGroupPage( + previous, + total, + desktop.matches ? (element?.clientHeight ?? 0) : fallbackSize * rowRem * rem, + rowRem * rem, + desktop.matches ? reservedRem * rem : 0, + anchor, + ); + return next.size === previous.size && + next.page === previous.page && + next.end === previous.end && + next.pages === previous.pages + ? previous + : next; + }); + } + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + desktop.addEventListener("change", measure); + return () => { + observer.disconnect(); + desktop.removeEventListener("change", measure); + }; + }, [total, rowRem, fallbackSize, reservedRem, anchor]); + return { + ...current, + viewport, + onPage: (page) => setState(groupPage(total, current.size, page)), + }; +} diff --git a/apps/web/src/lib/group-pagination.ts b/apps/web/src/lib/group-pagination.ts new file mode 100644 index 00000000..9f67ef80 --- /dev/null +++ b/apps/web/src/lib/group-pagination.ts @@ -0,0 +1,28 @@ +export type GroupPage = { page: number; size: number; pages: number; start: number; end: number }; + +export function groupPage(total: number, size: number, page: number): GroupPage { + const capacity = Math.max(1, Math.floor(size)); + const pages = Math.max(1, Math.ceil(total / capacity)); + const current = Math.max(0, Math.min(page, pages - 1)); + return { + page: current, + size: capacity, + pages, + start: current * capacity, + end: Math.min(total, (current + 1) * capacity), + }; +} + +export function fitGroupPage( + previous: GroupPage, + total: number, + height: number, + rowHeight: number, + reserved: number, + anchor: number, +): GroupPage { + const size = Math.max(1, Math.floor((height - reserved) / rowHeight)); + const visibleAnchor = anchor >= previous.start && anchor < previous.end; + const first = visibleAnchor ? anchor : previous.start; + return groupPage(total, size, size === previous.size ? previous.page : Math.floor(first / size)); +} diff --git a/apps/web/tests/group-pagination.test.ts b/apps/web/tests/group-pagination.test.ts new file mode 100644 index 00000000..bd21158a --- /dev/null +++ b/apps/web/tests/group-pagination.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { fitGroupPage, groupPage } from "../src/lib/group-pagination"; + +test("replacement pages cover 150 channels exactly once, including the partial last page", () => { + const channels = Array.from({ length: 150 }, (_, index) => index); + for (const size of [1, 6, 8, 11, 17]) { + const seen: number[] = []; + const first = groupPage(channels.length, size, 0); + for (let index = 0; index < first.pages; index++) { + const page = groupPage(channels.length, size, index); + seen.push(...channels.slice(page.start, page.end)); + } + expect(seen).toEqual(channels); + } +}); + +test("viewport capacity budgets the full expanded editor without losing the last selected row", () => { + const before = groupPage(150, 8, 1); + const editing = fitGroupPage(before, 150, 448, 56, 72, 15); + expect(editing.size).toBe(6); + expect(editing.start).toBeLessThanOrEqual(15); + expect(editing.end).toBeGreaterThan(15); + expect(editing.size * 56 + 72).toBeLessThanOrEqual(448); + const closed = fitGroupPage(editing, 150, 448, 56, 0, 15); + expect(closed.start).toBeLessThanOrEqual(15); + expect(closed.end).toBeGreaterThan(15); +}); + +test("resizing preserves the first visible item when selection is on another page", () => { + const before = groupPage(150, 8, 5); + const resized = fitGroupPage(before, 150, 340, 56, 72, 2); + expect(resized.start).toBeLessThanOrEqual(before.start); + expect(resized.end).toBeGreaterThan(before.start); + expect(resized.size).toBe(4); +}); + +test("navigating while one channel is selected does not force its page back into view", () => { + const next = groupPage(150, 6, 3); + expect(fitGroupPage(next, 150, 448, 56, 72, 15)).toEqual(next); +}); + +test("selecting a second channel keeps the clicked row visible when the editor closes", () => { + const editing = groupPage(150, 5, 1); + const multiple = fitGroupPage(editing, 150, 372, 56, 0, 6); + expect(multiple.start).toBeLessThanOrEqual(6); + expect(multiple.end).toBeGreaterThan(6); +}); + +test("deleting the last group on a page clamps to an available page", () => { + const before = groupPage(19, 6, 3); + const after = fitGroupPage(before, 18, 216, 36, 0, -1); + expect(after).toEqual({ page: 2, size: 6, pages: 3, start: 12, end: 18 }); + expect(groupPage(0, 6, 3)).toEqual({ page: 0, size: 6, pages: 1, start: 0, end: 0 }); +}); + +test("very short viewports and notices retain at least one usable row", () => { + expect(fitGroupPage(groupPage(18, 6, 0), 18, 30, 56, 72, -1).size).toBe(1); + expect(groupPage(18, 0, -1).size).toBe(1); +}); From c1706754c49d915158c9a3d0d15898e09037c542 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:36 +0900 Subject: [PATCH 18/32] style: compact group management with TypeType controls --- .../channel-group-editor.tsx | 2 +- .../group-channel-list.tsx | 87 +++++++++++-------- .../subscription-groups/group-channel-row.tsx | 44 +++++++--- .../subscription-groups/group-combobox.tsx | 2 +- .../subscription-groups/group-name-form.tsx | 8 +- .../group-sidebar-item.tsx | 6 +- .../subscription-groups/group-sidebar.tsx | 50 ++++++++--- .../subscription-groups/group-toolbar.tsx | 28 ++---- apps/web/src/routes/__root.tsx | 5 +- apps/web/src/styles/subscription-groups.css | 32 ++++++- 10 files changed, 168 insertions(+), 96 deletions(-) diff --git a/apps/web/src/components/subscription-groups/channel-group-editor.tsx b/apps/web/src/components/subscription-groups/channel-group-editor.tsx index cf495e3b..c38fc702 100644 --- a/apps/web/src/components/subscription-groups/channel-group-editor.tsx +++ b/apps/web/src/components/subscription-groups/channel-group-editor.tsx @@ -32,7 +32,7 @@ export function ChannelGroupEditor({ } return (
    { event.preventDefault(); diff --git a/apps/web/src/components/subscription-groups/group-channel-list.tsx b/apps/web/src/components/subscription-groups/group-channel-list.tsx index 10689a51..b6b64555 100644 --- a/apps/web/src/components/subscription-groups/group-channel-list.tsx +++ b/apps/web/src/components/subscription-groups/group-channel-list.tsx @@ -1,10 +1,13 @@ import { useState } from "react"; +import { useGroupPagination } from "../../hooks/use-group-pagination"; import { m } from "../../paraglide/messages.js"; import type { GroupedSubscription, SubscriptionGroup } from "../../types/subscription-groups"; import { GroupChannelRow } from "./group-channel-row"; +import { GroupPagination } from "./group-pagination"; type Props = { channels: GroupedSubscription[]; + label: string; groups: SubscriptionGroup[]; selected: ReadonlySet; editing: string | null; @@ -17,44 +20,52 @@ type Props = { }; export function GroupChannelList(props: Props): React.JSX.Element { - const [limit, setLimit] = useState(50); - if (props.channels.length === 0) - return ( -
    -

    {m.sg_no_channel_match()}

    -

    {m.sg_change_filters()}

    -
    - ); + const [anchor, setAnchor] = useState(null); + const pagination = useGroupPagination({ + total: props.channels.length, + rowRem: 3.5, + fallbackSize: 10, + reservedRem: props.editing ? 4.5 : 0, + anchor: props.channels.findIndex((channel) => channel.channelUrl === (anchor ?? props.editing)), + }); return ( - <> -
      - {props.channels.slice(0, limit).map((channel) => ( - props.onToggle(channel.channelUrl)} - onDraft={(ids) => props.onDraft(channel.channelUrl, ids)} - onCancel={props.onCancel} - onSave={(ids) => props.onSave(channel, ids)} - /> - ))} -
    - {props.channels.length > limit && ( - - )} - +
    +
    + {props.channels.length === 0 ? ( +
    +

    {m.sg_no_channel_match()}

    +

    {m.sg_change_filters()}

    +
    + ) : ( +
      + {props.channels.slice(pagination.start, pagination.end).map((channel) => ( + { + setAnchor(channel.channelUrl); + props.onToggle(channel.channelUrl); + }} + onDraft={(ids) => props.onDraft(channel.channelUrl, ids)} + onCancel={props.onCancel} + onSave={(ids) => props.onSave(channel, ids)} + /> + ))} +
    + )} +
    + +
    ); } diff --git a/apps/web/src/components/subscription-groups/group-channel-row.tsx b/apps/web/src/components/subscription-groups/group-channel-row.tsx index d03a3dbc..01423e63 100644 --- a/apps/web/src/components/subscription-groups/group-channel-row.tsx +++ b/apps/web/src/components/subscription-groups/group-channel-row.tsx @@ -32,15 +32,16 @@ export function GroupChannelRow(props: Props): React.JSX.Element { return (
  • -
    +
    -
    +
    {channel.name} @@ -80,13 +81,34 @@ export function GroupChannelRow(props: Props): React.JSX.Element { }} /> ) : ( -
    +
    group.name).join(", ")} + > {memberships.length > 0 ? ( - memberships.map((group) => ( - - {group.name} - - )) + <> + {memberships.slice(0, 2).map((group) => ( + + {group.name} + + ))} + {memberships.length > 2 && ( + + + + {m.sg_more_groups({ count: memberships.length - 2 })}:{" "} + {memberships + .slice(2) + .map((group) => group.name) + .join(", ")} + + + )} + ) : ( {m.sg_add_groups()} )} diff --git a/apps/web/src/components/subscription-groups/group-combobox.tsx b/apps/web/src/components/subscription-groups/group-combobox.tsx index 882d0a90..39344469 100644 --- a/apps/web/src/components/subscription-groups/group-combobox.tsx +++ b/apps/web/src/components/subscription-groups/group-combobox.tsx @@ -22,7 +22,7 @@ export function GroupCombobox({ groups, selected, disabled, onToggle }: Props): if (!event.currentTarget.contains(event.relatedTarget)) combo.setOpen(false); }} > -
    +
    {combo.chosen.map((group) => ( @@ -61,7 +61,7 @@ export function GroupNameForm({ onClick={onCancel} disabled={busy} aria-label={m.portability_cancel()} - className="sg-button w-9 shrink-0 px-0" + className="sg-button w-8 shrink-0 px-0" > diff --git a/apps/web/src/components/subscription-groups/group-sidebar-item.tsx b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx index 2bba5dd1..0acae5a4 100644 --- a/apps/web/src/components/subscription-groups/group-sidebar-item.tsx +++ b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx @@ -49,14 +49,14 @@ export function GroupSidebarItem({ ); return (
    ))} -
    - {props.groups.map((group) => ( + + { + setQuery(event.target.value); + pagination.onPage(0); + }} + className="h-8 w-full shrink-0 border border-border bg-app px-2 text-xs placeholder:text-fg-muted" + /> +
    + {matches.slice(pagination.start, pagination.end).map((group) => ( props.onDelete(group)} /> ))} - {props.groups.length === 0 && ( -

    {m.sg_no_groups()}

    + {matches.length === 0 && ( +

    + {props.groups.length === 0 ? m.sg_no_groups() : m.sg_no_group_results()} +

    )} - +
    + ); } diff --git a/apps/web/src/components/subscription-groups/group-toolbar.tsx b/apps/web/src/components/subscription-groups/group-toolbar.tsx index fe1fc90b..77eb7f0c 100644 --- a/apps/web/src/components/subscription-groups/group-toolbar.tsx +++ b/apps/web/src/components/subscription-groups/group-toolbar.tsx @@ -6,7 +6,6 @@ import type { SubscriptionGroup } from "../../types/subscription-groups"; type Props = { groups: SubscriptionGroup[]; defaultTarget: string; - filterName: string; isGroup: boolean; excluded: boolean; query: string; @@ -30,12 +29,12 @@ export function GroupToolbar(props: Props): React.JSX.Element { const [target, setTarget] = useState(props.defaultTarget); const validTarget = props.groups.some((group) => group.id === target) ? target : ""; return ( -
    +
    -
    -
    -
    +
    +

    {props.busy ? m.sg_saving() @@ -136,26 +135,13 @@ export function GroupToolbar(props: Props): React.JSX.Element { type="button" disabled={props.selectedCount === 0} onClick={props.onClear} + aria-label={m.sg_clear()} + title={m.sg_clear()} className="sg-button border-transparent" > - {m.sg_clear()}

    -
    - - {props.onlySelected - ? m.sg_show_selected() - : props.isGroup && props.excluded - ? m.sg_outside_named({ group: props.filterName }) - : props.filterName} - - - {props.resultCount === 1 - ? m.sg_one_channel() - : m.sg_channel_count({ count: props.resultCount })} - -
    ); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ae5f1342..c5bd75cf 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -202,7 +202,10 @@ function RootLayoutContent() {
    {watchCinemaPage ? !isMobile && : } -
    +
    diff --git a/apps/web/src/styles/subscription-groups.css b/apps/web/src/styles/subscription-groups.css index 6cd7e7ea..6ee1d3a1 100644 --- a/apps/web/src/styles/subscription-groups.css +++ b/apps/web/src/styles/subscription-groups.css @@ -2,7 +2,7 @@ @layer components { .sg-button { - @apply inline-flex min-h-9 items-center justify-center gap-1.5 border border-border-strong px-3 py-1.5 text-xs font-medium text-fg transition-colors hover:bg-surface-strong disabled:cursor-not-allowed disabled:opacity-40; + @apply inline-flex min-h-8 items-center justify-center gap-1.5 border border-border px-2 py-1 text-xs font-medium text-fg-muted transition-colors hover:border-border-strong hover:bg-surface-strong hover:text-fg disabled:cursor-not-allowed disabled:opacity-40; } .sg-membership-toggle { @@ -10,7 +10,7 @@ } .sg-membership-toggle:enabled { - @apply cursor-pointer border-fg-muted bg-surface-strong hover:border-fg hover:bg-surface-soft; + @apply cursor-pointer border-fg-muted bg-surface-strong text-fg hover:border-fg hover:bg-surface-soft; } .sg-membership-toggle:enabled[aria-pressed="true"] { @@ -18,11 +18,11 @@ } .sg-chip { - @apply border border-border-strong px-2 py-1 text-xs text-fg-muted; + @apply border border-border-strong px-1.5 py-0.5 text-xs text-fg-muted; } .sg-menu-item { - @apply px-3 py-2 text-left text-xs hover:bg-surface-strong disabled:opacity-40; + @apply px-3 py-2 text-left text-xs text-fg-muted hover:bg-surface-strong hover:text-fg disabled:opacity-40; } } @@ -53,3 +53,27 @@ scrollbar-color: var(--color-border-strong) var(--color-surface); scroll-margin-top: 4rem; } + +@media (min-width: 1024px) and (min-height: 600px) { + .sg-page { + display: flex; + height: 100dvh; + flex-direction: column; + padding-bottom: 0.5rem; + } + + .sg-page > .sg-manager { + min-height: 0; + flex: 1; + } + + .sg-page .sg-workspace { + grid-template-rows: minmax(0, 1fr); + } + + .sg-page > footer { + flex-shrink: 0; + margin-top: 0.5rem; + padding-block: 0.5rem; + } +} From 51a40c31918c647fcac86e3d09baf84eea9dec7d Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:40 +0900 Subject: [PATCH 19/32] fix: pause group editing after failed data refreshes --- .../group-manager-data.tsx | 55 +++++ .../group-manager-header.tsx | 18 ++ .../subscription-groups/group-manager.tsx | 208 ++++++++---------- apps/web/src/hooks/use-group-manager.ts | 5 +- apps/web/src/hooks/use-subscription-groups.ts | 4 +- 5 files changed, 173 insertions(+), 117 deletions(-) create mode 100644 apps/web/src/components/subscription-groups/group-manager-data.tsx create mode 100644 apps/web/src/components/subscription-groups/group-manager-header.tsx diff --git a/apps/web/src/components/subscription-groups/group-manager-data.tsx b/apps/web/src/components/subscription-groups/group-manager-data.tsx new file mode 100644 index 00000000..159e170f --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-manager-data.tsx @@ -0,0 +1,55 @@ +import type { UseQueryResult } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { m } from "../../paraglide/messages.js"; + +type ManagedQuery = Pick< + UseQueryResult, + "data" | "isPending" | "isError" | "isFetching" | "refetch" +>; +type Props = { groups: ManagedQuery; channels: ManagedQuery; children: ReactNode }; + +export function GroupManagerData({ groups, channels, children }: Props): React.JSX.Element { + const queries = [groups, channels]; + const loaded = queries.every((query) => query.data !== undefined); + const failed = queries.some((query) => query.isError); + const refreshing = queries.some((query) => query.isFetching); + if (queries.some((query) => query.isPending)) + return ( +

    + {m.sg_loading()} +

    + ); + return ( + <> + {failed && ( +
    +

    + {loaded ? m.sg_refresh_error() : m.sg_load_error()} +

    + +
    + )} + {loaded && ( +
    + {children} +
    + )} + + ); +} diff --git a/apps/web/src/components/subscription-groups/group-manager-header.tsx b/apps/web/src/components/subscription-groups/group-manager-header.tsx new file mode 100644 index 00000000..c694dbce --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-manager-header.tsx @@ -0,0 +1,18 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import { m } from "../../paraglide/messages.js"; + +export function GroupManagerHeader(): React.JSX.Element { + return ( +
    +
    +

    {m.sg_manage_groups()}

    +

    {m.sg_manager_description()}

    +
    + + + {m.sg_back_channels()} + +
    + ); +} diff --git a/apps/web/src/components/subscription-groups/group-manager.tsx b/apps/web/src/components/subscription-groups/group-manager.tsx index 4e223ae3..22740caa 100644 --- a/apps/web/src/components/subscription-groups/group-manager.tsx +++ b/apps/web/src/components/subscription-groups/group-manager.tsx @@ -1,7 +1,6 @@ -import { Link } from "@tanstack/react-router"; -import { ArrowLeft } from "lucide-react"; import { useMemo } from "react"; import { useGroupManager } from "../../hooks/use-group-manager"; +import { useInterfaceLocale } from "../../hooks/use-interface-locale"; import { useGroupMemberships, useSubscriptionGroups } from "../../hooks/use-subscription-groups"; import { createSubscriptionGroup, @@ -12,13 +11,17 @@ import { channelMembershipChanges } from "../../lib/subscription-group-selection import { m } from "../../paraglide/messages.js"; import { GroupChannelList } from "./group-channel-list"; import { GroupConfirmDialog } from "./group-confirm-dialog"; +import { GroupManagerData } from "./group-manager-data"; +import { GroupManagerHeader } from "./group-manager-header"; import { GroupSidebar } from "./group-sidebar"; import { GroupToolbar } from "./group-toolbar"; import "../../styles/subscription-groups.css"; export function GroupManager(): React.JSX.Element { + useInterfaceLocale(); const groupsQuery = useSubscriptionGroups(); const channelsQuery = useGroupMemberships(); + const canEdit = groupsQuery.isSuccess && channelsQuery.isSuccess; const groups = useMemo( () => [...(groupsQuery.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)), [groupsQuery.data], @@ -54,125 +57,104 @@ export function GroupManager(): React.JSX.Element { clearSelection, bulk, confirm, - } = useGroupManager(groups, channels); + } = useGroupManager(groups, channels, canEdit); return ( -
    -
    -
    -

    {m.sg_manage_groups()}

    -

    {m.sg_manager_description()}

    -
    - - - {m.sg_back_channels()} - -
    - {groupsQuery.isPending || channelsQuery.isPending ? ( -

    - {m.sg_loading()} -

    - ) : (groupsQuery.isError && !groupsQuery.data) || - (channelsQuery.isError && !channelsQuery.data) ? ( -
    -

    {m.sg_load_error()}

    - -
    - ) : ( -
    - + + + channel.groupIds.length === 0).length} + filter={activeFilter} + disabled={disabled} + onFilter={changeFilter} + onCreate={(name) => + actions.run(() => createSubscriptionGroup(name), m.sg_group_created({ group: name })) + } + onRename={(id, name) => + actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed()) + } + onDelete={setConfirmation} + onCancelRename={actions.clearError} + /> +
    + channel.groupIds.length === 0).length} - filter={activeFilter} - disabled={disabled} - onFilter={changeFilter} - onCreate={(name) => - actions.run(() => createSubscriptionGroup(name), m.sg_group_created({ group: name })) - } - onRename={(id, name) => - actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed()) + defaultTarget={activeGroup?.id ?? ""} + isGroup={Boolean(activeGroup)} + excluded={excluded} + query={query} + selectedCount={chosen.length} + hiddenCount={hiddenCount} + resultCount={visible.length} + allSelected={ + visible.length > 0 && + visible.every((channel) => validSelected.has(channel.channelUrl)) } - onDelete={setConfirmation} - onCancelRename={actions.clearError} + onlySelected={onlySelected} + disabled={disabled} + busy={actions.busy} + onQuery={setQuery} + onExcluded={setExcluded} + onSelectResults={selectResults} + onClear={clearSelection} + onOnlySelected={() => setOnlySelected(!onlySelected)} + onBulk={bulk} + onRemoveAll={() => setConfirmation("clear")} /> -
    - 0 && - visible.every((channel) => validSelected.has(channel.channelUrl)) + {actions.error && ( +

    + {actions.error} +

    + )} + {actions.notice && ( +

    + {actions.notice} +

    + )} + {channels.length === 0 ? ( +
    +

    {m.ui_no_subscriptions_yet_2()}

    +

    {m.sg_empty_subscriptions()}

    +
    + ) : ( + setOnlySelected(!onlySelected)} - onBulk={bulk} - onRemoveAll={() => setConfirmation("clear")} + onToggle={toggle} + onDraft={setDraft} + onCancel={clearSelection} + onSave={(channel, ids) => + actions.run( + () => updateGroupMemberships(channelMembershipChanges(channel, ids)), + m.sg_channel_saved({ channel: channel.name }), + ) + } /> - {actions.error && ( -

    - {actions.error} -

    - )} - {actions.notice && ( -

    - {actions.notice} -

    - )} - {channels.length === 0 ? ( -
    -

    {m.ui_no_subscriptions_yet_2()}

    -

    {m.sg_empty_subscriptions()}

    -
    - ) : ( - - actions.run( - () => updateGroupMemberships(channelMembershipChanges(channel, ids)), - m.sg_channel_saved({ channel: channel.name }), - ) - } - /> - )} -
    + )}
    - )} - {confirmationProps && ( +
    + {confirmationProps && canEdit && ( setConfirmation(null)} diff --git a/apps/web/src/hooks/use-group-manager.ts b/apps/web/src/hooks/use-group-manager.ts index 9461b0f6..ebfd8ac0 100644 --- a/apps/web/src/hooks/use-group-manager.ts +++ b/apps/web/src/hooks/use-group-manager.ts @@ -42,8 +42,9 @@ type State = { export function useGroupManager( groups: SubscriptionGroup[], channels: GroupedSubscription[], + canEdit: boolean, ): State { - const actions = useGroupActions(); + const actions = useGroupActions(canEdit); const [filter, setFilter] = useState("all"); const [excluded, setExcluded] = useState(false); const [query, setQuery] = useState(""); @@ -67,7 +68,7 @@ export function useGroupManager( activeGroup?.name ?? (activeFilter === "ungrouped" ? m.groups_preview_ungrouped() : m.sg_all_channels()); const editing = chosen.length === 1 ? chosen[0].channelUrl : null; - const disabled = actions.busy; + const disabled = actions.busy || !canEdit; function toggle(url: string): void { actions.clearError(); diff --git a/apps/web/src/hooks/use-subscription-groups.ts b/apps/web/src/hooks/use-subscription-groups.ts index 8bbf236e..d66b98c8 100644 --- a/apps/web/src/hooks/use-subscription-groups.ts +++ b/apps/web/src/hooks/use-subscription-groups.ts @@ -41,7 +41,7 @@ type GroupActions = { run: (action: () => Promise, success: string) => Promise; }; -export function useGroupActions(): GroupActions { +export function useGroupActions(enabled: boolean): GroupActions { const client = useQueryClient(); const lock = useRef(false); const [busy, setBusy] = useState(false); @@ -49,7 +49,7 @@ export function useGroupActions(): GroupActions { const [notice, setNotice] = useState(null); async function run(action: () => Promise, success: string): Promise { - if (lock.current) return false; + if (!enabled || lock.current) return false; lock.current = true; setBusy(true); setError(null); From e43274c77ed444b89c653396b6563def73b39fac Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:43 +0900 Subject: [PATCH 20/32] test: cover group refresh recovery and document verification --- apps/web/tests/group-refresh-errors.test.tsx | 163 +++++++++++++++++++ docs/subscription-groups-fixture.md | 3 +- docs/subscription-groups-ux.md | 28 +++- 3 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 apps/web/tests/group-refresh-errors.test.tsx diff --git a/apps/web/tests/group-refresh-errors.test.tsx b/apps/web/tests/group-refresh-errors.test.tsx new file mode 100644 index 00000000..47e8796c --- /dev/null +++ b/apps/web/tests/group-refresh-errors.test.tsx @@ -0,0 +1,163 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient, QueryClientProvider, QueryObserver } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { GroupManagerData } from "../src/components/subscription-groups/group-manager-data"; +import { useGroupActions } from "../src/hooks/use-subscription-groups"; +import { MembershipUpdateError } from "../src/lib/api-subscription-groups"; +import { m } from "../src/paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../src/types/subscription-groups"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +function readActions(client: QueryClient, enabled: boolean): ReturnType { + let actions: ReturnType | undefined; + function ReadActions(): null { + actions = useGroupActions(enabled); + return null; + } + renderToStaticMarkup( + + + , + ); + if (!actions) throw new Error("Actions did not render"); + return actions; +} + +function setup() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const failedReads = new Set(); + const state: { memberships: string[]; writes: number } = { memberships: [], writes: 0 }; + const group: SubscriptionGroup = { + id: "tech", + name: "Tech", + channelCount: 0, + createdAt: 0, + updatedAt: 0, + }; + const channel: GroupedSubscription = { + channelUrl: "https://www.youtube.com/channel/example", + name: "Example", + avatarUrl: "", + subscribedAt: 0, + groupIds: [], + }; + const groupKey = ["subscription-groups", "profile"]; + const channelKey = ["subscription-group-memberships", "profile"]; + client.setQueryData(groupKey, [group]); + client.setQueryData(channelKey, [channel]); + const groups = new QueryObserver(client, { + queryKey: groupKey, + staleTime: Infinity, + queryFn: async () => { + if (failedReads.has("groups")) throw new Error("Group refresh unavailable"); + return [{ ...group, channelCount: state.memberships.length ? 1 : 0 }]; + }, + }); + const channels = new QueryObserver(client, { + queryKey: channelKey, + staleTime: Infinity, + queryFn: async () => { + if (failedReads.has("memberships")) throw new Error("Membership refresh unavailable"); + return [{ ...channel, groupIds: [...state.memberships] }]; + }, + }); + const unsubscribers = [groups.subscribe(() => {}), channels.subscribe(() => {})]; + cleanups.push(() => { + for (const unsubscribe of unsubscribers) unsubscribe(); + client.clear(); + }); + return { + client, + groups, + channels, + state, + failedReads, + write: async () => { + state.writes++; + state.memberships = ["tech"]; + }, + retry: () => Promise.all([groups.refetch(), channels.refetch()]), + actions: () => + readActions( + client, + groups.getCurrentResult().isSuccess && channels.getCurrentResult().isSuccess, + ), + render: () => + renderToStaticMarkup( + + + , + ), + }; +} + +test("saved memberships with a failed refresh pause editing; recovery only repeats reads", async () => { + const fixture = setup(); + fixture.failedReads.add("memberships"); + expect(await fixture.actions().run(fixture.write, "Saved")).toBe(true); + expect(fixture.state.memberships).toEqual(["tech"]); + expect(fixture.channels.getCurrentResult().data?.[0].groupIds).toEqual([]); + expect(fixture.render()).toContain(m.sg_refresh_error()); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("groups"); + expect(await fixture.actions().run(fixture.write, "Saved")).toBe(true); + expect(fixture.channels.getCurrentResult().data?.[0].groupIds).toEqual(["tech"]); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("memberships"); + expect( + await fixture.actions().run(async () => { + await fixture.write(); + throw new MembershipUpdateError(["https://www.youtube.com/channel/example"]); + }, "Saved"), + ).toBe(false); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("memberships"); + await fixture.channels.refetch(); + expect(fixture.render()).toContain("Edit memberships"); + expect(fixture.render()).toContain(m.sg_refresh_error()); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("groups"); + await fixture.client.resetQueries({ queryKey: ["subscription-groups"] }); + expect(fixture.render()).toContain(m.sg_load_error()); + expect(fixture.render()).toContain(m.sg_retry()); + expect(fixture.render()).not.toContain("Edit memberships"); +}); diff --git a/docs/subscription-groups-fixture.md b/docs/subscription-groups-fixture.md index b6e4fcf0..c17ed43f 100644 --- a/docs/subscription-groups-fixture.md +++ b/docs/subscription-groups-fixture.md @@ -36,7 +36,8 @@ Scroll to the bottom. Existing cards should remain visible, with an error and Re ## Suggested manager checks -- Show more from 50 to 100 to 150 channels; Select results selects all matching channels, including unloaded rows. +- Page through both lists and search groups. On desktop, changing the viewport height changes page capacity while both pagination bars remain visible without a document or group-list scrollbar. +- Select results selects all 150 matching channels across pages. Select the final row on a page, edit it, then select another channel; the clicked row should stay visible and drafts should survive page changes. - Select channels across group filters; compare In group / Not in group and Show selected. - Search for `Atlas` to inspect a long channel name and ten memberships in the combobox. - Test the Ungrouped and empty To explore filters, and create or rename a group. diff --git a/docs/subscription-groups-ux.md b/docs/subscription-groups-ux.md index 491989e9..05c5468d 100644 --- a/docs/subscription-groups-ux.md +++ b/docs/subscription-groups-ux.md @@ -4,13 +4,15 @@ Desktop implementation of [TypeType #172](https://github.com/TypeType-Video/Type ## Direction contract +**MODE:** Operate. Refine the existing TypeType interface; preserve the user-approved controls and workflow. The clean-main style audit is recorded in [DESIGN.md](../DESIGN.md). + **THESIS:** One shared channel library, with groups as filters and selection independent of the current view. Users organize subscriptions without losing context. **OWN-WORLD:** Inherit TypeType's neutral surface, foreground, border and accent tokens, existing type, channel avatars and Lucide icons. Support its light and dark themes. **STORY:** Open Manage groups from Channels, find a channel or group, edit one row or select channels across filters, apply changes, and inspect the result. Grouping remains optional. -**FIRST VIEWPORT:** A compact title and return link above a two-column workspace. Group filters and inline creation on the left; search, membership scope and a sticky selection/action toolbar above channel rows on the right. Editing replaces the row's membership display with one compact combobox and adjacent Save/Cancel buttons. +**FIRST VIEWPORT:** A compact title and return link above a two-column workspace. Group filters, creation and search on the left; channel search, membership scope and bulk actions on the right. Both lists use replacement pagination, with page capacity derived from available height and their page controls always visible on desktop. Editing replaces the row's membership display with one compact combobox and adjacent Save/Cancel buttons. **FORM:** The user-approved desktop prototype; no concept seed needed for this specified extension. Clicking a row outside its channel-name link and editor controls toggles its checkbox and highlights the row. Exactly one selected channel shows the inline editor; multiple selections show membership chips and use the bulk toolbar. Selection count includes channels hidden by filters. The original two-arrow Invert button remains a one-click toggle beside Select results; its label shows the current view, In group or Not in group. It is disabled for All channels, Ungrouped and Show selected. Selection survives toggling, and counts disclose hidden selections. Removing all memberships and deleting a group require confirmation. @@ -23,29 +25,39 @@ Desktop implementation of [TypeType #172](https://github.com/TypeType-Video/Type - `/subscriptions/groups` is the dedicated manager. Videos and Channels expose ordinary group filters. - Read group definitions and the complete channel membership projection separately. Existing shared subscription payloads remain compatible. - Batch membership changes in chunks of at most 500 channels per group. Multi-group operations can partially succeed; refetch actual state and retain failed work for retry. +- Treat write completion and data refresh separately. If either group definitions or memberships fail to refresh, retain the displayed data with a warning and pause editing. Retry reloads both reads without replaying writes; editing resumes only after both succeed. Partial-write drafts remain available for retry against the refreshed state. - Preserve current filters after mutations. Selection survives search and group changes; selecting results adds to the selection. - Selecting a named sidebar group defaults the bulk Add/Remove target to that group. Users can override it; searching, changing row selection and toggling In group/Not in group preserve that override. Changing sidebar groups resets the target to the new group; All channels and Ungrouped start without a target. +- Page changes retain selection and drafts. Select results still selects every match across pages. Filters reset the channel page; group search resets the group page. Resizing clamps page ranges and preserves the last interacted row when its page capacity changes. - Import completion may offer an optional link into the same manager. ## Inherited visual system -The existing [subscription header](../apps/web/src/components/subscriptions-header.tsx) and [group preview](../apps/web/src/components/subscription-groups-preview.tsx) are the visual references. [Theme tokens](../apps/web/src/styles/theme.css), imported by [index.css](../apps/web/src/index.css), remain the source of truth. These notes apply to the subscription group manager. +An isolated upstream `main` snapshot at `0f3c37c794a1a296032068ee2e13b6238b0b5495` supplied the references, independently of this branch's manager. Its committed [group preview](../apps/web/src/components/subscription-groups-preview.tsx), [subscription header](../apps/web/src/components/subscriptions-header.tsx), and [settings shell](../apps/web/src/components/section-shell.tsx) establish the square management surfaces, neutral hierarchy and compact type. Rounded admin pagination is not the reference for this workflow. [Theme tokens](../apps/web/src/styles/theme.css) remain the source of truth; [DESIGN.md](../DESIGN.md) records the broader system and its context-specific exceptions. | Element | Inherited treatment in the finished manager | | --- | --- | | Colors | `app` and `surface` provide the neutral canvas; `surface-strong` marks selection and hover. `fg`, `fg-muted`, `border` and `border-strong` preserve the existing hierarchy. Light mode uses the existing zinc-token remapping. | -| Actions and feedback | Foreground-filled Add and Save buttons use app-colored text, matching the preview's primary actions. The enabled membership toggle has a filled surface, prominent border and pointer cursor in both states; Not in group uses foreground fill to distinguish the active state. Accent color marks checkboxes and keyboard focus; danger color marks errors and destructive actions. | +| Actions and feedback | Foreground-filled Add and Save buttons use app-colored text, matching the preview's primary actions. Secondary controls have muted text and thin neutral borders; hover strengthens them. The enabled membership toggle has foreground text, a filled surface, prominent border and pointer cursor in both states; Not in group uses foreground fill. Accent marks checkboxes and keyboard focus; danger marks errors and destructive actions. | | Typography | The existing font stack is inherited. The page title matches the subscription header (24 px, semibold, tight tracking). Channel names and standard fields use 14 px type; controls, chips, the combobox and supporting counts use 12 px type. | -| Surfaces and shapes | Rectangular bordered panels, controls and chips follow the preview. Border and surface tone separate content; the manager adds no shadows. The confirmation dialog uses a dimmed backdrop. | -| Channel identity | Existing `ChannelAvatar` and `ChannelRouteLink` components carry channel identity; Lucide supplies the small action icons. Avatars remain 36 px in channel rows. | -| Density and layout | The desktop workspace uses a 220 px group column, a flexible channel column and a 16 px gap within a 1440 px maximum width. The group sidebar and action toolbar stay visible while scrolling. Rows retain compact spacing and wrap membership chips. | +| Surfaces and shapes | Panels, buttons, fields, menus and membership chips have square corners, matching the clean-main group prototype. The selected sidebar group uses a foreground outline and stronger surface. Border and surface tone separate content; the manager adds no shadows. The confirmation dialog uses a dimmed backdrop. Circular channel avatars remain unchanged. | +| Channel identity | Existing `ChannelAvatar` and `ChannelRouteLink` components carry channel identity; Lucide supplies the small action icons. Compact rows use 32 px avatars. | +| Density and layout | The desktop workspace uses a 224 px group column, a flexible channel column and a 12 px gap within a 1440 px maximum width. Standard channel rows are 56 px tall; group rows are 36 px. Two membership chips and a +N summary keep rows even; the editor exposes every membership. | + +The [manager stylesheet](../apps/web/src/styles/subscription-groups.css) consolidates local button, chip and menu treatments. Action buttons have a 32 px minimum height; keyboard focus uses a 2 px accent outline with a 3 px offset. The combobox outlines the entire field with a 2 px offset and suppresses the inner input outline. Search fields and native selects share the bordered input treatment. -The [manager stylesheet](../apps/web/src/styles/subscription-groups.css) consolidates the local button, chip and menu treatments. Shared action buttons have a 36 px minimum height; keyboard focus uses a 2 px accent outline with a 3 px offset. The combobox outlines the entire field with a 2 px offset and suppresses the inner input outline. Search fields and native selects share the bordered input treatment. Compact mobile composition remains deferred. +At widths of at least 1024 px and heights of at least 600 px, the manager and a compact app footer share the available viewport. Neither list scrolls. Channel capacity reserves room for the capped inline editor, and notices reduce capacity instead of pushing pagination down the page. Group action menus open above their buttons. Smaller or zoomed viewports use normal document flow with six groups and ten channels per page; compact mobile composition remains deferred. ## Verification scope +- Refresh recovery: 358 tests passed, including new coverage for a successful write followed by a failed membership refresh, failed group-definition refreshes, partial writes, background refreshes, and initial loading. Chromium confirmed all workspace controls pause after refresh failure, a failed retry stays paused, successful retry restores the saved membership without replaying the write, and a subsequent edit uses the recovered membership state. Checked the warning in both themes and at 1280 × 720 / 1024 × 600 without document overflow after layout settles. Fixture memberships were restored. All required automated checks passed; Firefox/WebKit remain unverified. +- Clean-main style audit: inspected Channels, Settings and the committed group prototype independently of this feature. Removed manager corner rounding and aligned borders, secondary text, selected filters and primary actions with those references. Added the extracted [design guide](../DESIGN.md) and Impeccable component sidecar. +- Style verification: Chromium confirmed square controls and panels, visible keyboard focus, both inversion states, target defaults, inline drafts and Cancel in light/dark themes. Document dimensions stayed within 1280 × 720 and 1024 × 600 desktop viewports; group lists had no scroll container. The existing 390 × 844 document-flow fallback had no horizontal overflow in either theme. `check`, `test`, `knip`, `sherif`, the production build and `git diff --check` passed. Firefox and WebKit remain unverified; the existing build-size warning and two stale Knip ignore hints remain. +- Compact pagination: 353 tests passed, including page coverage, viewport capacity, last-row editing, selection transitions, resizing and page clamping. `check`, `knip`, `sherif`, the production build and `git diff --check` passed. The build retains the existing large-chunk warning. +- Chromium desktop checks used the 150-channel fixture at 1280 × 720, 1440 × 900 and 1024 × 600. Settled document dimensions matched the viewport; group lists had no scroll container. Verified group search/pages, empty results, inversion, target defaults, retained drafts across pages, multiselect, the ten-group editor, action-menu bounds, rename error/cancel focus and success feedback. Checked light/dark themes, German text, French changes without reload, and a 390 × 844 document-flow fallback with no horizontal overflow. Firefox and WebKit remain unverified. +- Impeccable's layout assessment informed the spacing and pagination changes. Its engine was unavailable locally, so source and rendered checks replaced its mechanical detector. - Feed error regression: 346 tests passed after adding coverage for initial-load, pagination, and background-refresh failures. Chromium retained all 30 loaded cards after an injected next-page failure; keyboard Retry appended 30 more distinct cards and cleared the error. `check`, `knip`, `sherif`, the production build and the whitespace check passed. -- A [reproducible local fixture](subscription-groups-fixture.md) now provides 150 channels, 18 groups and 300 videos, including 109 channels in multiple groups and 22 ungrouped channels. The 50-to-100-row manager expansion was checked in Chromium; fixture CRUD and filtering were checked in memory. +- A [reproducible local fixture](subscription-groups-fixture.md) provides 150 channels, 18 groups and 300 videos, including 109 channels in multiple groups and 22 ungrouped channels. Fixture CRUD and filtering were checked in memory. - Final automated checks after defaulting the bulk target from the sidebar: 343 tests passed; `check`, `knip`, `sherif`, the production build and `git diff --check` passed. - Bulk-target browser checks confirmed Tech and Science sidebar selections populate the target, a manual Music override survives inversion and search, and All channels/Ungrouped reset the target. Existing channel selections were preserved throughout. - Row-selection checks covered background clicks, checkbox keyboard activation, highlighted rows in both themes, one/two/21 selections, hidden selections, retained drafts across filtering and multiple selection, Cancel, successful Save, and failed-save retry. Exactly one globally selected channel exposed an editor when visible. The local fixture's original memberships were restored after testing. From 93ebe61c86b524f944e5e387a54c78ee9dfb6111 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:46 +0900 Subject: [PATCH 21/32] docs: record TypeType's upstream design conventions --- .impeccable/design.json | 136 ++++++++++++++++++++++++++++++++++++ DESIGN.md | 148 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 .impeccable/design.json create mode 100644 DESIGN.md diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 00000000..cf56187c --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,136 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-09-16T13:08:32.722Z", + "title": "Design System: TypeType", + "extensions": { + "colorMeta": { + "app": { + "role": "neutral", + "displayName": "Neutral zinc canvas", + "tonalRamp": [ + "oklch(14.1% 0.005 285.823)", + "oklch(21% 0.006 285.885)", + "oklch(27.4% 0.006 286.033)", + "oklch(37% 0.013 285.805)", + "oklch(55.2% 0.016 285.938)", + "oklch(87.1% 0.006 286.286)", + "oklch(96.7% 0.001 286.375)", + "oklch(98.5% 0 0)" + ] + }, + "accent": { + "role": "primary", + "displayName": "Blue state accent", + "tonalRamp": [ + "oklch(28.2% 0.091 267.935)", + "oklch(37.9% 0.146 265.522)", + "oklch(42.4% 0.199 265.638)", + "oklch(48.8% 0.243 264.376)", + "oklch(62.3% 0.214 259.815)", + "oklch(80.9% 0.105 251.813)", + "oklch(93.2% 0.032 255.585)", + "oklch(97% 0.014 254.604)" + ] + }, + "danger": { + "role": "semantic", + "displayName": "Red destructive feedback", + "tonalRamp": [ + "oklch(25.8% 0.092 26.042)", + "oklch(39.6% 0.141 25.723)", + "oklch(44.4% 0.177 26.899)", + "oklch(50.5% 0.213 27.518)", + "oklch(63.7% 0.237 25.331)", + "oklch(80.8% 0.114 19.571)", + "oklch(93.6% 0.032 17.717)", + "oklch(97.1% 0.013 17.38)" + ] + } + }, + "motion": [ + { + "name": "state-transition", + "value": "150ms cubic-bezier(0.4, 0, 0.2, 1)", + "purpose": "Tailwind transition-colors used on the incumbent group's buttons." + } + ], + "breakpoints": [ + { + "name": "lg", + "value": "1024px" + } + ] + }, + "components": [ + { + "name": "Primary action", + "kind": "button", + "refersTo": "button-primary", + "description": "Square foreground-filled action from the group prototype, with the manager's visible keyboard focus.", + "html": "", + "css": ".ds-primary { min-height:32px; padding:4px 8px; border:1px solid var(--color-zinc-800,oklch(27.4% .006 286.033)); border-radius:0; background:var(--color-zinc-100,oklch(96.7% .001 286.375)); color:var(--color-zinc-950,oklch(14.1% .005 285.823)); font:500 12px/16px system-ui,sans-serif; cursor:pointer; transition:background-color .15s; } .ds-primary:hover { background:var(--color-zinc-50,oklch(98.5% 0 0)); } .ds-primary:focus-visible { outline:2px solid var(--color-blue-400,oklch(70.7% .165 254.624)); outline-offset:3px; } .ds-primary:disabled { opacity:.4; cursor:not-allowed; }" + }, + { + "name": "Secondary action", + "kind": "button", + "refersTo": "button-secondary", + "description": "Muted bordered action; hover strengthens the neutral surface and label.", + "html": "", + "css": ".ds-secondary { min-height:32px; padding:4px 8px; border:1px solid var(--color-zinc-800,oklch(27.4% .006 286.033)); border-radius:0; background:transparent; color:var(--color-zinc-400,oklch(70.5% .015 286.067)); font:500 12px/16px system-ui,sans-serif; cursor:pointer; transition:color .15s,background-color .15s; } .ds-secondary:hover { border-color:var(--color-zinc-700,oklch(37% .013 285.805)); background:var(--color-zinc-800,oklch(27.4% .006 286.033)); color:var(--color-zinc-100,oklch(96.7% .001 286.375)); } .ds-secondary:focus-visible { outline:2px solid var(--color-blue-400,oklch(70.7% .165 254.624)); outline-offset:3px; } .ds-secondary:disabled { opacity:.4; cursor:not-allowed; }" + }, + { + "name": "Text field", + "kind": "input", + "refersTo": "input", + "description": "Square inset field with semantic theme colors and a visible focus outline.", + "html": "", + "css": ".ds-field { height:32px; max-width:100%; padding:0 8px; border:1px solid var(--color-zinc-800,oklch(27.4% .006 286.033)); border-radius:0; background:var(--color-zinc-950,oklch(14.1% .005 285.823)); color:var(--color-zinc-100,oklch(96.7% .001 286.375)); font:14px/20px system-ui,sans-serif; } .ds-field::placeholder { color:var(--color-zinc-400,oklch(70.5% .015 286.067)); } .ds-field:focus-visible { outline:2px solid var(--color-blue-400,oklch(70.7% .165 254.624)); outline-offset:3px; }" + }, + { + "name": "Group filter", + "kind": "nav", + "refersTo": "group-filter-selected", + "description": "A selected group gets an outline and tonal fill; inactive groups remain muted.", + "html": "", + "css": ".ds-groups { display:flex; flex-direction:column; gap:4px; } .ds-group { height:36px; padding:0 8px; border:1px solid transparent; border-radius:0; text-align:left; background:transparent; color:var(--color-zinc-400,oklch(70.5% .015 286.067)); font:14px/20px system-ui,sans-serif; cursor:pointer; } .ds-group:hover,.ds-group[aria-current=\"true\"] { background:var(--color-zinc-800,oklch(27.4% .006 286.033)); color:var(--color-zinc-100,oklch(96.7% .001 286.375)); } .ds-group[aria-current=\"true\"] { border-color:var(--color-zinc-100,oklch(96.7% .001 286.375)); } .ds-group:focus-visible { outline:2px solid var(--color-blue-400,oklch(70.7% .165 254.624)); outline-offset:3px; }" + }, + { + "name": "Membership label", + "kind": "chip", + "refersTo": "membership-chip", + "description": "A compact square label with a stronger border; this display variant is not interactive.", + "html": "Tech", + "css": ".ds-membership { display:inline-block; padding:2px 6px; border:1px solid var(--color-zinc-700,oklch(37% .013 285.805)); border-radius:0; color:var(--color-zinc-400,oklch(70.5% .015 286.067)); font:12px/16px system-ui,sans-serif; }" + }, + { + "name": "Management panel", + "kind": "card", + "refersTo": "management-panel", + "description": "Flat square surface from the clean-main group prototype; border and tone provide structure.", + "html": "

    Groups

    Organize your channels.

    ", + "css": ".ds-panel { padding:16px; border:1px solid var(--color-zinc-800,oklch(27.4% .006 286.033)); border-radius:0; background:var(--color-zinc-900,oklch(21% .006 285.885)); color:var(--color-zinc-100,oklch(96.7% .001 286.375)); box-shadow:none; font:14px/20px system-ui,sans-serif; } .ds-heading { margin:0 0 8px; font-size:14px; font-weight:600; } .ds-copy { margin:0; color:var(--color-zinc-400,oklch(70.5% .015 286.067)); }" + } + ], + "narrative": { + "northStar": "TypeType's existing library and settings.", + "overview": "Preserve the application's restrained neutral surfaces, compact system typography, thin borders and recognizable channel identities. Derive new management controls from the closest existing workflow.", + "keyCharacteristics": [ + "Neutral tonal hierarchy, with color reserved for state and feedback.", + "Compact system text, ordinary icons and circular channel avatars.", + "Flat, bordered management surfaces and square group controls.", + "Context-specific shapes elsewhere; the application has no universal corner radius." + ], + "rules": [], + "dos": [ + "Do use the semantic theme roles and check both light and dark themes.", + "Do choose references from the same workflow before borrowing isolated controls from admin or media pages.", + "Do keep management controls square, with thin borders and visible keyboard focus.", + "Do preserve existing channel identity, icons and navigation patterns." + ], + "donts": [ + "Don't introduce a new font, palette, shadow or blanket corner radius for group management.", + "Don't remove legitimate rounded media controls or circular avatars elsewhere.", + "Don't promote a prototype's presentation scaffolding or one page's density into a global design rule." + ] + } +} diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..8c7054aa --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,148 @@ +--- +name: TypeType +description: The existing neutral library and settings interface, extracted from clean upstream main. +colors: + app: "oklch(14.1% 0.005 285.823)" + surface: "oklch(21% 0.006 285.885)" + surface-strong: "oklch(27.4% 0.006 286.033)" + surface-soft: "oklch(37% 0.013 285.805)" + fg: "oklch(96.7% 0.001 286.375)" + fg-strong: "oklch(98.5% 0 0)" + fg-muted: "oklch(70.5% 0.015 286.067)" + border: "oklch(27.4% 0.006 286.033)" + border-strong: "oklch(37% 0.013 285.805)" + accent: "oklch(70.7% 0.165 254.624)" + danger: "oklch(70.4% 0.191 22.216)" +typography: + title: + fontSize: "24px" + fontWeight: 600 + lineHeight: "32px" + letterSpacing: "-0.025em" + body: + fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'" + fontSize: "14px" + lineHeight: "20px" + label: + fontSize: "12px" + fontWeight: 500 + lineHeight: "16px" +rounded: + square: "0px" + avatar: "50%" +spacing: + "2": "8px" + "3": "12px" + "4": "16px" + "5": "20px" +components: + button-primary: + backgroundColor: "{colors.fg}" + textColor: "{colors.app}" + typography: "{typography.label}" + rounded: "{rounded.square}" + button-primary-hover: + backgroundColor: "{colors.fg-strong}" + button-secondary: + textColor: "{colors.fg-muted}" + typography: "{typography.label}" + rounded: "{rounded.square}" + input: + backgroundColor: "{colors.app}" + textColor: "{colors.fg}" + typography: "{typography.body}" + rounded: "{rounded.square}" + group-filter-selected: + backgroundColor: "{colors.surface-strong}" + textColor: "{colors.fg}" + rounded: "{rounded.square}" + membership-chip: + textColor: "{colors.fg-muted}" + rounded: "{rounded.square}" + management-panel: + backgroundColor: "{colors.surface}" + rounded: "{rounded.square}" +--- + +# Design System: TypeType + +## Overview + +**Direction: TypeType's existing library and settings.** Preserve the application's restrained neutral surfaces, compact system typography, thin borders and recognizable channel identities. Derive new management controls from the closest existing workflow. + +Extracted on 2026-09-16 from a separate, unmodified source snapshot of upstream `main` at [`0f3c37c`](https://github.com/TypeType-Video/TypeType-Frontend/tree/0f3c37c794a1a296032068ee2e13b6238b0b5495). Channels, Settings and the committed group prototype were inspected in the browser; the prototype was checked in both themes. Upstream's default branch is `dev`; this audit deliberately used `main` as requested. + +**Key characteristics:** + +- Neutral tonal hierarchy, with color reserved for state and feedback. +- Compact system text, ordinary icons and circular channel avatars. +- Flat, bordered management surfaces and square group controls. +- Context-specific shapes elsewhere; the application has no universal corner radius. + +This records the incumbent system, not a new visual identity. [PRODUCT.md](PRODUCT.md) describes the product; the [manager brief](docs/subscription-groups-ux.md) owns that page's density and behavior. + +## Colors + +The frontmatter samples the default dark theme. [theme.css](apps/web/src/styles/theme.css) remains the runtime authority: semantic colors reference Tailwind zinc, blue and red primitives. Light mode remaps the zinc primitives. Use semantic classes so both themes inherit correctly; do not copy these sampled values into components. + +| Role | Established use | +| --- | --- | +| `app` | Page canvas and inset input fields. | +| `surface` | Group panels and ordinary list rows. | +| `surface-strong`, `surface-soft` | Selection, hover and stronger tonal separation. | +| `fg`, `fg-strong` | Main text and foreground-filled primary actions; `app` is their contrasting text. | +| `fg-muted` | Secondary actions, counts, membership labels and supporting text. | +| `border`, `border-strong` | Panel dividers and stronger chip/popover edges. | +| `accent` | Blue focus and selection indicators; existing settings navigation markers. | +| `danger` | Red destructive actions and error feedback. | + +In light mode, the canvas becomes near-white, surfaces pale gray and foreground dark charcoal. Preserve the same role hierarchy. The primary action in the group prototype uses foreground fill rather than accent fill. + +## Typography + +Use the existing system font stack throughout. The subscription title is semibold with tight tracking; settings titles use the same size with bold weight. Channel names and normal fields use body size; compact controls and counts use label size. The prototype's small membership labels are 11 px; 12 px is already used for its controls. + +Avoid adding a display font, oversized heading or decorative uppercase treatment to a routine management view. Prototype-only step labels and presentation copy do not establish requirements for production pages. + +## Layout + +The app shell uses a 56 px top bar, a 192 px desktop sidebar (56 px collapsed) and page padding of 12 px, increasing to 16 px on desktop. Content uses a small 4 px-based spacing rhythm. + +Settings use a 228 px navigation column beside a flexible body, divided by a thin rule. The clean group prototype uses a 248 px group column beside a flexible channel list, switching to columns at 1024 px. These are references for relationships, not a single mandatory width for every screen. + +Keep related inputs and actions close, leave room for long channel/group names, and use dividers to structure repeated rows. The manager's user-approved compact dimensions and viewport pagination belong in its brief. + +## Elevation & Depth + +Library and settings surfaces are flat at rest: borders and surface tones supply separation. The clean prototype has no resting panel or button shadows. Its group popover uses an elevated shadow; some unrelated modals and media controls also use shadows. Do not add decorative elevation to management panels. + +## Shapes + +The closest reference for group management is [subscription-groups-preview.tsx](apps/web/src/components/subscription-groups-preview.tsx), already present unchanged in the audited `main`. Its panels, buttons, fields, group choices and membership chips all have square corners. Subscription tabs and settings navigation are also square. + +The wider app is mixed: channel avatars are circular; channel hover targets have 16 px rounding; global search, some playlist controls and modals use rounded corners. Those treatments belong to their own components. They do not justify rounding an entire group-management workflow. + +## Components + +| Component | Treatment and states | +| --- | --- | +| Primary action | Foreground fill, app-colored text, square silhouette and compact medium-weight label. Hover strengthens the foreground fill. | +| Secondary action | Thin neutral border, muted text, square silhouette. Hover strengthens text and may add a neutral surface. Disabled controls are visibly subdued. | +| Search / text field | App-colored inset, thin neutral border, square corners and inherited text. Preserve a visible keyboard focus indicator. | +| Group filter | Muted inactive text. Selected group gets a foreground outline, foreground text and stronger surface. Keep the selected state distinct from hover. | +| Membership chip | Small square bordered label with muted text. An editable chip can expose a remove icon; ordinary membership labels are not buttons. | +| Management panel | Square border and neutral surface, without a resting shadow. Channel rows use separators and stronger fill for selection. | +| Subscription tab | Underline navigation, foreground active label, muted inactive labels. Reuse the existing header rather than introducing a new tab shape. | +| Channel identity | Reuse `ChannelAvatar` and `ChannelRouteLink`; retain circular avatars and existing link behavior. | + +[SectionShell](apps/web/src/components/section-shell.tsx), [SubscriptionsHeader](apps/web/src/components/subscriptions-header.tsx), and the clean group prototype establish the relevant relationships. Lucide remains the action-icon library. Color transitions are brief and functional; controls do not need decorative motion. + +## Do's and Don'ts + +- Do use the semantic theme roles and check both light and dark themes. +- Do choose references from the same workflow before borrowing isolated controls from admin or media pages. +- Do keep management controls square, with thin borders and visible keyboard focus. +- Do preserve existing channel identity, icons and navigation patterns. +- Don't introduce a new font, palette, shadow or blanket corner radius for group management. +- Don't remove legitimate rounded media controls or circular avatars elsewhere. +- Don't promote a prototype's presentation scaffolding or one page's density into a global design rule. From 23386da36d5dc1c81b559b1e423b36eaed4873e3 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:10:53 +0900 Subject: [PATCH 22/32] refactor: share subscription queries and cache refreshes --- .../components/portability-import-panel.tsx | 10 +-- apps/web/src/hooks/use-subscription-feed.ts | 13 +--- apps/web/src/hooks/use-subscription-groups.ts | 18 +++--- apps/web/src/hooks/use-subscriptions.ts | 33 +++------- apps/web/src/lib/api-subscription-groups.ts | 6 -- apps/web/src/lib/api-user.ts | 5 +- apps/web/src/lib/subscription-queries.ts | 56 +++++++++++++++++ apps/web/src/routes/subscriptions.tsx | 31 +++------ .../src/routes/subscriptions_.channels.tsx | 30 +++------ .../tests/subscription-feed-errors.test.tsx | 48 +++++++++----- apps/web/tests/subscription-queries.test.ts | 63 +++++++++++++++++++ 11 files changed, 192 insertions(+), 121 deletions(-) create mode 100644 apps/web/src/lib/subscription-queries.ts create mode 100644 apps/web/tests/subscription-queries.test.ts diff --git a/apps/web/src/components/portability-import-panel.tsx b/apps/web/src/components/portability-import-panel.tsx index 5cafaa7f..ae5b033f 100644 --- a/apps/web/src/components/portability-import-panel.tsx +++ b/apps/web/src/components/portability-import-panel.tsx @@ -12,6 +12,7 @@ import { type PortabilityJob, startPortabilityImport, } from "../lib/api-portability"; +import { invalidateSubscriptionQueries } from "../lib/subscription-queries"; import { m } from "../paraglide/messages.js"; import { PortabilityFormatPicker } from "./portability-format-picker"; import { PortabilityImportGuide } from "./portability-import-guide"; @@ -64,14 +65,7 @@ export function PortabilityImportPanel({ formats }: { formats: PortabilityFormat useEffect(() => { const state = job.data?.state ?? null; if (state === "completed" && previousState.current !== "completed") { - for (const key of [ - "subscription-groups", - "subscription-group-memberships", - "subscriptions", - "subscription-feed", - ]) { - void queryClient.invalidateQueries({ queryKey: [key] }); - } + void invalidateSubscriptionQueries(queryClient); const count = Object.values(job.data?.result ?? {}).reduce((sum, value) => sum + value, 0); setToast( `${m.portability_import_completed()}: ${count.toLocaleString()} ${m.portability_items()}`, diff --git a/apps/web/src/hooks/use-subscription-feed.ts b/apps/web/src/hooks/use-subscription-feed.ts index a93d5142..e9a1d4e3 100644 --- a/apps/web/src/hooks/use-subscription-feed.ts +++ b/apps/web/src/hooks/use-subscription-feed.ts @@ -1,15 +1,13 @@ import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; import { ApiError } from "../lib/api"; -import { fetchSubscriptionFeed } from "../lib/api-user"; import { mapVideoItem } from "../lib/mappers"; import { proxyImage } from "../lib/proxy"; +import { subscriptionFeedQueryOptions } from "../lib/subscription-queries"; import type { VideoStream } from "../types/stream"; import { useAuth } from "./use-auth"; import { useSubscriptions } from "./use-subscriptions"; -export const SUBSCRIPTION_FEED_KEY = ["subscription-feed"]; - type Result = { streams: VideoStream[]; isLoading: boolean; @@ -31,12 +29,7 @@ export function useSubscriptionFeed(filter = "all"): Result { ); const query = useInfiniteQuery({ - queryKey: filter === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, filter], - queryFn: ({ pageParam, signal }) => - fetchSubscriptionFeed(pageParam as string | null, signal, filter), - initialPageParam: null as string | null, - getNextPageParam: (last) => last.nextpage ?? undefined, - staleTime: 5 * 60 * 1000, + ...subscriptionFeedQueryOptions(filter), enabled: authReady && isAuthed, }); @@ -48,7 +41,7 @@ export function useSubscriptionFeed(filter = "all"): Result { ) ) { void queryClient.resetQueries({ - queryKey: filter === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, filter], + queryKey: subscriptionFeedQueryOptions(filter).queryKey, exact: true, }); } diff --git a/apps/web/src/hooks/use-subscription-groups.ts b/apps/web/src/hooks/use-subscription-groups.ts index d66b98c8..90b599f7 100644 --- a/apps/web/src/hooks/use-subscription-groups.ts +++ b/apps/web/src/hooks/use-subscription-groups.ts @@ -6,17 +6,19 @@ import { fetchSubscriptionGroups, MembershipUpdateError, } from "../lib/api-subscription-groups"; +import { + invalidateSubscriptionQueries, + SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, + SUBSCRIPTION_GROUPS_KEY, +} from "../lib/subscription-queries"; import { m } from "../paraglide/messages.js"; import type { GroupedSubscription, SubscriptionGroup } from "../types/subscription-groups"; import { useAuth } from "./use-auth"; -const GROUPS_KEY = ["subscription-groups"]; -const GROUP_MEMBERSHIPS_KEY = ["subscription-group-memberships"]; - export function useSubscriptionGroups(): UseQueryResult { const { authReady, isAuthed, me } = useAuth(); return useQuery({ - queryKey: [...GROUPS_KEY, me?.id], + queryKey: [...SUBSCRIPTION_GROUPS_KEY, me?.id], queryFn: fetchSubscriptionGroups, enabled: authReady && isAuthed, staleTime: 60_000, @@ -26,7 +28,7 @@ export function useSubscriptionGroups(): UseQueryResult { export function useGroupMemberships(): UseQueryResult { const { authReady, isAuthed, me } = useAuth(); return useQuery({ - queryKey: [...GROUP_MEMBERSHIPS_KEY, me?.id], + queryKey: [...SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, me?.id], queryFn: fetchGroupMemberships, enabled: authReady && isAuthed, staleTime: 60_000, @@ -72,11 +74,7 @@ export function useGroupActions(enabled: boolean): GroupActions { : m.sg_save_error(), ); } finally { - await Promise.all( - [GROUPS_KEY, GROUP_MEMBERSHIPS_KEY, ["subscriptions"], ["subscription-feed"]].map( - (queryKey) => client.invalidateQueries({ queryKey }), - ), - ); + await invalidateSubscriptionQueries(client); lock.current = false; setBusy(false); } diff --git a/apps/web/src/hooks/use-subscriptions.ts b/apps/web/src/hooks/use-subscriptions.ts index 840c3036..0f193014 100644 --- a/apps/web/src/hooks/use-subscriptions.ts +++ b/apps/web/src/hooks/use-subscriptions.ts @@ -1,12 +1,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { fetchFilteredSubscriptions } from "../lib/api-subscription-groups"; -import { fetchSubscriptions, subscribe, unsubscribe } from "../lib/api-user"; +import { subscribe, unsubscribe } from "../lib/api-user"; import { normalizeChannelUrl } from "../lib/channel-url"; +import { + invalidateSubscriptionQueries, + subscriptionsQueryOptions, +} from "../lib/subscription-queries"; import type { SubscriptionItem } from "../types/user"; import { useAuth } from "./use-auth"; -export const SUBSCRIPTIONS_KEY = ["subscriptions"]; - function hasSubscription(data: SubscriptionItem[] | undefined, channelUrl: string): boolean { const target = normalizeChannelUrl(channelUrl); return (data ?? []).some((item) => normalizeChannelUrl(item.channelUrl) === target); @@ -29,11 +30,9 @@ export function useSubscriptions(filter = "all") { const { authReady, isAuthed } = useAuth(); const query = useQuery({ - queryKey: filter === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, filter], - queryFn: () => (filter === "all" ? fetchSubscriptions() : fetchFilteredSubscriptions(filter)), + ...subscriptionsQueryOptions(filter), enabled: authReady && isAuthed, select: dedupeSubscriptions, - staleTime: 5 * 60 * 1000, }); const add = useMutation({ @@ -45,28 +44,12 @@ export function useSubscriptions(filter = "all") { channelUrl: normalizeChannelUrl(item.channelUrl), }); }, - onSuccess: () => - Promise.all( - [ - SUBSCRIPTIONS_KEY, - ["subscription-groups"], - ["subscription-group-memberships"], - ["subscription-feed"], - ].map((queryKey) => qc.invalidateQueries({ queryKey })), - ), + onSuccess: () => invalidateSubscriptionQueries(qc), }); const remove = useMutation({ mutationFn: (channelUrl: string) => (isAuthed ? unsubscribe(channelUrl) : Promise.resolve()), - onSuccess: () => - Promise.all( - [ - SUBSCRIPTIONS_KEY, - ["subscription-groups"], - ["subscription-group-memberships"], - ["subscription-feed"], - ].map((queryKey) => qc.invalidateQueries({ queryKey })), - ), + onSuccess: () => invalidateSubscriptionQueries(qc), }); function isSubscribed(channelUrl: string): boolean { diff --git a/apps/web/src/lib/api-subscription-groups.ts b/apps/web/src/lib/api-subscription-groups.ts index 0383a9dd..a7201228 100644 --- a/apps/web/src/lib/api-subscription-groups.ts +++ b/apps/web/src/lib/api-subscription-groups.ts @@ -3,11 +3,9 @@ import type { MembershipChange, SubscriptionGroup, } from "../types/subscription-groups"; -import type { SubscriptionItem } from "../types/user"; import { ApiError } from "./api"; import { authed, authedJson } from "./authed"; import { API_BASE } from "./env"; -import { subscriptionFilterParams } from "./subscription-group-selection"; const GROUPS_URL = `${API_BASE}/subscriptions/groups`; @@ -19,10 +17,6 @@ export function fetchGroupMemberships(): Promise { return authedJson(`${API_BASE}/subscriptions/group-memberships`); } -export function fetchFilteredSubscriptions(filter: string): Promise { - return authedJson(`${API_BASE}/subscriptions?${subscriptionFilterParams(filter)}`); -} - async function groupRequest(path: string, method: string, body?: unknown): Promise { const response = await authed(`${GROUPS_URL}${path}`, { method, diff --git a/apps/web/src/lib/api-user.ts b/apps/web/src/lib/api-user.ts index 908580a3..a5f76d23 100644 --- a/apps/web/src/lib/api-user.ts +++ b/apps/web/src/lib/api-user.ts @@ -60,8 +60,9 @@ export async function clearHistory(): Promise { if (!res.ok) throw new ApiError("Failed to clear history", res.status); } -export function fetchSubscriptions(): Promise { - return authedJson(`${BASE}/subscriptions`); +export function fetchSubscriptions(filter = "all"): Promise { + const search = subscriptionFilterParams(filter).toString(); + return authedJson(`${BASE}/subscriptions${search ? `?${search}` : ""}`); } export async function subscribe(item: Omit): Promise { diff --git a/apps/web/src/lib/subscription-queries.ts b/apps/web/src/lib/subscription-queries.ts new file mode 100644 index 00000000..357a18a8 --- /dev/null +++ b/apps/web/src/lib/subscription-queries.ts @@ -0,0 +1,56 @@ +import { + type InfiniteData, + infiniteQueryOptions, + type QueryClient, + queryOptions, +} from "@tanstack/react-query"; +import type { SubscriptionFeedPage } from "../types/api"; +import type { SubscriptionItem } from "../types/user"; +import { fetchSubscriptionFeed, fetchSubscriptions } from "./api-user"; + +const SUBSCRIPTIONS_KEY = ["subscriptions"]; +const SUBSCRIPTION_FEED_KEY = ["subscription-feed"]; +export const SUBSCRIPTION_GROUPS_KEY = ["subscription-groups"]; +export const SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY = ["subscription-group-memberships"]; +const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000; + +export function subscriptionsQueryOptions( + filter = "all", +): ReturnType> { + return queryOptions({ + queryKey: filter === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, filter], + queryFn: () => fetchSubscriptions(filter), + staleTime: SUBSCRIPTION_STALE_MS, + }); +} + +export function subscriptionFeedQueryOptions( + filter = "all", +): ReturnType< + typeof infiniteQueryOptions< + SubscriptionFeedPage, + Error, + InfiniteData, + string[], + string | null + > +> { + return infiniteQueryOptions({ + queryKey: filter === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, filter], + queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam, signal, filter), + initialPageParam: null as string | null, + getNextPageParam: (last) => last.nextpage ?? undefined, + staleTime: SUBSCRIPTION_STALE_MS, + }); +} + +export async function invalidateSubscriptionQueries(client: QueryClient): Promise { + await Promise.all( + [ + SUBSCRIPTIONS_KEY, + SUBSCRIPTION_FEED_KEY, + SUBSCRIPTION_GROUPS_KEY, + SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, + ].map((queryKey) => client.invalidateQueries({ queryKey })), + ); +} diff --git a/apps/web/src/routes/subscriptions.tsx b/apps/web/src/routes/subscriptions.tsx index 6f5b3a39..233f3756 100644 --- a/apps/web/src/routes/subscriptions.tsx +++ b/apps/web/src/routes/subscriptions.tsx @@ -8,19 +8,15 @@ import { VideoGrid } from "../components/video-grid"; import { VideoGridSkeleton } from "../components/video-grid-skeleton"; import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { streamQueryOptions } from "../hooks/use-stream"; -import { SUBSCRIPTION_FEED_KEY, useSubscriptionFeed } from "../hooks/use-subscription-feed"; -import { SUBSCRIPTIONS_KEY, useSubscriptions } from "../hooks/use-subscriptions"; +import { useSubscriptionFeed } from "../hooks/use-subscription-feed"; +import { useSubscriptions } from "../hooks/use-subscriptions"; import { ApiError } from "../lib/api"; -import { fetchFilteredSubscriptions } from "../lib/api-subscription-groups"; -import { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user"; +import { + subscriptionFeedQueryOptions, + subscriptionsQueryOptions, +} from "../lib/subscription-queries"; import { m } from "../paraglide/messages.js"; -const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000; - -function nextSubscriptionPage(last: Awaited>) { - return last.nextpage ?? undefined; -} - function SubscriptionsPage() { const queryClient = useQueryClient(); const prefetchedIdsRef = useRef(new Set()); @@ -42,22 +38,11 @@ function SubscriptionsPage() { const visible = useMemo(() => filter(streams), [filter, streams]); function prefetchChannels() { - void queryClient.prefetchQuery({ - queryKey: group === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, group], - queryFn: () => (group === "all" ? fetchSubscriptions() : fetchFilteredSubscriptions(group)), - staleTime: SUBSCRIPTION_STALE_MS, - }); + void queryClient.prefetchQuery(subscriptionsQueryOptions(group)); } function prefetchVideos() { - void queryClient.prefetchInfiniteQuery({ - queryKey: group === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, group], - queryFn: ({ pageParam, signal }) => - fetchSubscriptionFeed(pageParam as string | null, signal, group), - initialPageParam: null as string | null, - getNextPageParam: nextSubscriptionPage, - staleTime: SUBSCRIPTION_STALE_MS, - }); + void queryClient.prefetchInfiniteQuery(subscriptionFeedQueryOptions(group)); } useEffect(() => { diff --git a/apps/web/src/routes/subscriptions_.channels.tsx b/apps/web/src/routes/subscriptions_.channels.tsx index 938f7184..59e437b9 100644 --- a/apps/web/src/routes/subscriptions_.channels.tsx +++ b/apps/web/src/routes/subscriptions_.channels.tsx @@ -5,18 +5,13 @@ import { SubscriptionGroupFilter } from "../components/subscription-group-filter import { SubscriptionsHeader } from "../components/subscriptions-header"; import { VideoGridSkeleton } from "../components/video-grid-skeleton"; import { useBlockedFilter } from "../hooks/use-blocked-filter"; -import { SUBSCRIPTION_FEED_KEY } from "../hooks/use-subscription-feed"; -import { SUBSCRIPTIONS_KEY, useSubscriptions } from "../hooks/use-subscriptions"; -import { fetchFilteredSubscriptions } from "../lib/api-subscription-groups"; -import { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user"; +import { useSubscriptions } from "../hooks/use-subscriptions"; +import { + subscriptionFeedQueryOptions, + subscriptionsQueryOptions, +} from "../lib/subscription-queries"; import { m } from "../paraglide/messages.js"; -const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000; - -function nextSubscriptionPage(last: Awaited>) { - return last.nextpage ?? undefined; -} - function SubscriptionChannelsPage() { const queryClient = useQueryClient(); const { group = "all" } = Route.useSearch(); @@ -28,22 +23,11 @@ function SubscriptionChannelsPage() { ); function prefetchChannels() { - void queryClient.prefetchQuery({ - queryKey: group === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, group], - queryFn: () => (group === "all" ? fetchSubscriptions() : fetchFilteredSubscriptions(group)), - staleTime: SUBSCRIPTION_STALE_MS, - }); + void queryClient.prefetchQuery(subscriptionsQueryOptions(group)); } function prefetchVideos() { - void queryClient.prefetchInfiniteQuery({ - queryKey: group === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, group], - queryFn: ({ pageParam, signal }) => - fetchSubscriptionFeed(pageParam as string | null, signal, group), - initialPageParam: null as string | null, - getNextPageParam: nextSubscriptionPage, - staleTime: SUBSCRIPTION_STALE_MS, - }); + void queryClient.prefetchInfiniteQuery(subscriptionFeedQueryOptions(group)); } return ( diff --git a/apps/web/tests/subscription-feed-errors.test.tsx b/apps/web/tests/subscription-feed-errors.test.tsx index 10628220..bd4e289f 100644 --- a/apps/web/tests/subscription-feed-errors.test.tsx +++ b/apps/web/tests/subscription-feed-errors.test.tsx @@ -1,11 +1,13 @@ import { afterEach, expect, test } from "bun:test"; import { InfiniteQueryObserver, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; -import { SUBSCRIPTION_FEED_KEY, useSubscriptionFeed } from "../src/hooks/use-subscription-feed"; -import { SUBSCRIPTIONS_KEY } from "../src/hooks/use-subscriptions"; -import { fetchSubscriptionFeed } from "../src/lib/api-user"; +import { useSubscriptionFeed } from "../src/hooks/use-subscription-feed"; +import { + subscriptionFeedQueryOptions, + subscriptionsQueryOptions, +} from "../src/lib/subscription-queries"; import { useAuthStore } from "../src/stores/auth-store"; -import type { SubscriptionFeedPage, VideoItem } from "../src/types/api"; +import type { VideoItem } from "../src/types/api"; const originalFetch = globalThis.fetch; const clients: QueryClient[] = []; @@ -15,10 +17,10 @@ afterEach(() => { for (const client of clients.splice(0)) client.clear(); }); -function readFeed(client: QueryClient): ReturnType { +function readFeed(client: QueryClient, filter = "all"): ReturnType { let state: ReturnType | undefined; function ReadFeed(): null { - state = useSubscriptionFeed(); + state = useSubscriptionFeed(filter); return null; } renderToStaticMarkup( @@ -30,17 +32,12 @@ function readFeed(client: QueryClient): ReturnType { return state; } -function setup() { +function setup(filter = "all") { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); - client.setQueryData(SUBSCRIPTIONS_KEY, []); + client.setQueryData(subscriptionsQueryOptions().queryKey, []); useAuthStore.getState().setToken("feed-error-test"); - const observer = new InfiniteQueryObserver(client, { - queryKey: SUBSCRIPTION_FEED_KEY, - queryFn: ({ pageParam }) => fetchSubscriptionFeed(pageParam), - initialPageParam: null as string | null, - getNextPageParam: (last: SubscriptionFeedPage) => last.nextpage ?? undefined, - }); + const observer = new InfiniteQueryObserver(client, subscriptionFeedQueryOptions(filter)); return { client, observer }; } @@ -122,3 +119,26 @@ test("a failed background refresh retains cached feed content", async () => { expect(failed.isFetchNextPageError).toBe(false); expect(failed.streams.map((item) => item.title)).toEqual(["first"]); }); + +test("a prefetched filtered feed is reused and keeps its filter when fetching another page", async () => { + const filter = "tech & science/#"; + const { client } = setup(filter); + const calls: URL[] = []; + globalThis.fetch = async (input) => { + const url = new URL(String(input), "https://fixture.invalid"); + calls.push(url); + const cursor = url.searchParams.get("cursor"); + return Response.json({ + videos: [video(cursor ? "second" : "first")], + nextpage: cursor ? null : "page-2", + }); + }; + await client.prefetchInfiniteQuery(subscriptionFeedQueryOptions(filter)); + const feed = readFeed(client, filter); + expect(feed.streams.map((item) => item.title)).toEqual(["first"]); + expect(calls).toHaveLength(1); + await feed.fetchNextPage(); + expect(readFeed(client, filter).streams.map((item) => item.title)).toEqual(["first", "second"]); + expect(calls.map((url) => url.searchParams.get("cursor"))).toEqual([null, "page-2"]); + expect(calls.every((url) => url.searchParams.get("groupId") === filter)).toBe(true); +}); diff --git a/apps/web/tests/subscription-queries.test.ts b/apps/web/tests/subscription-queries.test.ts new file mode 100644 index 00000000..b1033b4b --- /dev/null +++ b/apps/web/tests/subscription-queries.test.ts @@ -0,0 +1,63 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; +import { fetchSubscriptions } from "../src/lib/api-user"; +import { + invalidateSubscriptionQueries, + subscriptionsQueryOptions, +} from "../src/lib/subscription-queries"; +import { useAuthStore } from "../src/stores/auth-store"; + +const originalFetch = globalThis.fetch; +const clients: QueryClient[] = []; +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); + for (const client of clients.splice(0)) client.clear(); +}); + +test("channel prefetches stay distinct by filter and are reused by subsequent reads", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + useAuthStore.getState().setToken("subscription-query-test"); + const calls: string[] = []; + globalThis.fetch = async (input) => { + const url = new URL(String(input), "https://fixture.invalid"); + calls.push(url.pathname + url.search); + const name = + url.searchParams.get("groupId") ?? (url.searchParams.has("ungrouped") ? "ungrouped" : "all"); + return Response.json([{ channelUrl: name, name, avatarUrl: "", subscribedAt: 0 }]); + }; + for (const filter of ["all", "ungrouped", "tech & science/#"]) { + const options = subscriptionsQueryOptions(filter); + await client.prefetchQuery(options); + expect((await client.fetchQuery(options))[0].name).toBe(filter); + } + expect(calls).toEqual([ + "/api/subscriptions", + "/api/subscriptions?ungrouped=true", + "/api/subscriptions?groupId=tech+%26+science%2F%23", + ]); + expect((await fetchSubscriptions())[0].name).toBe("all"); + expect(calls.at(-1)).toBe("/api/subscriptions"); +}); + +test("subscription changes invalidate every filter and profile variant without clearing data", async () => { + const client = new QueryClient(); + clients.push(client); + const affectedKeys = [ + ["subscriptions"], + ["subscriptions", "tech"], + ["subscriptions", "ungrouped"], + ["subscription-feed"], + ["subscription-feed", "tech"], + ["subscription-groups", "profile"], + ["subscription-group-memberships", "profile"], + ]; + for (const key of [...affectedKeys, ["playlists"]]) client.setQueryData(key, ["cached"]); + await invalidateSubscriptionQueries(client); + for (const key of affectedKeys) { + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + expect(client.getQueryData(key)).toEqual(["cached"]); + } + expect(client.getQueryState(["playlists"])?.isInvalidated).toBe(false); +}); From 8de58af42c61e186a1a2f7b06ca16fcd4c8548c6 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:15:11 +0900 Subject: [PATCH 23/32] fix: cancel subscription reads and preserve API errors --- apps/web/src/hooks/use-subscription-groups.ts | 4 +- apps/web/src/lib/api-subscription-groups.ts | 17 ++-- apps/web/src/lib/api-user.ts | 16 ++-- apps/web/src/lib/api.ts | 24 +++++- apps/web/src/lib/authed.ts | 7 +- apps/web/src/lib/subscription-queries.ts | 2 +- apps/web/tests/subscription-requests.test.ts | 85 +++++++++++++++++++ 7 files changed, 126 insertions(+), 29 deletions(-) create mode 100644 apps/web/tests/subscription-requests.test.ts diff --git a/apps/web/src/hooks/use-subscription-groups.ts b/apps/web/src/hooks/use-subscription-groups.ts index 90b599f7..8e7cf162 100644 --- a/apps/web/src/hooks/use-subscription-groups.ts +++ b/apps/web/src/hooks/use-subscription-groups.ts @@ -19,7 +19,7 @@ export function useSubscriptionGroups(): UseQueryResult { const { authReady, isAuthed, me } = useAuth(); return useQuery({ queryKey: [...SUBSCRIPTION_GROUPS_KEY, me?.id], - queryFn: fetchSubscriptionGroups, + queryFn: ({ signal }) => fetchSubscriptionGroups(signal), enabled: authReady && isAuthed, staleTime: 60_000, }); @@ -29,7 +29,7 @@ export function useGroupMemberships(): UseQueryResult { const { authReady, isAuthed, me } = useAuth(); return useQuery({ queryKey: [...SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, me?.id], - queryFn: fetchGroupMemberships, + queryFn: ({ signal }) => fetchGroupMemberships(signal), enabled: authReady && isAuthed, staleTime: 60_000, }); diff --git a/apps/web/src/lib/api-subscription-groups.ts b/apps/web/src/lib/api-subscription-groups.ts index a7201228..0953964f 100644 --- a/apps/web/src/lib/api-subscription-groups.ts +++ b/apps/web/src/lib/api-subscription-groups.ts @@ -3,18 +3,18 @@ import type { MembershipChange, SubscriptionGroup, } from "../types/subscription-groups"; -import { ApiError } from "./api"; +import { apiErrorFromResponse } from "./api"; import { authed, authedJson } from "./authed"; import { API_BASE } from "./env"; const GROUPS_URL = `${API_BASE}/subscriptions/groups`; -export function fetchSubscriptionGroups(): Promise { - return authedJson(GROUPS_URL); +export function fetchSubscriptionGroups(signal?: AbortSignal): Promise { + return authedJson(GROUPS_URL, { signal }); } -export function fetchGroupMemberships(): Promise { - return authedJson(`${API_BASE}/subscriptions/group-memberships`); +export function fetchGroupMemberships(signal?: AbortSignal): Promise { + return authedJson(`${API_BASE}/subscriptions/group-memberships`, { signal }); } async function groupRequest(path: string, method: string, body?: unknown): Promise { @@ -25,12 +25,7 @@ async function groupRequest(path: string, method: string, body?: unknown): Promi : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), }); if (!response.ok) { - const error: unknown = await response.json().catch(() => null); - const code = - error && typeof error === "object" && "code" in error && typeof error.code === "string" - ? error.code - : null; - throw new ApiError("Subscription group request failed", response.status, code); + throw apiErrorFromResponse(response, await response.json().catch(() => null)); } return response; } diff --git a/apps/web/src/lib/api-user.ts b/apps/web/src/lib/api-user.ts index a5f76d23..cf62c7c2 100644 --- a/apps/web/src/lib/api-user.ts +++ b/apps/web/src/lib/api-user.ts @@ -1,6 +1,6 @@ import type { SubscriptionFeedPage } from "../types/api"; import type { HistoryItem, SearchHistoryItem, SettingsItem, SubscriptionItem } from "../types/user"; -import { ApiError } from "./api"; +import { ApiError, apiErrorFromResponse } from "./api"; import { authed, authedJson } from "./authed"; import { channelUrlVariants, normalizeChannelUrl } from "./channel-url"; import { API_BASE as BASE } from "./env"; @@ -60,9 +60,12 @@ export async function clearHistory(): Promise { if (!res.ok) throw new ApiError("Failed to clear history", res.status); } -export function fetchSubscriptions(filter = "all"): Promise { +export function fetchSubscriptions( + filter = "all", + signal?: AbortSignal, +): Promise { const search = subscriptionFilterParams(filter).toString(); - return authedJson(`${BASE}/subscriptions${search ? `?${search}` : ""}`); + return authedJson(`${BASE}/subscriptions${search ? `?${search}` : ""}`, { signal }); } export async function subscribe(item: Omit): Promise { @@ -152,12 +155,7 @@ export async function fetchSubscriptionFeed( continue; } if (!res.ok) { - const error = body as { code?: string; error?: string }; - throw new ApiError( - error.error ?? "Subscription feed request failed", - res.status, - error.code ?? null, - ); + throw apiErrorFromResponse(res, body); } return body as SubscriptionFeedPage; } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4781374d..64b72564 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -13,11 +13,18 @@ import { normalizeApiPayload } from "./text-normalize"; export class ApiError extends Error { status: number; code: string | null; - constructor(message: string, status: number, code: string | null = null) { + requestId: string | null; + constructor( + message: string, + status: number, + code: string | null = null, + requestId: string | null = null, + ) { super(message); this.name = "ApiError"; this.status = status; this.code = code; + this.requestId = requestId; } } @@ -63,6 +70,19 @@ function toErrorCode(body: unknown): string | null { return typeof candidate.code === "string" && candidate.code.length > 0 ? candidate.code : null; } +export function apiErrorFromResponse(response: Response, body: unknown): ApiError { + const bodyRequestId = + body && typeof body === "object" && "requestId" in body && typeof body.requestId === "string" + ? body.requestId + : null; + return new ApiError( + toErrorMessage(response.status, response.statusText, body), + response.status, + toErrorCode(body), + extractRequestId(response.headers) ?? bodyRequestId, + ); +} + export async function request(url: string, init?: RequestInit): Promise { const method = init?.method ?? "GET"; let res: Response; @@ -102,7 +122,7 @@ export async function request(url: string, init?: RequestInit): Promise { requestId, message: sanitizeDebugText(errorMessage), }); - throw new ApiError(errorMessage, res.status, errorCode); + throw apiErrorFromResponse(res, body); } return body as T; } diff --git a/apps/web/src/lib/authed.ts b/apps/web/src/lib/authed.ts index b63146bf..f9d1993d 100644 --- a/apps/web/src/lib/authed.ts +++ b/apps/web/src/lib/authed.ts @@ -1,5 +1,5 @@ import { useAuthStore } from "../stores/auth-store"; -import { ApiError } from "./api"; +import { ApiError, apiErrorFromResponse } from "./api"; import { recordApiError } from "./api-error-log"; import { isRefreshSessionRejected, refreshSession } from "./auth-session"; import { extractRequestId, recordClientEvent } from "./client-debug-log"; @@ -125,7 +125,6 @@ export async function authed( export async function authedJson(url: string, init?: RequestInit): Promise { const res = await authed(url, init); - const body = normalizeApiPayload(await res.json()); - if (!res.ok) throw new ApiError((body as { error: string }).error, res.status); - return body as T; + if (!res.ok) throw apiErrorFromResponse(res, await res.json().catch(() => null)); + return normalizeApiPayload(await res.json()) as T; } diff --git a/apps/web/src/lib/subscription-queries.ts b/apps/web/src/lib/subscription-queries.ts index 357a18a8..deab18c9 100644 --- a/apps/web/src/lib/subscription-queries.ts +++ b/apps/web/src/lib/subscription-queries.ts @@ -19,7 +19,7 @@ export function subscriptionsQueryOptions( ): ReturnType> { return queryOptions({ queryKey: filter === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, filter], - queryFn: () => fetchSubscriptions(filter), + queryFn: ({ signal }) => fetchSubscriptions(filter, signal), staleTime: SUBSCRIPTION_STALE_MS, }); } diff --git a/apps/web/tests/subscription-requests.test.ts b/apps/web/tests/subscription-requests.test.ts new file mode 100644 index 00000000..74eaf430 --- /dev/null +++ b/apps/web/tests/subscription-requests.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { ApiError } from "../src/lib/api"; +import { fetchGroupMemberships, fetchSubscriptionGroups } from "../src/lib/api-subscription-groups"; +import { fetchSubscriptions } from "../src/lib/api-user"; +import { subscriptionsQueryOptions } from "../src/lib/subscription-queries"; +import { useAuthStore } from "../src/stores/auth-store"; + +const originalFetch = globalThis.fetch; +beforeEach(() => useAuthStore.getState().setToken("subscription-request-test")); +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); +}); + +test.each(["all", "ungrouped", "deleted-group"])( + "%s reads preserve structured errors and request IDs", + async (filter) => { + globalThis.fetch = async () => + Response.json( + { + error: "Subscription group not found", + code: "subscription_group_not_found", + requestId: "body-id", + }, + { status: 404, headers: { "x-request-id": "header-id" } }, + ); + try { + await fetchSubscriptions(filter); + throw new Error("Expected missing group"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + if (!(error instanceof ApiError)) throw error; + expect(error.code).toBe("subscription_group_not_found"); + expect(error.status).toBe(404); + expect(error.requestId).toBe("header-id"); + } + }, +); + +test("request IDs fall back to the body; non-JSON failures keep their status", async () => { + globalThis.fetch = async () => + Response.json({ error: "Unavailable", requestId: "body-id" }, { status: 503 }); + await expect(fetchSubscriptions()).rejects.toMatchObject({ status: 503, requestId: "body-id" }); + globalThis.fetch = async () => + new Response("Unavailable", { status: 502, statusText: "Bad Gateway" }); + await expect(fetchSubscriptions()).rejects.toMatchObject({ status: 502, message: "Bad Gateway" }); +}); + +test("every subscription list read forwards cancellation to fetch", async () => { + for (const read of [ + fetchSubscriptionGroups, + fetchGroupMemberships, + (signal: AbortSignal) => fetchSubscriptions("tech", signal), + ]) { + const controller = new AbortController(); + const reason = new DOMException("Navigation", "AbortError"); + globalThis.fetch = async (_input, init) => { + expect(init?.signal).toBe(controller.signal); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + }; + const pending = read(controller.signal); + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + } +}); + +test("unsubscribing from a query aborts its pending subscription request", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + let signal: AbortSignal | null | undefined; + globalThis.fetch = async (_input, init) => { + signal = init?.signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal?.reason), { once: true }); + }); + }; + const observer = new QueryObserver(client, subscriptionsQueryOptions("tech")); + const unsubscribe = observer.subscribe(() => {}); + expect(signal?.aborted).toBe(false); + unsubscribe(); + expect(signal?.aborted).toBe(true); + client.clear(); +}); From 991c863156af933e752fc9bfd26ec81f4089b633 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:18:40 +0900 Subject: [PATCH 24/32] fix: bound membership writes and target query refreshes --- .../subscription-groups/group-manager.tsx | 8 +- apps/web/src/hooks/use-group-manager.ts | 10 +-- apps/web/src/hooks/use-subscription-groups.ts | 14 ++- apps/web/src/lib/api-subscription-groups.ts | 32 ++++--- apps/web/src/lib/membership-batches.ts | 28 ++++++ apps/web/src/lib/subscription-queries.ts | 31 +++++-- apps/web/tests/membership-batches.test.ts | 85 +++++++++++++++++++ .../tests/subscription-refresh-scope.test.ts | 63 ++++++++++++++ 8 files changed, 241 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/lib/membership-batches.ts create mode 100644 apps/web/tests/membership-batches.test.ts create mode 100644 apps/web/tests/subscription-refresh-scope.test.ts diff --git a/apps/web/src/components/subscription-groups/group-manager.tsx b/apps/web/src/components/subscription-groups/group-manager.tsx index 22740caa..f40bf6d4 100644 --- a/apps/web/src/components/subscription-groups/group-manager.tsx +++ b/apps/web/src/components/subscription-groups/group-manager.tsx @@ -70,10 +70,14 @@ export function GroupManager(): React.JSX.Element { disabled={disabled} onFilter={changeFilter} onCreate={(name) => - actions.run(() => createSubscriptionGroup(name), m.sg_group_created({ group: name })) + actions.run( + () => createSubscriptionGroup(name), + m.sg_group_created({ group: name }), + "groups", + ) } onRename={(id, name) => - actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed()) + actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed(), "groups") } onDelete={setConfirmation} onCancelRename={actions.clearError} diff --git a/apps/web/src/hooks/use-group-manager.ts b/apps/web/src/hooks/use-group-manager.ts index ebfd8ac0..835497c7 100644 --- a/apps/web/src/hooks/use-group-manager.ts +++ b/apps/web/src/hooks/use-group-manager.ts @@ -89,6 +89,7 @@ export function useGroupManager( const channelUrls = chosen .filter((channel) => channel.groupIds.includes(groupId) === (action === "remove")) .map((channel) => channel.channelUrl); + if (channelUrls.length === 0) return; const name = groups.find((group) => group.id === groupId)?.name ?? ""; const message = action === "add" @@ -107,12 +108,9 @@ export function useGroupManager( const pending = confirmation; setConfirmation(null); if (pending === "clear") { - if ( - await actions.run( - () => updateGroupMemberships(clearMembershipChanges(chosen)), - m.sg_memberships_cleared(), - ) - ) + const changes = clearMembershipChanges(chosen); + if (changes.length === 0) return; + if (await actions.run(() => updateGroupMemberships(changes), m.sg_memberships_cleared())) clearSelection(); } else if (pending) { if ( diff --git a/apps/web/src/hooks/use-subscription-groups.ts b/apps/web/src/hooks/use-subscription-groups.ts index 8e7cf162..7667f26f 100644 --- a/apps/web/src/hooks/use-subscription-groups.ts +++ b/apps/web/src/hooks/use-subscription-groups.ts @@ -40,7 +40,11 @@ type GroupActions = { error: string | null; notice: string | null; clearError: () => void; - run: (action: () => Promise, success: string) => Promise; + run: ( + action: () => Promise, + success: string, + change?: "groups" | "memberships", + ) => Promise; }; export function useGroupActions(enabled: boolean): GroupActions { @@ -50,7 +54,11 @@ export function useGroupActions(enabled: boolean): GroupActions { const [error, setError] = useState(null); const [notice, setNotice] = useState(null); - async function run(action: () => Promise, success: string): Promise { + async function run( + action: () => Promise, + success: string, + change: "groups" | "memberships" = "memberships", + ): Promise { if (!enabled || lock.current) return false; lock.current = true; setBusy(true); @@ -74,7 +82,7 @@ export function useGroupActions(enabled: boolean): GroupActions { : m.sg_save_error(), ); } finally { - await invalidateSubscriptionQueries(client); + await invalidateSubscriptionQueries(client, change); lock.current = false; setBusy(false); } diff --git a/apps/web/src/lib/api-subscription-groups.ts b/apps/web/src/lib/api-subscription-groups.ts index 0953964f..2d9fce16 100644 --- a/apps/web/src/lib/api-subscription-groups.ts +++ b/apps/web/src/lib/api-subscription-groups.ts @@ -6,8 +6,10 @@ import type { import { apiErrorFromResponse } from "./api"; import { authed, authedJson } from "./authed"; import { API_BASE } from "./env"; +import { membershipBatches } from "./membership-batches"; const GROUPS_URL = `${API_BASE}/subscriptions/groups`; +const MAX_CONCURRENT_MEMBERSHIP_REQUESTS = 3; export function fetchSubscriptionGroups(signal?: AbortSignal): Promise { return authedJson(GROUPS_URL, { signal }); @@ -54,18 +56,24 @@ export class MembershipUpdateError extends Error { export async function updateGroupMemberships(changes: MembershipChange[]): Promise { const failed = new Set(); for (const change of changes) { - const urls = [...new Set(change.channelUrls)]; - for (let offset = 0; offset < urls.length; offset += 500) { - const channelUrls = urls.slice(offset, offset + 500); - try { - await groupRequest( - `/${encodeURIComponent(change.groupId)}/channels`, - change.action === "add" ? "PUT" : "DELETE", - { channelUrls }, - ); - } catch { - for (const url of channelUrls) failed.add(url); - } + const { batches, invalid } = membershipBatches(change.channelUrls); + for (const url of invalid) failed.add(url); + for (let offset = 0; offset < batches.length; offset += MAX_CONCURRENT_MEMBERSHIP_REQUESTS) { + await Promise.all( + batches + .slice(offset, offset + MAX_CONCURRENT_MEMBERSHIP_REQUESTS) + .map(async (channelUrls) => { + try { + await groupRequest( + `/${encodeURIComponent(change.groupId)}/channels`, + change.action === "add" ? "PUT" : "DELETE", + { channelUrls }, + ); + } catch { + for (const url of channelUrls) failed.add(url); + } + }), + ); } } if (failed.size > 0) throw new MembershipUpdateError([...failed]); diff --git a/apps/web/src/lib/membership-batches.ts b/apps/web/src/lib/membership-batches.ts new file mode 100644 index 00000000..8d49e2ae --- /dev/null +++ b/apps/web/src/lib/membership-batches.ts @@ -0,0 +1,28 @@ +const MAX_CHANNELS = 500; +const MAX_BODY_BYTES = 1024 * 1024; +const MAX_URL_LENGTH = 2048; +const ENCODER = new TextEncoder(); +const EMPTY_BODY_BYTES = ENCODER.encode(JSON.stringify({ channelUrls: [] })).byteLength; + +export function membershipBatches(urls: string[]): { batches: string[][]; invalid: string[] } { + const batches: string[][] = []; + const invalid: string[] = []; + let batch: string[] = []; + let bytes = EMPTY_BODY_BYTES; + for (const url of new Set(urls)) { + if (!url.trim() || url.length > MAX_URL_LENGTH) { + invalid.push(url); + continue; + } + const size = ENCODER.encode(JSON.stringify(url)).byteLength; + if (batch.length === MAX_CHANNELS || bytes + size + (batch.length ? 1 : 0) > MAX_BODY_BYTES) { + batches.push(batch); + batch = []; + bytes = EMPTY_BODY_BYTES; + } + bytes += size + (batch.length ? 1 : 0); + batch.push(url); + } + if (batch.length) batches.push(batch); + return { batches, invalid }; +} diff --git a/apps/web/src/lib/subscription-queries.ts b/apps/web/src/lib/subscription-queries.ts index deab18c9..584e26b9 100644 --- a/apps/web/src/lib/subscription-queries.ts +++ b/apps/web/src/lib/subscription-queries.ts @@ -44,13 +44,30 @@ export function subscriptionFeedQueryOptions( }); } -export async function invalidateSubscriptionQueries(client: QueryClient): Promise { +export async function invalidateSubscriptionQueries( + client: QueryClient, + change: "subscriptions" | "groups" | "memberships" = "subscriptions", +): Promise { + const keys = + change === "groups" + ? [SUBSCRIPTION_GROUPS_KEY] + : [ + SUBSCRIPTIONS_KEY, + SUBSCRIPTION_FEED_KEY, + SUBSCRIPTION_GROUPS_KEY, + SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, + ]; await Promise.all( - [ - SUBSCRIPTIONS_KEY, - SUBSCRIPTION_FEED_KEY, - SUBSCRIPTION_GROUPS_KEY, - SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, - ].map((queryKey) => client.invalidateQueries({ queryKey })), + keys.map((queryKey) => { + const deferred = + change === "memberships" && + (queryKey === SUBSCRIPTIONS_KEY || queryKey === SUBSCRIPTION_FEED_KEY); + // Membership edits leave global views unchanged; filtered views refresh when opened. + return client.invalidateQueries({ + queryKey, + refetchType: deferred ? "none" : "active", + predicate: (query) => !deferred || query.queryKey.length > 1, + }); + }), ); } diff --git a/apps/web/tests/membership-batches.test.ts b/apps/web/tests/membership-batches.test.ts new file mode 100644 index 00000000..b8a88bec --- /dev/null +++ b/apps/web/tests/membership-batches.test.ts @@ -0,0 +1,85 @@ +import { afterEach, expect, test } from "bun:test"; +import { MembershipUpdateError, updateGroupMemberships } from "../src/lib/api-subscription-groups"; +import { membershipBatches } from "../src/lib/membership-batches"; +import { useAuthStore } from "../src/stores/auth-store"; + +const bodyBytes = (channelUrls: string[]): number => + new TextEncoder().encode(JSON.stringify({ channelUrls })).byteLength; +const MAX_BYTES = 1024 * 1024; +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); +}); + +test("a body exactly 1 MiB fits, and adding another URL starts a new batch", () => { + const urls = Array.from({ length: 499 }, (_, i) => `https://example.org/${i}/`.padEnd(2048, "a")); + let remaining = MAX_BYTES - bodyBytes(urls); + for (let i = 0; remaining > 0; i++) { + const added = Math.min(remaining, 4000); + const count = Math.ceil(added / 2); + urls[i] = + urls[i].slice(0, -count) + "界".repeat(Math.floor(added / 2)) + (added % 2 ? "é" : ""); + remaining -= added; + } + expect(bodyBytes(urls)).toBe(MAX_BYTES); + const { batches, invalid } = membershipBatches([...urls, "https://example.org/extra"]); + expect(invalid).toEqual([]); + expect(batches).toEqual([urls, ["https://example.org/extra"]]); +}); + +test("UTF-8 and JSON escapes count toward the byte limit without losing channels", () => { + const urls = Array.from( + { length: 600 }, + (_, i) => `https://example.org/${i}/${'界\\"'.repeat(670)}`, + ); + const { batches, invalid } = membershipBatches([...urls, urls[0]]); + expect(invalid).toEqual([]); + expect(batches.flat()).toEqual(urls); + expect(batches.every((batch) => batch.length <= 500 && bodyBytes(batch) <= MAX_BYTES)).toBe(true); + expect(batches.length).toBeGreaterThan(2); +}); + +test("overlong URLs are reported without preventing valid membership writes", async () => { + useAuthStore.getState().setToken("membership-test"); + const calls: string[][] = []; + globalThis.fetch = async (_input, init) => { + calls.push(JSON.parse(String(init?.body)).channelUrls); + return new Response(null, { status: 204 }); + }; + const valid = "https://example.org/".padEnd(2048, "a"); + const invalid = `${valid}a`; + try { + await updateGroupMemberships([ + { groupId: "tech", action: "add", channelUrls: [invalid, valid] }, + ]); + throw new Error("Expected invalid URL"); + } catch (error) { + expect(error).toBeInstanceOf(MembershipUpdateError); + if (error instanceof MembershipUpdateError) expect(error.failedUrls).toEqual([invalid]); + } + expect(calls).toEqual([[valid]]); +}); + +test("a large edit has at most three requests in flight and preserves operation order", async () => { + useAuthStore.getState().setToken("membership-test"); + let active = 0; + let peak = 0; + const methods: string[] = []; + globalThis.fetch = async (_input, init) => { + methods.push(init?.method ?? "GET"); + active++; + peak = Math.max(peak, active); + await Bun.sleep(1); + active--; + return new Response(null, { status: 204 }); + }; + const channelUrls = Array.from({ length: 5001 }, (_, i) => `https://example.org/${i}`); + await updateGroupMemberships([ + { groupId: "tech", action: "add", channelUrls }, + { groupId: "tech", action: "remove", channelUrls: [channelUrls[0]] }, + ]); + expect(peak).toBe(3); + expect(active).toBe(0); + expect(methods).toEqual([...Array(11).fill("PUT"), "DELETE"]); +}); diff --git a/apps/web/tests/subscription-refresh-scope.test.ts b/apps/web/tests/subscription-refresh-scope.test.ts new file mode 100644 index 00000000..de82a62d --- /dev/null +++ b/apps/web/tests/subscription-refresh-scope.test.ts @@ -0,0 +1,63 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { invalidateSubscriptionQueries } from "../src/lib/subscription-queries"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +function setup() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + const reads: string[] = []; + const keys = [ + ["subscription-groups", "profile"], + ["subscription-group-memberships", "profile"], + ["subscriptions"], + ["subscriptions", "tech"], + ["subscriptions", "ungrouped"], + ["subscription-feed"], + ["subscription-feed", "tech"], + ["subscription-feed", "ungrouped"], + ]; + for (const queryKey of keys) { + client.setQueryData(queryKey, ["cached"]); + const observer = new QueryObserver(client, { + queryKey, + queryFn: async () => { + reads.push(queryKey.join("/")); + return ["fresh"]; + }, + }); + cleanups.push(observer.subscribe(() => {})); + } + cleanups.push(() => client.clear()); + return { client, reads }; +} + +test("membership saves refresh manager reads and defer filtered views without touching global views", async () => { + const { client, reads } = setup(); + await invalidateSubscriptionQueries(client, "memberships"); + expect(reads.sort()).toEqual([ + "subscription-group-memberships/profile", + "subscription-groups/profile", + ]); + for (const family of ["subscriptions", "subscription-feed"]) { + expect(client.getQueryState([family])?.isInvalidated).toBe(false); + for (const filter of ["tech", "ungrouped"]) { + expect(client.getQueryState([family, filter])?.isInvalidated).toBe(true); + expect(client.getQueryData([family, filter])).toEqual(["cached"]); + } + } +}); + +test("creating or renaming a group refreshes only group definitions", async () => { + const { client, reads } = setup(); + await invalidateSubscriptionQueries(client, "groups"); + expect(reads).toEqual(["subscription-groups/profile"]); + expect(client.getQueryState(["subscription-group-memberships", "profile"])?.isInvalidated).toBe( + false, + ); +}); From 3656733cd0bba6a03ff05370e5da655b8d6baab0 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:35:40 +0900 Subject: [PATCH 25/32] fix: recover subscription filters and empty states --- apps/web/messages/de.json | 2 + apps/web/messages/en.json | 2 + apps/web/messages/fr.json | 2 + .../components/subscription-group-filter.tsx | 27 +++++- .../subscription-groups/group-sidebar.tsx | 2 +- apps/web/src/hooks/use-group-manager.ts | 3 +- apps/web/src/hooks/use-subscription-feed.ts | 17 ++-- apps/web/src/routes/subscriptions.tsx | 5 +- .../src/routes/subscriptions_.channels.tsx | 22 ++++- apps/web/tests/helpers/subscription-feed.tsx | 46 +++++++++ .../tests/subscription-feed-errors.test.tsx | 54 ++--------- .../tests/subscription-feed-filters.test.tsx | 95 +++++++++++++++++++ 12 files changed, 214 insertions(+), 63 deletions(-) create mode 100644 apps/web/tests/helpers/subscription-feed.tsx create mode 100644 apps/web/tests/subscription-feed-filters.test.tsx diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 58aaee5b..acaa9ddd 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1331,6 +1331,8 @@ "sg_added": "{count} Kanäle zu {group} hinzugefügt.", "sg_all_channels": "Alle Kanäle", "sg_all_subscriptions": "Alle Abonnements", + "sg_ungrouped": "Nicht gruppiert", + "sg_channels_load_error": "Deine Kanäle konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", "sg_back_channels": "Zurück zu den Kanälen", "sg_back_to_results": "Zurück zu den Ergebnissen", "sg_change_filters": "Wähle eine andere Gruppe oder ändere deine Suche.", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index e563b74a..e9e29534 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1331,6 +1331,8 @@ "sg_added": "Added {count} channels to {group}.", "sg_all_channels": "All channels", "sg_all_subscriptions": "All subscriptions", + "sg_ungrouped": "Ungrouped", + "sg_channels_load_error": "Your channels could not be loaded. Check your connection and try again.", "sg_back_channels": "Back to channels", "sg_back_to_results": "Back to results", "sg_change_filters": "Try another group or change your search.", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 3cc6d939..b6026a42 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1331,6 +1331,8 @@ "sg_added": "{count} chaînes ajoutées à {group}.", "sg_all_channels": "Toutes les chaînes", "sg_all_subscriptions": "Tous les abonnements", + "sg_ungrouped": "Non groupées", + "sg_channels_load_error": "Impossible de charger vos chaînes. Vérifiez votre connexion et réessayez.", "sg_back_channels": "Retour aux chaînes", "sg_back_to_results": "Retour aux résultats", "sg_change_filters": "Essayez un autre groupe ou modifiez votre recherche.", diff --git a/apps/web/src/components/subscription-group-filter.tsx b/apps/web/src/components/subscription-group-filter.tsx index 61fc70f3..c278b4f1 100644 --- a/apps/web/src/components/subscription-group-filter.tsx +++ b/apps/web/src/components/subscription-group-filter.tsx @@ -1,11 +1,32 @@ +import { useEffect } from "react"; import { useSubscriptionGroups } from "../hooks/use-subscription-groups"; +import { ApiError } from "../lib/api"; import { m } from "../paraglide/messages.js"; -type Props = { value: string; onChange: (value: string) => void }; +type Props = { + value: string; + error?: Error | null; + onChange: (value: string, replace?: boolean) => void; +}; -export function SubscriptionGroupFilter({ value, onChange }: Props): React.JSX.Element { +export function SubscriptionGroupFilter({ value, error, onChange }: Props): React.JSX.Element { const query = useSubscriptionGroups(); const groups = [...(query.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)); + const requestMissing = error instanceof ApiError && error.code === "subscription_group_not_found"; + const missing = + value !== "all" && + value !== "ungrouped" && + (requestMissing || + (query.isSuccess && + query.isFetchedAfterMount && + !query.isFetching && + !groups.some((group) => group.id === value))); + const { refetch } = query; + useEffect(() => { + if (!missing) return; + onChange("all", true); + if (requestMissing) void refetch(); + }, [missing, requestMissing, onChange, refetch]); return ( props.onQuery(event.target.value)} disabled={props.onlySelected} @@ -48,13 +49,11 @@ export function GroupToolbar(props: Props): React.JSX.Element { ", - "css": ".ds-primary { min-height:32px; padding:4px 8px; border:1px solid var(--color-zinc-800,oklch(27.4% .006 286.033)); border-radius:0; background:var(--color-zinc-100,oklch(96.7% .001 286.375)); color:var(--color-zinc-950,oklch(14.1% .005 285.823)); font:500 12px/16px system-ui,sans-serif; cursor:pointer; transition:background-color .15s; } .ds-primary:hover { background:var(--color-zinc-50,oklch(98.5% 0 0)); } .ds-primary:focus-visible { outline:2px solid var(--color-blue-400,oklch(70.7% .165 254.624)); outline-offset:3px; } .ds-primary:disabled { opacity:.4; cursor:not-allowed; }" - }, - { - "name": "Secondary action", - "kind": "button", - "refersTo": "button-secondary", - "description": "Muted bordered action; hover strengthens the neutral surface and label.", - "html": "", - "css": ".ds-secondary { min-height:32px; padding:4px 8px; border:1px solid var(--color-zinc-800,oklch(27.4% .006 286.033)); border-radius:0; background:transparent; color:var(--color-zinc-400,oklch(70.5% .015 286.067)); font:500 12px/16px system-ui,sans-serif; cursor:pointer; transition:color .15s,background-color .15s; } .ds-secondary:hover { border-color:var(--color-zinc-700,oklch(37% .013 285.805)); background:var(--color-zinc-800,oklch(27.4% .006 286.033)); color:var(--color-zinc-100,oklch(96.7% .001 286.375)); } .ds-secondary:focus-visible { outline:2px solid var(--color-blue-400,oklch(70.7% .165 254.624)); outline-offset:3px; } .ds-secondary:disabled { opacity:.4; cursor:not-allowed; }" - }, - { - "name": "Text field", - "kind": "input", - "refersTo": "input", - "description": "Square inset field with semantic theme colors and a visible focus outline.", - "html": "", - "css": ".ds-field { height:32px; max-width:100%; padding:0 8px; border:1px solid var(--color-zinc-800,oklch(27.4% .006 286.033)); border-radius:0; background:var(--color-zinc-950,oklch(14.1% .005 285.823)); color:var(--color-zinc-100,oklch(96.7% .001 286.375)); font:14px/20px system-ui,sans-serif; } .ds-field::placeholder { color:var(--color-zinc-400,oklch(70.5% .015 286.067)); } .ds-field:focus-visible { outline:2px solid var(--color-blue-400,oklch(70.7% .165 254.624)); outline-offset:3px; }" - }, - { - "name": "Group filter", - "kind": "nav", - "refersTo": "group-filter-selected", - "description": "A selected group gets an outline and tonal fill; inactive groups remain muted.", - "html": "", - "css": ".ds-groups { display:flex; flex-direction:column; gap:4px; } .ds-group { height:36px; padding:0 8px; border:1px solid transparent; border-radius:0; text-align:left; background:transparent; color:var(--color-zinc-400,oklch(70.5% .015 286.067)); font:14px/20px system-ui,sans-serif; cursor:pointer; } .ds-group:hover,.ds-group[aria-current=\"true\"] { background:var(--color-zinc-800,oklch(27.4% .006 286.033)); color:var(--color-zinc-100,oklch(96.7% .001 286.375)); } .ds-group[aria-current=\"true\"] { border-color:var(--color-zinc-100,oklch(96.7% .001 286.375)); } .ds-group:focus-visible { outline:2px solid var(--color-blue-400,oklch(70.7% .165 254.624)); outline-offset:3px; }" - }, - { - "name": "Membership label", - "kind": "chip", - "refersTo": "membership-chip", - "description": "A compact square label with a stronger border; this display variant is not interactive.", - "html": "Tech", - "css": ".ds-membership { display:inline-block; padding:2px 6px; border:1px solid var(--color-zinc-700,oklch(37% .013 285.805)); border-radius:0; color:var(--color-zinc-400,oklch(70.5% .015 286.067)); font:12px/16px system-ui,sans-serif; }" - }, - { - "name": "Management panel", - "kind": "card", - "refersTo": "management-panel", - "description": "Flat square surface from the clean-main group prototype; border and tone provide structure.", - "html": "

    Groups

    Organize your channels.

    ", - "css": ".ds-panel { padding:16px; border:1px solid var(--color-zinc-800,oklch(27.4% .006 286.033)); border-radius:0; background:var(--color-zinc-900,oklch(21% .006 285.885)); color:var(--color-zinc-100,oklch(96.7% .001 286.375)); box-shadow:none; font:14px/20px system-ui,sans-serif; } .ds-heading { margin:0 0 8px; font-size:14px; font-weight:600; } .ds-copy { margin:0; color:var(--color-zinc-400,oklch(70.5% .015 286.067)); }" - } - ], - "narrative": { - "northStar": "TypeType's existing library and settings.", - "overview": "Preserve the application's restrained neutral surfaces, compact system typography, thin borders and recognizable channel identities. Derive new management controls from the closest existing workflow.", - "keyCharacteristics": [ - "Neutral tonal hierarchy, with color reserved for state and feedback.", - "Compact system text, ordinary icons and circular channel avatars.", - "Flat, bordered management surfaces and square group controls.", - "Context-specific shapes elsewhere; the application has no universal corner radius." - ], - "rules": [], - "dos": [ - "Do use the semantic theme roles and check both light and dark themes.", - "Do choose references from the same workflow before borrowing isolated controls from admin or media pages.", - "Do keep management controls square, with thin borders and visible keyboard focus.", - "Do preserve existing channel identity, icons and navigation patterns." - ], - "donts": [ - "Don't introduce a new font, palette, shadow or blanket corner radius for group management.", - "Don't remove legitimate rounded media controls or circular avatars elsewhere.", - "Don't promote a prototype's presentation scaffolding or one page's density into a global design rule." - ] - } -} diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 8c7054aa..00000000 --- a/DESIGN.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: TypeType -description: The existing neutral library and settings interface, extracted from clean upstream main. -colors: - app: "oklch(14.1% 0.005 285.823)" - surface: "oklch(21% 0.006 285.885)" - surface-strong: "oklch(27.4% 0.006 286.033)" - surface-soft: "oklch(37% 0.013 285.805)" - fg: "oklch(96.7% 0.001 286.375)" - fg-strong: "oklch(98.5% 0 0)" - fg-muted: "oklch(70.5% 0.015 286.067)" - border: "oklch(27.4% 0.006 286.033)" - border-strong: "oklch(37% 0.013 285.805)" - accent: "oklch(70.7% 0.165 254.624)" - danger: "oklch(70.4% 0.191 22.216)" -typography: - title: - fontSize: "24px" - fontWeight: 600 - lineHeight: "32px" - letterSpacing: "-0.025em" - body: - fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'" - fontSize: "14px" - lineHeight: "20px" - label: - fontSize: "12px" - fontWeight: 500 - lineHeight: "16px" -rounded: - square: "0px" - avatar: "50%" -spacing: - "2": "8px" - "3": "12px" - "4": "16px" - "5": "20px" -components: - button-primary: - backgroundColor: "{colors.fg}" - textColor: "{colors.app}" - typography: "{typography.label}" - rounded: "{rounded.square}" - button-primary-hover: - backgroundColor: "{colors.fg-strong}" - button-secondary: - textColor: "{colors.fg-muted}" - typography: "{typography.label}" - rounded: "{rounded.square}" - input: - backgroundColor: "{colors.app}" - textColor: "{colors.fg}" - typography: "{typography.body}" - rounded: "{rounded.square}" - group-filter-selected: - backgroundColor: "{colors.surface-strong}" - textColor: "{colors.fg}" - rounded: "{rounded.square}" - membership-chip: - textColor: "{colors.fg-muted}" - rounded: "{rounded.square}" - management-panel: - backgroundColor: "{colors.surface}" - rounded: "{rounded.square}" ---- - -# Design System: TypeType - -## Overview - -**Direction: TypeType's existing library and settings.** Preserve the application's restrained neutral surfaces, compact system typography, thin borders and recognizable channel identities. Derive new management controls from the closest existing workflow. - -Extracted on 2026-09-16 from a separate, unmodified source snapshot of upstream `main` at [`0f3c37c`](https://github.com/TypeType-Video/TypeType-Frontend/tree/0f3c37c794a1a296032068ee2e13b6238b0b5495). Channels, Settings and the committed group prototype were inspected in the browser; the prototype was checked in both themes. Upstream's default branch is `dev`; this audit deliberately used `main` as requested. - -**Key characteristics:** - -- Neutral tonal hierarchy, with color reserved for state and feedback. -- Compact system text, ordinary icons and circular channel avatars. -- Flat, bordered management surfaces and square group controls. -- Context-specific shapes elsewhere; the application has no universal corner radius. - -This records the incumbent system, not a new visual identity. [PRODUCT.md](PRODUCT.md) describes the product; the [manager brief](docs/subscription-groups-ux.md) owns that page's density and behavior. - -## Colors - -The frontmatter samples the default dark theme. [theme.css](apps/web/src/styles/theme.css) remains the runtime authority: semantic colors reference Tailwind zinc, blue and red primitives. Light mode remaps the zinc primitives. Use semantic classes so both themes inherit correctly; do not copy these sampled values into components. - -| Role | Established use | -| --- | --- | -| `app` | Page canvas and inset input fields. | -| `surface` | Group panels and ordinary list rows. | -| `surface-strong`, `surface-soft` | Selection, hover and stronger tonal separation. | -| `fg`, `fg-strong` | Main text and foreground-filled primary actions; `app` is their contrasting text. | -| `fg-muted` | Secondary actions, counts, membership labels and supporting text. | -| `border`, `border-strong` | Panel dividers and stronger chip/popover edges. | -| `accent` | Blue focus and selection indicators; existing settings navigation markers. | -| `danger` | Red destructive actions and error feedback. | - -In light mode, the canvas becomes near-white, surfaces pale gray and foreground dark charcoal. Preserve the same role hierarchy. The primary action in the group prototype uses foreground fill rather than accent fill. - -## Typography - -Use the existing system font stack throughout. The subscription title is semibold with tight tracking; settings titles use the same size with bold weight. Channel names and normal fields use body size; compact controls and counts use label size. The prototype's small membership labels are 11 px; 12 px is already used for its controls. - -Avoid adding a display font, oversized heading or decorative uppercase treatment to a routine management view. Prototype-only step labels and presentation copy do not establish requirements for production pages. - -## Layout - -The app shell uses a 56 px top bar, a 192 px desktop sidebar (56 px collapsed) and page padding of 12 px, increasing to 16 px on desktop. Content uses a small 4 px-based spacing rhythm. - -Settings use a 228 px navigation column beside a flexible body, divided by a thin rule. The clean group prototype uses a 248 px group column beside a flexible channel list, switching to columns at 1024 px. These are references for relationships, not a single mandatory width for every screen. - -Keep related inputs and actions close, leave room for long channel/group names, and use dividers to structure repeated rows. The manager's user-approved compact dimensions and viewport pagination belong in its brief. - -## Elevation & Depth - -Library and settings surfaces are flat at rest: borders and surface tones supply separation. The clean prototype has no resting panel or button shadows. Its group popover uses an elevated shadow; some unrelated modals and media controls also use shadows. Do not add decorative elevation to management panels. - -## Shapes - -The closest reference for group management is [subscription-groups-preview.tsx](apps/web/src/components/subscription-groups-preview.tsx), already present unchanged in the audited `main`. Its panels, buttons, fields, group choices and membership chips all have square corners. Subscription tabs and settings navigation are also square. - -The wider app is mixed: channel avatars are circular; channel hover targets have 16 px rounding; global search, some playlist controls and modals use rounded corners. Those treatments belong to their own components. They do not justify rounding an entire group-management workflow. - -## Components - -| Component | Treatment and states | -| --- | --- | -| Primary action | Foreground fill, app-colored text, square silhouette and compact medium-weight label. Hover strengthens the foreground fill. | -| Secondary action | Thin neutral border, muted text, square silhouette. Hover strengthens text and may add a neutral surface. Disabled controls are visibly subdued. | -| Search / text field | App-colored inset, thin neutral border, square corners and inherited text. Preserve a visible keyboard focus indicator. | -| Group filter | Muted inactive text. Selected group gets a foreground outline, foreground text and stronger surface. Keep the selected state distinct from hover. | -| Membership chip | Small square bordered label with muted text. An editable chip can expose a remove icon; ordinary membership labels are not buttons. | -| Management panel | Square border and neutral surface, without a resting shadow. Channel rows use separators and stronger fill for selection. | -| Subscription tab | Underline navigation, foreground active label, muted inactive labels. Reuse the existing header rather than introducing a new tab shape. | -| Channel identity | Reuse `ChannelAvatar` and `ChannelRouteLink`; retain circular avatars and existing link behavior. | - -[SectionShell](apps/web/src/components/section-shell.tsx), [SubscriptionsHeader](apps/web/src/components/subscriptions-header.tsx), and the clean group prototype establish the relevant relationships. Lucide remains the action-icon library. Color transitions are brief and functional; controls do not need decorative motion. - -## Do's and Don'ts - -- Do use the semantic theme roles and check both light and dark themes. -- Do choose references from the same workflow before borrowing isolated controls from admin or media pages. -- Do keep management controls square, with thin borders and visible keyboard focus. -- Do preserve existing channel identity, icons and navigation patterns. -- Don't introduce a new font, palette, shadow or blanket corner radius for group management. -- Don't remove legitimate rounded media controls or circular avatars elsewhere. -- Don't promote a prototype's presentation scaffolding or one page's density into a global design rule. diff --git a/PRODUCT.md b/PRODUCT.md deleted file mode 100644 index ff56bdbf..00000000 --- a/PRODUCT.md +++ /dev/null @@ -1,25 +0,0 @@ -# TypeType frontend - - - -## Platform - -web - -## Product purpose - -TypeType's browser client supports watching videos and managing channel subscriptions. Subscription groups organize the same shared subscription library and filter its channel list and video feed. - -## Capabilities and constraints - -- Users can put a channel in multiple groups or leave it ungrouped. -- Group membership is independent of subscription status. Deleting a group or removing memberships must preserve subscriptions. -- Subscription group UX follows the contributor's prototypes and the project maintainer's feedback in [TypeType #172](https://github.com/TypeType-Video/TypeType/issues/172#issuecomment-5433024163). -- The user approved a dedicated manager and desktop-first implementation. Compact mobile composition is deferred until the desktop version is finalized. -- This PR delivers desktop management using the existing unpaged API. Server pagination/search and large-library optimization are deferred; client pagination only controls the visible layout. -- Organizing only the channels added by an import is deferred. The post-import link opens the full library and does not implement that workflow. -- Browser behavior belongs in this repository. Persistence and membership contracts belong to TypeType-Server. - -## Evidence and scope - -The current feature brief and implementation direction are recorded in [Subscription group manager](docs/subscription-groups-ux.md). This record captures the confirmed subscription-group scope; broader audience research and product positioning remain unspecified. diff --git a/docs/subscription-groups-fixture.md b/docs/subscription-groups-fixture.md deleted file mode 100644 index c17ed43f..00000000 --- a/docs/subscription-groups-fixture.md +++ /dev/null @@ -1,44 +0,0 @@ -# Local subscription-group testing fixture - -Run the real frontend against an in-memory API with **150 channels, 18 groups and 300 videos**. The seed includes overlapping memberships, ungrouped channels, an empty group, a channel in ten groups, long names, non-Latin names, and missing avatars. All images are generated locally. Video cards are illustrative; playback is unavailable. - -From the repository root, start the API: - -```sh -bun run dev:groups-fixture -``` - -In another terminal, start the frontend: - -```sh -VITE_DEV_PROXY_TARGET=http://127.0.0.1:9876 VITE_API_URL=/api bun run dev --host 127.0.0.1 --strictPort -``` - -Open [Manage groups](http://127.0.0.1:5173/subscriptions/groups), [Channels](http://127.0.0.1:5173/subscriptions/channels?group=all), or [Videos](http://127.0.0.1:5173/subscriptions?group=all). If prompted to sign in, any nonempty identifier and password work with this local fixture; use dummy values. The profile menu identifies the local fixture. - -The fixture binds only to `127.0.0.1:9876` and never forwards requests. CRUD and membership changes exist only in memory. Restarting the API restores the seed. To reset without restarting, then reload the browser: - -```sh -curl -X POST http://127.0.0.1:9876/__qa/reset -``` - -## Pagination failure and retry - -Open Videos and wait for the first page to load. Before scrolling to the bottom, inject four failures to exhaust the client's automatic retries on the next page: - -```sh -curl http://127.0.0.1:9876/__qa/fail -X POST \ - -H 'Content-Type: application/json' \ - --data '{"path":"/subscriptions/feed","query":"cursor=","method":"GET","count":4}' -``` - -Scroll to the bottom. Existing cards should remain visible, with an error and Retry button below them. Retry should append the next page without removing or duplicating earlier cards. The same endpoint accepts a group-membership path with `method: "PUT"` and `count: 1` to exercise partial-save recovery. - -## Suggested manager checks - -- Page through both lists and search groups. On desktop, changing the viewport height changes page capacity while both pagination bars remain visible without a document or group-list scrollbar. -- Select results selects all 150 matching channels across pages. Select the final row on a page, edit it, then select another channel; the clicked row should stay visible and drafts should survive page changes. -- Select channels across group filters; compare In group / Not in group and Show selected. -- Search for `Atlas` to inspect a long channel name and ten memberships in the combobox. -- Test the Ungrouped and empty To explore filters, and create or rename a group. -- Inspect current memberships, group counts, mutation logs and dataset totals at `http://127.0.0.1:9876/__qa/state`. diff --git a/docs/subscription-groups-ux.md b/docs/subscription-groups-ux.md deleted file mode 100644 index 020c5602..00000000 --- a/docs/subscription-groups-ux.md +++ /dev/null @@ -1,90 +0,0 @@ -# Subscription group manager - -Desktop management increment toward [TypeType #172](https://github.com/TypeType-Video/TypeType/issues/172), based on the contributor's two prototypes and the maintainer's feedback. This PR only partially addresses the issue. - -## Scope and acceptance - -The contributor explicitly chose a dedicated desktop manager and, after review, confirmed narrowing this PR to that scope. Acceptance covers: - -- Desktop group creation, rename and deletion; searchable group filters; inline and bulk membership editing; selection and drafts retained across filters and pages. -- Existing Videos and Channels group filters, independent feed pagination, empty states, retry and recovery from deleted-group links. -- Bounded membership writes that respect the current API limits, cancellation of unused reads, structured error details and recovery after partial writes or failed refreshes. -- Existing TypeType styling, keyboard menu behavior and the existing document-flow fallback at smaller sizes. - -Explicit follow-up work, outside this PR's acceptance criteria: - -- **Large-library optimization:** the manager still loads the complete membership projection, which the server assembles in memory. Client search and pagination reduce rendered rows, not transferred data or server work. Server-side pagination/search, their OpenAPI contract and scale/performance testing are deferred; this PR makes no large-library performance claim. -- **Compact mobile workflow:** the stacked document-flow fallback is not the mobile composition requested in #172. The user chose to finalize desktop first; dedicated mobile organization remains deferred. -- **Post-import organization:** the optional import-completion link opens the full manager. It does not identify or select newly imported channels; a workflow scoped to that import cohort remains deferred. - -No server or player change is required for this desktop increment. Completing the deferred scale and import workflows may require API changes. - -## Direction contract - -**MODE:** Operate. Refine the existing TypeType interface; preserve the user-approved controls and workflow. The clean-main style audit is recorded in [DESIGN.md](../DESIGN.md). - -**THESIS:** One shared channel library, with groups as filters and selection independent of the current view. Users organize subscriptions without losing context. - -**OWN-WORLD:** Inherit TypeType's neutral surface, foreground, border and accent tokens, existing type, channel avatars and Lucide icons. Support its light and dark themes. - -**STORY:** Open Manage groups from Channels, find a channel or group, edit one row or select channels across filters, apply changes, and inspect the result. Grouping remains optional. - -**FIRST VIEWPORT:** A compact title and return link above a two-column workspace. Group filters, creation and search on the left; channel search, membership scope and bulk actions on the right. Both lists use replacement pagination, with page capacity derived from available height and their page controls always visible on desktop. Editing replaces the row's membership display with one compact combobox and adjacent Save/Cancel buttons. - -**FORM:** The user-approved desktop prototype; no concept seed needed for this specified extension. Clicking a row outside its channel-name link and editor controls toggles its checkbox and highlights the row. Exactly one selected channel shows the inline editor; multiple selections show membership chips and use the bulk toolbar. Selection count includes channels hidden by filters. The original two-arrow Invert button remains a one-click toggle beside Select results; its label shows the current view, In group or Not in group. It is disabled for All channels, Ungrouped and Show selected. Selection survives toggling, and counts disclose hidden selections. Removing all memberships and deleting a group require confirmation. - -**INLINE EDITOR:** Match the [original GIF](https://github.com/TypeType-Video/TypeType/issues/172#issuecomment-5433024163) and the token-input interaction in [ROCKNIX DB filters](https://rocknix-steamdb.pages.dev/). Selected groups are removable chips inside the field. Available groups appear as plain options in a searchable, scrollable dropdown; there is no expanded checkbox grid. Choosing an option keeps the field ready for another group. Drafts survive filtering and temporary multiple selection while their channel remains selected. Successful Save or Cancel clears selection; Cancel discards the draft. Escape closes the dropdown first, then cancels editing on a second press. Opening the editor preserves focus on the checkbox or search control so keyboard selection and filtering remain usable. - -**FINISH:** Desktop behavior, keyboard interactions, light/dark rendering and error recovery verified within the scope recorded below. The manager extends the inherited system without new raster assets. - -## Integration - -- `/subscriptions/groups` is the dedicated manager. Videos and Channels expose ordinary group filters. -- Read group definitions and the complete channel membership projection separately. Existing shared subscription payloads remain compatible. -- Deduplicate and validate channel URLs (nonblank, at most 2048 characters), then batch membership changes by both 500-channel and 1 MiB serialized UTF-8 body limits. Run at most three chunks concurrently within each group change, preserving change order. Multi-group operations can partially succeed; refetch actual state and retain failed work for retry. Skip bulk actions with no effective changes. -- Create/rename refresh only group definitions. Membership changes refresh definitions and the membership projection, and mark filtered Channels/Feeds stale for their next visit; unfiltered views remain valid. Subscription changes and imports retain the broader refresh. Query cancellation reaches the underlying requests, and API errors retain their code and request ID. -- Treat write completion and data refresh separately. If either group definitions or memberships fail to refresh, retain the displayed data with a warning and pause editing. Retry reloads both reads without replaying writes; editing resumes only after both succeed. Partial-write drafts remain available for retry against the refreshed state. -- Preserve current filters after mutations. Selection survives search and group changes; selecting results adds to the selection. -- Selecting a named sidebar group defaults the bulk Add/Remove target to that group. Users can override it; searching, changing row selection and toggling In group/Not in group preserve that override. Changing sidebar groups resets the target to the new group; All channels and Ungrouped start without a target. -- Page changes retain selection and drafts. Select results still selects every match across pages. Filters reset the channel page; group search resets the group page. Resizing clamps page ranges and preserves the last interacted row when its page capacity changes. -- Import completion may offer an optional link into the same manager. - -## Inherited visual system - -An isolated upstream `main` snapshot at `0f3c37c794a1a296032068ee2e13b6238b0b5495` supplied the references, independently of this branch's manager. Its committed [group preview](../apps/web/src/components/subscription-groups-preview.tsx), [subscription header](../apps/web/src/components/subscriptions-header.tsx), and [settings shell](../apps/web/src/components/section-shell.tsx) establish the square management surfaces, neutral hierarchy and compact type. Rounded admin pagination is not the reference for this workflow. [Theme tokens](../apps/web/src/styles/theme.css) remain the source of truth; [DESIGN.md](../DESIGN.md) records the broader system and its context-specific exceptions. - -| Element | Inherited treatment in the finished manager | -| --- | --- | -| Colors | `app` and `surface` provide the neutral canvas; `surface-strong` marks selection and hover. `fg`, `fg-muted`, `border` and `border-strong` preserve the existing hierarchy. Light mode uses the existing zinc-token remapping. | -| Actions and feedback | Foreground-filled Add and Save buttons use app-colored text, matching the preview's primary actions. Secondary controls have muted text and thin neutral borders; hover strengthens them. The enabled membership toggle has foreground text, a filled surface, prominent border and pointer cursor in both states; Not in group uses foreground fill. Accent marks checkboxes and keyboard focus; danger marks errors and destructive actions. | -| Typography | The existing font stack is inherited. The page title matches the subscription header (24 px, semibold, tight tracking). Channel names and standard fields use 14 px type; controls, chips, the combobox and supporting counts use 12 px type. | -| Surfaces and shapes | Panels, buttons, fields, menus and membership chips have square corners, matching the clean-main group prototype. The selected sidebar group uses a foreground outline and stronger surface. Border and surface tone separate content; the manager adds no shadows. The confirmation dialog uses a dimmed backdrop. Circular channel avatars remain unchanged. | -| Channel identity | Existing `ChannelAvatar` and `ChannelRouteLink` components carry channel identity; Lucide supplies the small action icons. Compact rows use 32 px avatars. | -| Density and layout | The desktop workspace uses a 224 px group column, a flexible channel column and a 12 px gap within a 1440 px maximum width. Standard channel rows are 56 px tall; group rows are 36 px. Two membership chips and a +N summary keep rows even; the editor exposes every membership. | - -The [manager stylesheet](../apps/web/src/styles/subscription-groups.css) consolidates local button, chip and menu treatments. Action buttons have a 32 px minimum height; keyboard focus uses a 2 px accent outline with a 3 px offset. The combobox outlines the entire field with a 2 px offset and suppresses the inner input outline. Search fields and native selects share the bordered input treatment. - -At widths of at least 1024 px and heights of at least 600 px, the manager and a compact app footer share the available viewport. Neither list scrolls. Channel capacity reserves room for the capped inline editor, and notices reduce capacity instead of pushing pagination down the page. Group action menus open above their buttons. Smaller or zoomed viewports use normal document flow with six groups and ten channels per page; compact mobile composition remains deferred. - -## Verification scope - -- Author-review follow-up: 376 tests passed, including exact 1 MiB batching, multibyte/escaped URLs, the 2048-character URL limit, a 5001-channel edit with at most three concurrent writes, targeted query refreshes, request cancellation/error metadata, and independent All/named/Ungrouped feed pages, cursors and avatar lookups. Chromium confirmed old group URLs recover to All on both pages, the Channels Retry action restores results, empty Channels has distinct default/filtered messages, direct empty Videos loads make no feed request, and a no-op bulk Add preserves selection without requests or a success notice. Menu checks covered arrow/Home/End keys, Escape focus restoration, Tab/Shift-Tab exit, outside dismissal and switching between menus. Checked the menu in light/dark themes at 1280 × 720 and the existing 390 × 844 fallback without horizontal overflow. These are fixture checks, not a large-library benchmark; Firefox, WebKit and live-backend integration remain unverified. -- Refresh recovery: 358 tests passed, including new coverage for a successful write followed by a failed membership refresh, failed group-definition refreshes, partial writes, background refreshes, and initial loading. Chromium confirmed all workspace controls pause after refresh failure, a failed retry stays paused, successful retry restores the saved membership without replaying the write, and a subsequent edit uses the recovered membership state. Checked the warning in both themes and at 1280 × 720 / 1024 × 600 without document overflow after layout settles. Fixture memberships were restored. All required automated checks passed; Firefox/WebKit remain unverified. -- Clean-main style audit: inspected Channels, Settings and the committed group prototype independently of this feature. Removed manager corner rounding and aligned borders, secondary text, selected filters and primary actions with those references. Added the extracted [design guide](../DESIGN.md) and Impeccable component sidecar. -- Style verification: Chromium confirmed square controls and panels, visible keyboard focus, both inversion states, target defaults, inline drafts and Cancel in light/dark themes. Document dimensions stayed within 1280 × 720 and 1024 × 600 desktop viewports; group lists had no scroll container. The existing 390 × 844 document-flow fallback had no horizontal overflow in either theme. `check`, `test`, `knip`, `sherif`, the production build and `git diff --check` passed. Firefox and WebKit remain unverified; the existing build-size warning and two stale Knip ignore hints remain. -- Compact pagination: 353 tests passed, including page coverage, viewport capacity, last-row editing, selection transitions, resizing and page clamping. `check`, `knip`, `sherif`, the production build and `git diff --check` passed. The build retains the existing large-chunk warning. -- Chromium desktop checks used the 150-channel fixture at 1280 × 720, 1440 × 900 and 1024 × 600. Settled document dimensions matched the viewport; group lists had no scroll container. Verified group search/pages, empty results, inversion, target defaults, retained drafts across pages, multiselect, the ten-group editor, action-menu bounds, rename error/cancel focus and success feedback. Checked light/dark themes, German text, French changes without reload, and a 390 × 844 document-flow fallback with no horizontal overflow. Firefox and WebKit remain unverified. -- Impeccable's layout assessment informed the spacing and pagination changes. Its engine was unavailable locally, so source and rendered checks replaced its mechanical detector. -- Feed error regression: 346 tests passed after adding coverage for initial-load, pagination, and background-refresh failures. Chromium retained all 30 loaded cards after an injected next-page failure; keyboard Retry appended 30 more distinct cards and cleared the error. `check`, `knip`, `sherif`, the production build and the whitespace check passed. -- A [reproducible local fixture](subscription-groups-fixture.md) provides 150 channels, 18 groups and 300 videos, including 109 channels in multiple groups and 22 ungrouped channels. Fixture CRUD and filtering were checked in memory. -- Final automated checks after defaulting the bulk target from the sidebar: 343 tests passed; `check`, `knip`, `sherif`, the production build and `git diff --check` passed. -- Bulk-target browser checks confirmed Tech and Science sidebar selections populate the target, a manual Music override survives inversion and search, and All channels/Ungrouped reset the target. Existing channel selections were preserved throughout. -- Row-selection checks covered background clicks, checkbox keyboard activation, highlighted rows in both themes, one/two/21 selections, hidden selections, retained drafts across filtering and multiple selection, Cancel, successful Save, and failed-save retry. Exactly one globally selected channel exposed an editor when visible. The local fixture's original memberships were restored after testing. -- The restored two-arrow toggle was compared with the original bulk-edit GIF and inspected in light and dark themes. Clicking switched Tech from 5 members to 16 non-members; Enter switched back. The selected channel and hidden-selection count survived both views. The control was disabled for All channels, Ungrouped and Show selected. -- The button visibility refinement was inspected in both states and themes, with click and keyboard toggling verified. `check`, the production build and the whitespace check passed after the styling change. -- Desktop browser checks used a disposable local API fixture in the in-app browser (Chromium). The corrected combobox was inspected in light and dark themes. At a 1280 × 800 CSS viewport, the ordinary inline form remained 36 px high without horizontal overflow; a bottom-row dropdown opened above the field and fit the viewport. -- The implementer and an independent reviewer compared the corrected editor with frames from the original #172 GIF. The reviewer also inspected fresh 2560 × 1440 desktop captures and found no material issues. -- Large-group checks used 107 groups: the dropdown contained 106 unselected plain options and zero group checkboxes, with 3816 px of content scrolling inside a 222 px viewport. With 21 selected chips, the chip region stayed capped at 96 px while its 326 px content scrolled; the input remained visible after adding a chip. -- Corrected-editor interactions: Enter added a group; Escape closed the dropdown, then cancelled editing on a second press; Cancel restored the original Tech membership. A failed Save preserved the Science draft and allowed retry; retry saved Science and Tech successfully. The fixture's original Tech membership was then restored. -- Earlier manager checks covered group creation, rename and deletion; bulk selection across filters; partial-failure retry; confirmations; preserved subscriptions; rename focus restoration; empty Show selected recovery; and clearing stale errors. -- Firefox, WebKit and live-backend integration were not checked. Mobile composition was explicitly deferred. The checks above do not establish complete browser or accessibility coverage. From dda7c6724b9194bdf273f3767f16eca7b3c122c6 Mon Sep 17 00:00:00 2001 From: web-flow Date: Fri, 18 Sep 2026 01:18:58 +0900 Subject: [PATCH 32/32] fix: use refreshed memberships for inline group edits --- apps/web/src/hooks/use-group-manager.ts | 5 +- .../tests/group-manager-memberships.test.tsx | 126 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 apps/web/tests/group-manager-memberships.test.tsx diff --git a/apps/web/src/hooks/use-group-manager.ts b/apps/web/src/hooks/use-group-manager.ts index 5a159961..f2ab2c51 100644 --- a/apps/web/src/hooks/use-group-manager.ts +++ b/apps/web/src/hooks/use-group-manager.ts @@ -49,7 +49,10 @@ export function useGroupManager(groups: SubscriptionGroup[], groupsReady: boolea const activeGroup = groups.find((group) => group.id === filter); const activeFilter = activeGroup || filter === "ungrouped" ? filter : "all"; const page = useGroupChannelPage(activeFilter, excluded, query, onlySelected ? chosen : null); - const visible = page.channels; + const selectedChannels = new Map(chosen.map((channel) => [channel.channelUrl, channel])); + const visible = page.channels.map( + (channel) => selectedChannels.get(channel.channelUrl) ?? channel, + ); const canEdit = groupsReady && selection.query.isSuccess && diff --git a/apps/web/tests/group-manager-memberships.test.tsx b/apps/web/tests/group-manager-memberships.test.tsx new file mode 100644 index 00000000..a73e4c38 --- /dev/null +++ b/apps/web/tests/group-manager-memberships.test.tsx @@ -0,0 +1,126 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ChannelGroupEditor } from "../src/components/subscription-groups/channel-group-editor"; +import { useGroupManager } from "../src/hooks/use-group-manager"; +import { updateGroupMemberships } from "../src/lib/api-subscription-groups"; +import { + groupMembershipPageOptions, + selectedMembershipOptions, +} from "../src/lib/group-membership-queries"; +import { channelMembershipChanges } from "../src/lib/subscription-group-selection"; +import { useAuthStore } from "../src/stores/auth-store"; +import type { GroupedSubscription } from "../src/types/subscription-groups"; + +const originalFetch = globalThis.fetch; +const clients: QueryClient[] = []; +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); + for (const client of clients.splice(0)) client.clear(); +}); +const groups = ["tech", "science", "travel"].map((id) => ({ + id, + name: id, + channelCount: 1, + createdAt: 0, + updatedAt: 0, +})); +const channel: GroupedSubscription = { + channelUrl: "https://example.com/channel/selected", + name: "Selected channel", + avatarUrl: "", + subscribedAt: 0, + groupIds: ["tech", "travel"], +}; +const other = { ...channel, channelUrl: "https://example.com/channel/other", groupIds: [] }; + +function readManager(current: string[], draft?: string[]) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + const options = groupMembershipPageOptions(undefined, { + page: 0, + limit: 10, + filter: "all", + excluded: false, + search: "", + }); + client.setQueryData(options.queryKey, { + items: [channel, other], + total: 2, + totalSubscriptions: 2, + ungroupedCount: 1, + page: 0, + limit: 10, + }); + client.setQueryData(selectedMembershipOptions(undefined, [channel.channelUrl]).queryKey, [ + { ...channel, groupIds: current }, + ]); + let state: ReturnType | undefined; + function ReadManager(): React.JSX.Element | null { + const manager = useGroupManager(groups, true); + state = manager; + if (!manager.validSelected.has(channel.channelUrl)) { + manager.toggle(channel.channelUrl); + return null; + } + if (draft && !manager.drafts.has(channel.channelUrl)) { + manager.setDraft(channel.channelUrl, new Set(draft)); + return null; + } + const visible = manager.visible[0]; + return ( + {}} + busy={false} + disabled={false} + onSave={() => {}} + onCancel={() => {}} + /> + ); + } + const markup = renderToStaticMarkup( + + + , + ); + if (!state) throw new Error("Manager did not render"); + return { state, markup, client, options }; +} + +test("inline editing shows refreshed selected memberships without changing page order or cache", () => { + const { state, markup, client, options } = readManager(["travel"]); + expect(state.visible.map((item) => item.channelUrl)).toEqual([ + channel.channelUrl, + other.channelUrl, + ]); + expect(state.visible[0].groupIds).toEqual(["travel"]); + expect(state.visible[1]).toEqual(other); + expect(markup).toContain('aria-label="Remove travel"'); + expect(markup).not.toContain('aria-label="Remove tech"'); + expect(client.getQueryData(options.queryKey)?.items[0].groupIds).toEqual(["tech", "travel"]); +}); + +test("a retained draft is saved against refreshed memberships, including externally removed groups", async () => { + const desired = ["tech", "science", "travel"]; + const { state, markup } = readManager(["travel"], desired); + const draft = state.drafts.get(channel.channelUrl); + if (!draft) throw new Error("Draft was lost"); + expect([...draft]).toEqual(desired); + for (const id of desired) expect(markup).toContain(`aria-label="Remove ${id}"`); + const stored = new Set(["travel"]); + useAuthStore.getState().setToken("membership-regression"); + globalThis.fetch = async (input, init) => { + const group = new URL(String(input), "https://fixture.test").pathname.split("/").at(-2); + if (!group) throw new Error("Missing group ID"); + expect(JSON.parse(String(init?.body)).channelUrls).toEqual([channel.channelUrl]); + if (init?.method === "PUT") stored.add(group); + else stored.delete(group); + return new Response(null, { status: 204 }); + }; + await updateGroupMemberships(channelMembershipChanges(state.visible[0], draft)); + expect([...stored].sort()).toEqual([...desired].sort()); +});