From a6bb60cd27c94290132b5e7767210da67b3b4afc 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 --- README.md | 2 + .../src/lib/subscription-group-selection.ts | 73 +++++++++++++++ apps/web/src/types/subscription-groups.ts | 16 ++++ .../subscription-group-selection.test.ts | 90 +++++++++++++++++++ 4 files changed, 181 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/README.md b/README.md index b8ad99df..de4b47ce 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@

The React web client for TypeType.

+You want to know the current position of TypeType about AI ? Go check [this](https://github.com/TypeType-Video/TypeType/blob/dev/AI_TRANSPARENCY.md). + [![Translation status](https://translate.typetype.video/widgets/typetype/-/svg-badge.svg)](https://translate.typetype.video/engage/typetype/) This repository contains the browser application and its nginx container. It owns the user interface, routing, settings, account flows, library views, and integration with the TypeType API and playback package. 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 889a6e4fac39ca7190384c0dea8e562e7dd131f5 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 f012052170393f4e9d48f67841af76075b7fa6b4 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 488d2549f34271d836fd68ed444fbe87610443e9 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 4b215fb0e138963a119f6b4bdc16a4b75deb7c7d 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 84d5b5b8cf369ce9a4550369ce92de6acd5c9d4a 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 3b48af09036a5a8d0607314b2743448e586097fc 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 3b2b29f4b247608cb76b9a5a40e5572f3a2f75d8 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 9060eb11b9643f983d4a08f1fc77032793c206db 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 a3642612387d630ca778b0f576e7b4310d3faced 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 8ce1ec84cdf6c0acb1431a7667bf916df7e0aa40 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 5ff1f1f6ba359a701ff019182619f2e50aca3a59 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 738dfc6c264cfa75261490539af553370922a523 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 14/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 d91e7309e22a28a9ca48b39135eaa5ba5b665b4c 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: serve a reproducible subscription group preview --- package.json | 1 + scripts/fixtures/subscription-groups.ts | 161 ++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 scripts/fixtures/subscription-groups.ts 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 edd1b4d6ae7f5dfae14773bd7ca657d4cd7e6d8b Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:33 +0900 Subject: [PATCH 16/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 423138855a391bc16baa0de8bb22084b609dec21 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:36 +0900 Subject: [PATCH 17/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 ba33bf6b09f18c4bc2ee9a41b0a8bcca0da52ca1 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:40 +0900 Subject: [PATCH 18/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 42086648159195461e2e3669917d840a08d981d0 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:43 +0900 Subject: [PATCH 19/32] test: cover group refresh recovery --- apps/web/tests/group-refresh-errors.test.tsx | 163 +++++++++++++++++++ 1 file changed, 163 insertions(+) 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"); +}); From 84c23b0cf5548a008919788c8fd0f89a41b31d1b Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:10:53 +0900 Subject: [PATCH 20/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 3e7954f4f221d791d347faf91bc34df601e11272 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:15:11 +0900 Subject: [PATCH 21/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 db5e12a207bc94cc382c4cca1a63df74ab5d36e2 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:18:40 +0900 Subject: [PATCH 22/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 e627d7c27507f8ca4e4c95d4ddc4644da9d614f4 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:35:40 +0900 Subject: [PATCH 23/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 {