Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a6bb60c
feat: add subscription group models and selection helpers
kapdon Sep 15, 2026
889a6e4
feat: add subscription group API client and batch writes
kapdon Sep 15, 2026
f012052
feat: translate subscription group management messages
kapdon Sep 15, 2026
488d254
feat: load group memberships and refresh subscription caches
kapdon Sep 15, 2026
4b215fb
feat: add group forms and destructive action confirmations
kapdon Sep 15, 2026
84d5b5b
feat: add subscription group sidebar and group actions
kapdon Sep 15, 2026
3b48af0
feat: add searchable inline group membership editing
kapdon Sep 15, 2026
3b2b29f
feat: manage channel selection and group edit drafts
kapdon Sep 15, 2026
9060eb1
feat: show group editors for single selected channel rows
kapdon Sep 15, 2026
a364261
feat: add group membership filters and bulk action toolbar
kapdon Sep 15, 2026
8ce1ec8
feat: assemble the subscription group management page
kapdon Sep 15, 2026
5ff1f1f
feat: integrate group filters into subscription views
kapdon Sep 15, 2026
7d7c9c5
fix: preserve subscription feed after pagination errors
kapdon Sep 16, 2026
738dfc6
test: add varied subscription group fixture data
kapdon Sep 16, 2026
d91e730
test: serve a reproducible subscription group preview
kapdon Sep 16, 2026
edd1b4d
feat: add viewport-aware subscription group pagination
web-flow Sep 16, 2026
4231388
style: compact group management with TypeType controls
web-flow Sep 16, 2026
ba33bf6
fix: pause group editing after failed data refreshes
web-flow Sep 16, 2026
4208664
test: cover group refresh recovery
web-flow Sep 16, 2026
84c23b0
refactor: share subscription queries and cache refreshes
web-flow Sep 17, 2026
3e7954f
fix: cancel subscription reads and preserve API errors
web-flow Sep 17, 2026
db5e12a
fix: bound membership writes and target query refreshes
web-flow Sep 17, 2026
e627d7c
fix: recover subscription filters and empty states
web-flow Sep 17, 2026
56607cb
fix: add accessible group action menus
web-flow Sep 17, 2026
1821d57
feat: query server pages and bounded membership selections
web-flow Sep 17, 2026
a18067d
feat: retain channel selection across server-paginated results
web-flow Sep 17, 2026
9460ecc
test: serve paginated subscription membership fixtures
web-flow Sep 17, 2026
9dd3074
fix: use refreshed memberships for inline group edits
web-flow Sep 17, 2026
efb63e8
test: mirror merged membership page contract in fixture
web-flow Sep 23, 2026
d3a8147
Merge dev into subscription group management
web-flow Sep 23, 2026
915c9c6
fix: refresh memberships when reselecting channels
web-flow Sep 23, 2026
c94537d
Merge remote-tracking branch 'origin/dev' into pr32-local-review
Priveetee Sep 26, 2026
dfc9ebf
fix: align portability query invalidation
Priveetee Sep 26, 2026
7b65f53
fix: switch filter before deleting active group
Priveetee Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions apps/web/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,83 @@
"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.",
"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.",
"ui_bilibili_session": "BiliBili-Sitzung",
"ui_bilibili_session_connected": "Verbunden",
"ui_bilibili_session_not_connected": "Nicht verbunden",
Expand Down
77 changes: 77 additions & 0 deletions apps/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,83 @@
"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.",
"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.",
"ui_bilibili_session": "BiliBili Session",
"ui_bilibili_session_connected": "Connected",
"ui_bilibili_session_not_connected": "Not connected",
Expand Down
77 changes: 77 additions & 0 deletions apps/web/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,83 @@
"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.",
"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.",
"ui_bilibili_session": "Session BiliBili",
"ui_bilibili_session_connected": "Connecté",
"ui_bilibili_session_not_connected": "Non connecté",
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/components/portability-import-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { ArchiveRestore } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "../hooks/use-auth";
Expand All @@ -13,6 +14,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";
Expand Down Expand Up @@ -78,13 +80,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;
Expand Down Expand Up @@ -170,6 +173,16 @@ export function PortabilityImportPanel({ formats }: { formats: PortabilityFormat

{job.data && ["completed", "failed", "cancelled"].includes(job.data.state) && (
<div className="flex flex-col gap-2 sm:flex-row">
{job.data.state === "completed" &&
Comment thread
Priveetee marked this conversation as resolved.
((job.data.result?.subscriptions ?? 0) > 0 ||
(job.data.result?.subscriptionGroups ?? 0) > 0) && (
<Link
to="/subscriptions/groups"
className="inline-flex h-9 items-center border border-border px-3 text-xs text-fg hover:bg-surface-strong"
>
{m.sg_manage_groups()}
</Link>
)}
<button
type="button"
onClick={reset}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/portability-progress-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useAuth } from "../hooks/use-auth";
import { usePersistedPortabilityJob } from "../hooks/use-persisted-portability-job";
import { usePortabilityJob } from "../hooks/use-portability-job";
import { SUBSCRIPTION_FEED_KEY } from "../hooks/use-subscription-feed";
import type { PortabilityCategory, PortabilityJob } from "../lib/api-portability";
import {
getPortabilityPreparationProgress,
subscribePortabilityPreparationProgress,
} from "../lib/portability-preparation-progress";
import { SUBSCRIPTION_FEED_KEY } from "../lib/subscription-queries";
import { m } from "../paraglide/messages.js";
import { getLocale } from "../paraglide/runtime.js";
import { portabilityImportStageLabel } from "./portability-job-status";
Expand Down
Loading