diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index ea3e739c..dde7208f 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1325,5 +1325,82 @@ "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_select_page": "Seite auswählen ({count})", + "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.", + "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_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", + "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_selected": "{count} ausgewählt", + "sg_show_selected": "Auswahl anzeigen", + "sg_target_group": "Zielgruppe", + "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.", + "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.", + "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 5217c739..537caec3 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1325,5 +1325,82 @@ "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_select_page": "Select page ({count})", + "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.", + "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_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", + "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_selected": "{count} selected", + "sg_show_selected": "Show selected", + "sg_target_group": "Target group", + "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.", + "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.", + "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 dbd0be19..7f063285 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1325,5 +1325,82 @@ "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_select_page": "Sélectionner la page ({count})", + "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.", + "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_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", + "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_selected": "{count} sélectionnés", + "sg_show_selected": "Voir la sélection", + "sg_target_group": "Groupe cible", + "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.", + "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.", + "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/portability-import-panel.tsx b/apps/web/src/components/portability-import-panel.tsx index 683e6e67..ae5b033f 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"; @@ -11,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"; @@ -63,13 +65,14 @@ export function PortabilityImportPanel({ formats }: { formats: PortabilityFormat useEffect(() => { const state = job.data?.state ?? null; if (state === "completed" && previousState.current !== "completed") { + 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()}`, ); } previousState.current = state; - }, [job.data?.result, job.data?.state]); + }, [job.data?.result, job.data?.state, queryClient]); useEffect(() => { if (!job.missing || !jobId) return; @@ -183,6 +186,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()} + + )} + + + + ); +} 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..179ca543 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-channel-list.tsx @@ -0,0 +1,64 @@ +import type { useGroupChannelPage } from "../../hooks/use-group-channel-page"; +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[]; + pagination: ReturnType["pagination"]; + label: string; + groups: SubscriptionGroup[]; + selected: ReadonlySet; + editing: string | null; + drafts: ReadonlyMap>; + busy: boolean; + disabled: 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 { pagination } = props; + return ( +
+
+ {props.channels.length === 0 ? ( +
+

{m.sg_no_channel_match()}

+

{m.sg_change_filters()}

+
+ ) : ( +
    + {props.channels.map((channel) => ( + { + 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 new file mode 100644 index 00000000..589e48fe --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-channel-row.tsx @@ -0,0 +1,121 @@ +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(); + }} + /> + ) : ( +
    group.name).join(", ")} + > + {memberships.length > 0 ? ( + <> + {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 new file mode 100644 index 00000000..39344469 --- /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/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-manager-data.tsx b/apps/web/src/components/subscription-groups/group-manager-data.tsx new file mode 100644 index 00000000..53c4508e --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-manager-data.tsx @@ -0,0 +1,64 @@ +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; + selection?: ManagedQuery; + children: ReactNode; +}; + +export function GroupManagerData({ + groups, + channels, + selection, + children, +}: Props): React.JSX.Element { + const queries = selection ? [groups, channels, selection] : [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 new file mode 100644 index 00000000..106f82f9 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-manager.tsx @@ -0,0 +1,169 @@ +import { useMemo } from "react"; +import { useGroupManager } from "../../hooks/use-group-manager"; +import { useInterfaceLocale } from "../../hooks/use-interface-locale"; +import { 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 { 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 groups = useMemo( + () => [...(groupsQuery.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [groupsQuery.data], + ); + const { + actions, + page, + selectionQuery, + 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, groupsQuery.isSuccess); + const channelsQuery = onlySelected ? selectionQuery : page.query; + const total = page.query.data?.totalSubscriptions ?? 0; + return ( +
    + + + + actions.run( + () => createSubscriptionGroup(name), + m.sg_group_created({ group: name }), + "groups", + ) + } + onRename={(id, name) => + actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed(), "groups") + } + 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} +

    + )} + {total === 0 && page.query.isSuccess ? ( +
    +

    {m.ui_no_subscriptions_yet_2()}

    +

    {m.sg_empty_subscriptions()}

    +
    + ) : ( + + actions.run( + () => updateGroupMemberships(channelMembershipChanges(channel, ids)), + m.sg_channel_saved({ channel: channel.name }), + ) + } + /> + )} +
    +
    + {confirmationProps && !disabled && ( + setConfirmation(null)} + onConfirm={confirm} + /> + )} +
    + ); +} 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..b0657fd7 --- /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-8 min-w-0 flex-1 border border-border bg-app px-2 text-sm text-fg placeholder:text-fg-muted" + /> + + {onCancel && ( + + )} +
    + ); +} 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/components/subscription-groups/group-sidebar-item.tsx b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx new file mode 100644 index 00000000..56e77657 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx @@ -0,0 +1,121 @@ +import { MoreHorizontal } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useGroupActionMenu } from "../../hooks/use-group-action-menu"; +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 menu = useGroupActionMenu(disabled); + const actionButton = menu.trigger; + const wasRenaming = useRef(false); + useEffect(() => { + if (wasRenaming.current && !renaming) actionButton.current?.focus(); + wasRenaming.current = renaming; + }, [renaming, actionButton]); + if (renaming) + return ( + { + setRenaming(false); + onCancelRename(); + }} + onSave={async (name) => { + const saved = await onRename(name); + if (saved) setRenaming(false); + return saved; + }} + /> + ); + return ( +
    + + + {menu.open && ( + + )} +
    + ); +} 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..60e9e437 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-sidebar.tsx @@ -0,0 +1,96 @@ +import { Inbox, Users } from "lucide-react"; +import { useState } from "react"; +import { useGroupPagination } from "../../hooks/use-group-pagination"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupNameForm } from "./group-name-form"; +import { GroupPagination } from "./group-pagination"; +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 { + const [query, setQuery] = useState(""); + const matches = props.groups.filter((group) => + group.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()), + ); + const pagination = useGroupPagination({ total: matches.length, rowRem: 2.25, fallbackSize: 6 }); + return ( + + ); +} 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..58605e7e --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-toolbar.tsx @@ -0,0 +1,146 @@ +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; + 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) && ( + + )} + + + + + +
    +
    + ); +} diff --git a/apps/web/src/components/subscriptions-header.tsx b/apps/web/src/components/subscriptions-header.tsx index 0a1265f2..bce79a27 100644 --- a/apps/web/src/components/subscriptions-header.tsx +++ b/apps/web/src/components/subscriptions-header.tsx @@ -7,6 +7,7 @@ type Props = { count: number; onVideosIntent?: () => void; onChannelsIntent?: () => void; + group?: string; }; function linkClass(active: boolean): string { @@ -15,7 +16,13 @@ function linkClass(active: boolean): string { : "border-transparent text-fg-muted hover:border-border-strong hover:text-fg"; } -export function SubscriptionsHeader({ active, count, onVideosIntent, onChannelsIntent }: Props) { +export function SubscriptionsHeader({ + active, + count, + group = "all", + onVideosIntent, + onChannelsIntent, +}: Props) { return (
    @@ -30,6 +37,7 @@ export function SubscriptionsHeader({ active, count, onVideosIntent, onChannelsI
    {watchCinemaPage ? !isMobile && : } -
    +
    diff --git a/apps/web/src/routes/subscriptions.tsx b/apps/web/src/routes/subscriptions.tsx index f8b92f04..7cb26cd8 100644 --- a/apps/web/src/routes/subscriptions.tsx +++ b/apps/web/src/routes/subscriptions.tsx @@ -2,49 +2,49 @@ import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useMemo, useRef } from "react"; import { ScrollSentinel } from "../components/scroll-sentinel"; +import { SubscriptionGroupFilter } from "../components/subscription-group-filter"; import { SubscriptionsHeader } from "../components/subscriptions-header"; 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 { 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()); - const { query } = useSubscriptions(); + const { group = "all" } = Route.useSearch(); + const navigate = Route.useNavigate(); + const { query } = useSubscriptions(group); const subscriptions = query.data ?? []; - const { streams, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = - useSubscriptionFeed(); + const { + streams, + isLoading, + isLoadingError, + isFetchNextPageError, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + refetch, + error: feedError, + } = useSubscriptionFeed(group); const { filter } = useBlockedFilter(); const visible = useMemo(() => filter(streams), [filter, streams]); function prefetchChannels() { - void queryClient.prefetchQuery({ - queryKey: SUBSCRIPTIONS_KEY, - queryFn: fetchSubscriptions, - staleTime: SUBSCRIPTION_STALE_MS, - }); + void queryClient.prefetchQuery(subscriptionsQueryOptions(group)); } function prefetchVideos() { - void queryClient.prefetchInfiniteQuery({ - queryKey: SUBSCRIPTION_FEED_KEY, - queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal), - initialPageParam: null as string | null, - getNextPageParam: nextSubscriptionPage, - staleTime: SUBSCRIPTION_STALE_MS, - }); + if (!query.data?.length) return; + void queryClient.prefetchInfiniteQuery(subscriptionFeedQueryOptions(group)); } useEffect(() => { @@ -60,31 +60,67 @@ function SubscriptionsPage() { } }, [streams, queryClient]); - if (query.isSuccess && subscriptions.length === 0) { - return ( -
    -

    {m.ui_no_subscriptions_yet_2()}

    -
    - ); - } - return (
    + void navigate({ search: { group: value }, replace })} + /> {query.isLoading || isLoading ? ( + ) : query.isLoadingError || isLoadingError ? ( +
    +

    {m.subscriptions_feed_load_error()}

    + +
    ) : ( <> + {visible.length === 0 && ( +

    + {group === "all" && subscriptions.length === 0 + ? m.ui_no_subscriptions_yet_2() + : m.sg_empty_feed()} +

    + )} {isFetchingNextPage && } + {isFetchNextPageError && ( +
    +

    {m.subscriptions_feed_next_page_error()}

    + +
    + )} )} @@ -93,5 +129,8 @@ function SubscriptionsPage() { } export const Route = createFileRoute("/subscriptions")({ + validateSearch: (search: Record): { group?: string } => ({ + group: typeof search.group === "string" && search.group ? search.group : "all", + }), component: SubscriptionsPage, }); diff --git a/apps/web/src/routes/subscriptions_.channels.tsx b/apps/web/src/routes/subscriptions_.channels.tsx index 49c5238a..2d68464e 100644 --- a/apps/web/src/routes/subscriptions_.channels.tsx +++ b/apps/web/src/routes/subscriptions_.channels.tsx @@ -1,52 +1,34 @@ import { useQueryClient } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; import { SubscriptionChannelList } from "../components/subscription-channel-list"; +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 { 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 { query } = useSubscriptions(); + const { group = "all" } = Route.useSearch(); + const navigate = Route.useNavigate(); + const { query } = useSubscriptions(group); const { isChannelIdentityBlocked } = useBlockedFilter(); const subscriptions = (query.data ?? []).filter( (item) => !isChannelIdentityBlocked({ url: item.channelUrl, name: item.name }), ); function prefetchChannels() { - void queryClient.prefetchQuery({ - queryKey: SUBSCRIPTIONS_KEY, - queryFn: fetchSubscriptions, - staleTime: SUBSCRIPTION_STALE_MS, - }); + void queryClient.prefetchQuery(subscriptionsQueryOptions(group)); } function prefetchVideos() { - void queryClient.prefetchInfiniteQuery({ - queryKey: SUBSCRIPTION_FEED_KEY, - queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal), - initialPageParam: null as string | null, - getNextPageParam: nextSubscriptionPage, - staleTime: SUBSCRIPTION_STALE_MS, - }); - } - - if (query.isSuccess && subscriptions.length === 0) { - return ( -
    -

    {m.ui_no_subscriptions_yet_2()}

    -
    - ); + if (!query.data?.length) return; + void queryClient.prefetchInfiniteQuery(subscriptionFeedQueryOptions(group)); } return ( @@ -54,11 +36,41 @@ function SubscriptionChannelsPage() { +
    + void navigate({ search: { group: value }, replace })} + /> + + {m.sg_manage_groups()} + +
    {query.isLoading ? ( + ) : query.isError ? ( +
    +

    {m.sg_channels_load_error()}

    + +
    + ) : subscriptions.length === 0 ? ( +

    + {group === "all" ? m.ui_no_subscriptions_yet_2() : m.sg_no_channel_match()} +

    ) : ( )} @@ -67,5 +79,8 @@ function SubscriptionChannelsPage() { } export const Route = createFileRoute("/subscriptions_/channels")({ + validateSearch: (search: Record): { group?: string } => ({ + group: typeof search.group === "string" && search.group ? search.group : "all", + }), component: SubscriptionChannelsPage, }); 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, +}); diff --git a/apps/web/src/styles/subscription-groups.css b/apps/web/src/styles/subscription-groups.css new file mode 100644 index 00000000..6ee1d3a1 --- /dev/null +++ b/apps/web/src/styles/subscription-groups.css @@ -0,0 +1,79 @@ +@reference "../index.css"; + +@layer components { + .sg-button { + @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 { + @apply bg-app; + } + + .sg-membership-toggle:enabled { + @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"] { + @apply border-fg bg-fg text-app hover:bg-fg-strong; + } + + .sg-chip { + @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 text-fg-muted hover:bg-surface-strong hover:text-fg 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; +} + +@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; + } +} diff --git a/apps/web/src/types/subscription-groups.ts b/apps/web/src/types/subscription-groups.ts new file mode 100644 index 00000000..3d211efd --- /dev/null +++ b/apps/web/src/types/subscription-groups.ts @@ -0,0 +1,31 @@ +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 MembershipPage = { + items: GroupedSubscription[]; + total: number; + totalSubscriptions: number; + ungroupedCount: number; + page: number; + limit: number; +}; +export type MembershipPageRequest = { + page: number; + limit: number; + filter: string; + search: string; + excluded: boolean; +}; +export type MembershipChange = { + groupId: string; + channelUrls: string[]; + action: "add" | "remove"; +}; 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..2ec942fc --- /dev/null +++ b/apps/web/tests/api-subscription-groups.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { ApiError } from "../src/lib/api"; +import { + createSubscriptionGroup, + deleteSubscriptionGroup, + 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("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"); + } +}); diff --git a/apps/web/tests/group-manager-memberships.test.tsx b/apps/web/tests/group-manager-memberships.test.tsx new file mode 100644 index 00000000..a73e4c38 --- /dev/null +++ b/apps/web/tests/group-manager-memberships.test.tsx @@ -0,0 +1,126 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ChannelGroupEditor } from "../src/components/subscription-groups/channel-group-editor"; +import { useGroupManager } from "../src/hooks/use-group-manager"; +import { updateGroupMemberships } from "../src/lib/api-subscription-groups"; +import { + groupMembershipPageOptions, + selectedMembershipOptions, +} from "../src/lib/group-membership-queries"; +import { channelMembershipChanges } from "../src/lib/subscription-group-selection"; +import { useAuthStore } from "../src/stores/auth-store"; +import type { GroupedSubscription } from "../src/types/subscription-groups"; + +const originalFetch = globalThis.fetch; +const clients: QueryClient[] = []; +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); + for (const client of clients.splice(0)) client.clear(); +}); +const groups = ["tech", "science", "travel"].map((id) => ({ + id, + name: id, + channelCount: 1, + createdAt: 0, + updatedAt: 0, +})); +const channel: GroupedSubscription = { + channelUrl: "https://example.com/channel/selected", + name: "Selected channel", + avatarUrl: "", + subscribedAt: 0, + groupIds: ["tech", "travel"], +}; +const other = { ...channel, channelUrl: "https://example.com/channel/other", groupIds: [] }; + +function readManager(current: string[], draft?: string[]) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + const options = groupMembershipPageOptions(undefined, { + page: 0, + limit: 10, + filter: "all", + excluded: false, + search: "", + }); + client.setQueryData(options.queryKey, { + items: [channel, other], + total: 2, + totalSubscriptions: 2, + ungroupedCount: 1, + page: 0, + limit: 10, + }); + client.setQueryData(selectedMembershipOptions(undefined, [channel.channelUrl]).queryKey, [ + { ...channel, groupIds: current }, + ]); + let state: ReturnType | undefined; + function ReadManager(): React.JSX.Element | null { + const manager = useGroupManager(groups, true); + state = manager; + if (!manager.validSelected.has(channel.channelUrl)) { + manager.toggle(channel.channelUrl); + return null; + } + if (draft && !manager.drafts.has(channel.channelUrl)) { + manager.setDraft(channel.channelUrl, new Set(draft)); + return null; + } + const visible = manager.visible[0]; + return ( + {}} + busy={false} + disabled={false} + onSave={() => {}} + onCancel={() => {}} + /> + ); + } + const markup = renderToStaticMarkup( + + + , + ); + if (!state) throw new Error("Manager did not render"); + return { state, markup, client, options }; +} + +test("inline editing shows refreshed selected memberships without changing page order or cache", () => { + const { state, markup, client, options } = readManager(["travel"]); + expect(state.visible.map((item) => item.channelUrl)).toEqual([ + channel.channelUrl, + other.channelUrl, + ]); + expect(state.visible[0].groupIds).toEqual(["travel"]); + expect(state.visible[1]).toEqual(other); + expect(markup).toContain('aria-label="Remove travel"'); + expect(markup).not.toContain('aria-label="Remove tech"'); + expect(client.getQueryData(options.queryKey)?.items[0].groupIds).toEqual(["tech", "travel"]); +}); + +test("a retained draft is saved against refreshed memberships, including externally removed groups", async () => { + const desired = ["tech", "science", "travel"]; + const { state, markup } = readManager(["travel"], desired); + const draft = state.drafts.get(channel.channelUrl); + if (!draft) throw new Error("Draft was lost"); + expect([...draft]).toEqual(desired); + for (const id of desired) expect(markup).toContain(`aria-label="Remove ${id}"`); + const stored = new Set(["travel"]); + useAuthStore.getState().setToken("membership-regression"); + globalThis.fetch = async (input, init) => { + const group = new URL(String(input), "https://fixture.test").pathname.split("/").at(-2); + if (!group) throw new Error("Missing group ID"); + expect(JSON.parse(String(init?.body)).channelUrls).toEqual([channel.channelUrl]); + if (init?.method === "PUT") stored.add(group); + else stored.delete(group); + return new Response(null, { status: 204 }); + }; + await updateGroupMemberships(channelMembershipChanges(state.visible[0], draft)); + expect([...stored].sort()).toEqual([...desired].sort()); +}); diff --git a/apps/web/tests/group-membership-queries.test.ts b/apps/web/tests/group-membership-queries.test.ts new file mode 100644 index 00000000..c082a309 --- /dev/null +++ b/apps/web/tests/group-membership-queries.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { + groupMembershipPageOptions, + selectedMembershipOptions, +} from "../src/lib/group-membership-queries"; +import { invalidateSubscriptionQueries } from "../src/lib/subscription-queries"; +import { useAuthStore } from "../src/stores/auth-store"; + +const originalFetch = globalThis.fetch; +const clients: QueryClient[] = []; +function client(): QueryClient { + const value = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(value); + return value; +} +beforeEach(() => useAuthStore.getState().setToken("page-test")); +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); + for (const value of clients.splice(0)) value.clear(); +}); +const request = { page: 1, limit: 7, filter: "tech", excluded: true, search: "A & B" }; + +test("pages and filters have independent caches and send bounded server parameters", async () => { + const cache = client(); + const calls: URL[] = []; + globalThis.fetch = async (input) => { + const url = new URL(String(input), "https://fixture.test"); + calls.push(url); + return Response.json({ + items: [], + total: 1001, + totalSubscriptions: 1200, + ungroupedCount: 20, + page: Number(url.searchParams.get("page")), + limit: Number(url.searchParams.get("limit")), + }); + }; + const requests = [ + request, + { ...request, page: 2 }, + { ...request, filter: "ungrouped", excluded: false }, + { ...request, filter: "all", excluded: false }, + ]; + for (const value of requests) + await cache.fetchQuery(groupMembershipPageOptions("profile", value)); + await cache.fetchQuery(groupMembershipPageOptions("profile", request)); + expect(calls).toHaveLength(4); + expect(calls.every((url) => url.pathname === "/api/subscriptions/group-memberships/page")).toBe( + true, + ); + expect(Object.fromEntries(calls[0].searchParams)).toEqual({ + page: "1", + limit: "7", + groupId: "tech", + excluded: "true", + search: "A & B", + }); + expect(calls[2].searchParams.get("ungrouped")).toBe("true"); + expect(calls[3].searchParams.has("groupId")).toBe(false); +}); + +test("selection lookup splits large selections, deduplicates URLs and stays account scoped", async () => { + const cache = client(); + const urls = Array.from({ length: 1201 }, (_, i) => `https://example.com/channel/${i}`); + const sizes: number[] = []; + globalThis.fetch = async (input, init) => { + expect(String(input)).toBe("/api/subscriptions/group-memberships/lookup"); + expect(init?.method).toBe("POST"); + const body = JSON.parse(String(init?.body)); + sizes.push(body.channelUrls.length); + return Response.json( + body.channelUrls.map((channelUrl: string) => ({ + channelUrl, + name: channelUrl, + avatarUrl: "", + subscribedAt: 0, + groupIds: [], + })), + ); + }; + expect( + await cache.fetchQuery(selectedMembershipOptions("first", [...urls, urls[0]])), + ).toHaveLength(1201); + expect(sizes).toEqual([500, 500, 201]); + expect(cache.getQueryData(selectedMembershipOptions("second", urls).queryKey)).toBeUndefined(); +}); + +test("leaving a page or selection aborts the underlying read", () => { + for (const options of [ + groupMembershipPageOptions("profile", request), + selectedMembershipOptions("profile", ["channel"]), + ]) { + const cache = client(); + 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(cache, options); + const stop = observer.subscribe(() => {}); + expect(signal?.aborted).toBe(false); + stop(); + expect(signal?.aborted).toBe(true); + } +}); + +test("membership changes refresh off-page selections and remove unsubscribed channels", async () => { + const cache = client(); + let present = true; + let groupIds: string[] = []; + globalThis.fetch = async () => + Response.json( + present + ? [{ channelUrl: "off-page", name: "Off page", avatarUrl: "", subscribedAt: 0, groupIds }] + : [], + ); + const options = selectedMembershipOptions("profile", ["off-page"]); + await cache.fetchQuery(options); + const observer = new QueryObserver(cache, options); + const stop = observer.subscribe(() => {}); + groupIds = ["tech", "science"]; + await invalidateSubscriptionQueries(cache, "memberships"); + expect(cache.getQueryData(options.queryKey)?.[0].groupIds).toEqual(groupIds); + present = false; + await invalidateSubscriptionQueries(cache, "subscriptions"); + expect(cache.getQueryData(options.queryKey)).toEqual([]); + stop(); +}); 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); +}); diff --git a/apps/web/tests/group-refresh-errors.test.tsx b/apps/web/tests/group-refresh-errors.test.tsx new file mode 100644 index 00000000..47e8796c --- /dev/null +++ b/apps/web/tests/group-refresh-errors.test.tsx @@ -0,0 +1,163 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient, QueryClientProvider, QueryObserver } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { GroupManagerData } from "../src/components/subscription-groups/group-manager-data"; +import { useGroupActions } from "../src/hooks/use-subscription-groups"; +import { MembershipUpdateError } from "../src/lib/api-subscription-groups"; +import { m } from "../src/paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../src/types/subscription-groups"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +function readActions(client: QueryClient, enabled: boolean): ReturnType { + let actions: ReturnType | undefined; + function ReadActions(): null { + actions = useGroupActions(enabled); + return null; + } + renderToStaticMarkup( + + + , + ); + if (!actions) throw new Error("Actions did not render"); + return actions; +} + +function setup() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const failedReads = new Set(); + const state: { memberships: string[]; writes: number } = { memberships: [], writes: 0 }; + const group: SubscriptionGroup = { + id: "tech", + name: "Tech", + channelCount: 0, + createdAt: 0, + updatedAt: 0, + }; + const channel: GroupedSubscription = { + channelUrl: "https://www.youtube.com/channel/example", + name: "Example", + avatarUrl: "", + subscribedAt: 0, + groupIds: [], + }; + const groupKey = ["subscription-groups", "profile"]; + const channelKey = ["subscription-group-memberships", "profile"]; + client.setQueryData(groupKey, [group]); + client.setQueryData(channelKey, [channel]); + const groups = new QueryObserver(client, { + queryKey: groupKey, + staleTime: Infinity, + queryFn: async () => { + if (failedReads.has("groups")) throw new Error("Group refresh unavailable"); + return [{ ...group, channelCount: state.memberships.length ? 1 : 0 }]; + }, + }); + const channels = new QueryObserver(client, { + queryKey: channelKey, + staleTime: Infinity, + queryFn: async () => { + if (failedReads.has("memberships")) throw new Error("Membership refresh unavailable"); + return [{ ...channel, groupIds: [...state.memberships] }]; + }, + }); + const unsubscribers = [groups.subscribe(() => {}), channels.subscribe(() => {})]; + cleanups.push(() => { + for (const unsubscribe of unsubscribers) unsubscribe(); + client.clear(); + }); + return { + client, + groups, + channels, + state, + failedReads, + write: async () => { + state.writes++; + state.memberships = ["tech"]; + }, + retry: () => Promise.all([groups.refetch(), channels.refetch()]), + actions: () => + readActions( + client, + groups.getCurrentResult().isSuccess && channels.getCurrentResult().isSuccess, + ), + render: () => + renderToStaticMarkup( + + + , + ), + }; +} + +test("saved memberships with a failed refresh pause editing; recovery only repeats reads", async () => { + const fixture = setup(); + fixture.failedReads.add("memberships"); + expect(await fixture.actions().run(fixture.write, "Saved")).toBe(true); + expect(fixture.state.memberships).toEqual(["tech"]); + expect(fixture.channels.getCurrentResult().data?.[0].groupIds).toEqual([]); + expect(fixture.render()).toContain(m.sg_refresh_error()); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("groups"); + expect(await fixture.actions().run(fixture.write, "Saved")).toBe(true); + expect(fixture.channels.getCurrentResult().data?.[0].groupIds).toEqual(["tech"]); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("memberships"); + expect( + await fixture.actions().run(async () => { + await fixture.write(); + throw new MembershipUpdateError(["https://www.youtube.com/channel/example"]); + }, "Saved"), + ).toBe(false); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("memberships"); + await fixture.channels.refetch(); + expect(fixture.render()).toContain("Edit memberships"); + expect(fixture.render()).toContain(m.sg_refresh_error()); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("groups"); + await fixture.client.resetQueries({ queryKey: ["subscription-groups"] }); + expect(fixture.render()).toContain(m.sg_load_error()); + expect(fixture.render()).toContain(m.sg_retry()); + expect(fixture.render()).not.toContain("Edit memberships"); +}); diff --git a/apps/web/tests/helpers/subscription-feed.tsx b/apps/web/tests/helpers/subscription-feed.tsx new file mode 100644 index 00000000..4144e8ef --- /dev/null +++ b/apps/web/tests/helpers/subscription-feed.tsx @@ -0,0 +1,46 @@ +import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { useSubscriptionFeed } from "../../src/hooks/use-subscription-feed"; +import type { VideoItem } from "../../src/types/api"; + +export function readFeed( + client: QueryClient, + filter = "all", +): ReturnType { + let state: ReturnType | undefined; + function ReadFeed(): null { + state = useSubscriptionFeed(filter); + return null; + } + renderToStaticMarkup( + + + , + ); + if (!state) throw new Error("Feed hook did not render"); + return state; +} + +export 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, + }; +} 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/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; 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..70a452e9 --- /dev/null +++ b/apps/web/tests/subscription-feed-errors.test.tsx @@ -0,0 +1,110 @@ +import { afterEach, expect, test } from "bun:test"; +import { InfiniteQueryObserver, QueryClient } from "@tanstack/react-query"; +import { + subscriptionFeedQueryOptions, + subscriptionsQueryOptions, +} from "../src/lib/subscription-queries"; +import { useAuthStore } from "../src/stores/auth-store"; +import { readFeed, video } from "./helpers/subscription-feed"; + +const originalFetch = globalThis.fetch; +const clients: QueryClient[] = []; +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); + for (const client of clients.splice(0)) client.clear(); +}); + +function setup(filter = "all") { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + client.setQueryData(subscriptionsQueryOptions(filter).queryKey, [ + { + channelUrl: "https://example.org/channel", + name: "Test channel", + avatarUrl: "", + subscribedAt: 0, + }, + ]); + useAuthStore.getState().setToken("feed-error-test"); + const observer = new InfiniteQueryObserver(client, subscriptionFeedQueryOptions(filter)); + return { client, observer }; +} + +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"]); +}); + +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-feed-filters.test.tsx b/apps/web/tests/subscription-feed-filters.test.tsx new file mode 100644 index 00000000..22ae37cb --- /dev/null +++ b/apps/web/tests/subscription-feed-filters.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; +import { proxyImage } from "../src/lib/proxy"; +import { + subscriptionFeedQueryOptions, + subscriptionsQueryOptions, +} from "../src/lib/subscription-queries"; +import { useAuthStore } from "../src/stores/auth-store"; +import { readFeed, video } from "./helpers/subscription-feed"; + +const originalFetch = globalThis.fetch; +const clients: QueryClient[] = []; +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); + for (const client of clients.splice(0)) client.clear(); +}); + +function setup(): QueryClient { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + useAuthStore.getState().setToken("feed-filter-test"); + return client; +} + +test("all, named and ungrouped feeds keep separate pages, cursors and avatar sources", async () => { + const client = setup(); + const calls: Array<{ filter: string; cursor: string | null }> = []; + const channelUrl = "https://www.youtube.com/channel/shared"; + globalThis.fetch = async (input) => { + const url = new URL(String(input), "https://fixture.invalid"); + const filter = + url.searchParams.get("groupId") ?? + (url.searchParams.get("ungrouped") === "true" ? "ungrouped" : "all"); + const cursor = url.searchParams.get("cursor"); + calls.push({ filter, cursor }); + return Response.json({ + videos: [{ ...video(`${filter}-${cursor ? 2 : 1}`), uploaderUrl: channelUrl }], + nextpage: cursor ? null : `${filter}-cursor`, + }); + }; + const filters = ["all", "tech", "ungrouped"]; + for (const filter of filters) { + client.setQueryData(subscriptionsQueryOptions(filter).queryKey, [ + { + channelUrl, + name: "Channel", + avatarUrl: `https://example.org/${filter}.jpg`, + subscribedAt: 0, + }, + ]); + await client.prefetchInfiniteQuery(subscriptionFeedQueryOptions(filter)); + } + for (const filter of filters) { + const feed = readFeed(client, filter); + expect(feed.streams.map((item) => item.title)).toEqual([`${filter}-1`]); + expect(feed.streams[0].channelAvatar).toBe(proxyImage(`https://example.org/${filter}.jpg`)); + } + await readFeed(client, "tech").fetchNextPage(); + expect(readFeed(client, "all").streams).toHaveLength(1); + expect(readFeed(client, "ungrouped").streams).toHaveLength(1); + await readFeed(client, "ungrouped").fetchNextPage(); + await readFeed(client, "all").fetchNextPage(); + for (const filter of filters) { + expect(calls.filter((call) => call.filter === filter).map((call) => call.cursor)).toEqual([ + null, + `${filter}-cursor`, + ]); + expect(readFeed(client, filter).streams.map((item) => item.title)).toEqual([ + `${filter}-1`, + `${filter}-2`, + ]); + } +}); + +test("a named feed reuses filtered subscriptions without creating an unfiltered query", () => { + const client = setup(); + client.setQueryData(subscriptionsQueryOptions("tech").queryKey, []); + expect(readFeed(client, "tech").isLoading).toBe(false); + expect(client.getQueryState(subscriptionsQueryOptions().queryKey)).toBeUndefined(); +}); + +test("empty subscriptions suppress cached feed cards and loading or pagination states", () => { + const client = setup(); + client.setQueryData(subscriptionsQueryOptions().queryKey, []); + client.setQueryData(subscriptionFeedQueryOptions().queryKey, { + pages: [{ videos: [video("old")], nextpage: "old-cursor" }], + pageParams: [null], + }); + const feed = readFeed(client); + expect(feed.streams).toEqual([]); + expect(feed.isLoading).toBe(false); + expect(feed.isLoadingError).toBe(false); + expect(feed.hasNextPage).toBe(false); +}); 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..e37a4471 --- /dev/null +++ b/apps/web/tests/subscription-group-selection.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import { + channelMembershipChanges, + clearMembershipChanges, + 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: [], + }, +]; +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"); +}); 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); +}); 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, + ); +}); diff --git a/apps/web/tests/subscription-requests.test.ts b/apps/web/tests/subscription-requests.test.ts new file mode 100644 index 00000000..dc4a678e --- /dev/null +++ b/apps/web/tests/subscription-requests.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { ApiError } from "../src/lib/api"; +import { 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, + (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(); +}); 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-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..bae93093 --- /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 }; +export 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 }); +} diff --git a/scripts/fixtures/subscription-groups.ts b/scripts/fixtures/subscription-groups.ts new file mode 100644 index 00000000..63d8b027 --- /dev/null +++ b/scripts/fixtures/subscription-groups.ts @@ -0,0 +1,165 @@ +import { fixtureImage } from "./subscription-groups-data"; +import { + createFixture, + filteredChannels, + groupCounts, + writeGroup, +} from "./subscription-groups-state"; +import { fixtureMembershipLookup, fixtureMembershipPage } from "./subscription-membership-pages"; + +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/page") return fixtureMembershipPage(state, url); + if (path === "/subscriptions/group-memberships/lookup" && method === "POST") + return fixtureMembershipLookup(state, request); + 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)", +); diff --git a/scripts/fixtures/subscription-membership-pages.ts b/scripts/fixtures/subscription-membership-pages.ts new file mode 100644 index 00000000..fa14ebd7 --- /dev/null +++ b/scripts/fixtures/subscription-membership-pages.ts @@ -0,0 +1,50 @@ +import type { Fixture } from "./subscription-groups-state"; + +export function fixtureMembershipPage(state: Fixture, url: URL): Response { + const group = url.searchParams.get("groupId"); + if (group && !state.groups.some((item) => item.id === group)) + return Response.json( + { error: "Group not found", code: "subscription_group_not_found" }, + { status: 404 }, + ); + const page = Math.max(0, Number(url.searchParams.get("page")) || 0); + const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit")) || 20)); + const search = (url.searchParams.get("search") ?? "").trim().toLowerCase(); + const excluded = url.searchParams.get("excluded") === "true"; + const ungrouped = url.searchParams.get("ungrouped") === "true"; + const channels = state.channels + .filter( + (channel) => + (group + ? channel.groupIds.includes(group) !== excluded + : !ungrouped || channel.groupIds.length === 0) && + (!search || `${channel.name} ${channel.channelUrl}`.toLowerCase().includes(search)), + ) + .sort( + (a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || + a.channelUrl.localeCompare(b.channelUrl), + ); + return Response.json({ + items: channels.slice(page * limit, (page + 1) * limit), + total: channels.length, + totalSubscriptions: state.channels.length, + ungroupedCount: state.channels.filter((channel) => !channel.groupIds.length).length, + page, + limit, + }); +} + +export async function fixtureMembershipLookup(state: Fixture, request: Request): Promise { + const body: unknown = await request.json(); + if ( + !body || + typeof body !== "object" || + !("channelUrls" in body) || + !Array.isArray(body.channelUrls) || + body.channelUrls.length > 500 + ) + return Response.json({ error: "Invalid channels" }, { status: 400 }); + const selected = new Set(body.channelUrls); + return Response.json(state.channels.filter((channel) => selected.has(channel.channelUrl))); +}