Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
bbd1ef2
feat: add subscription group models and selection helpers
kapdon Sep 15, 2026
99e7231
feat: add subscription group API client and batch writes
kapdon Sep 15, 2026
88f9fe3
feat: translate subscription group management messages
kapdon Sep 15, 2026
5493f96
feat: load group memberships and refresh subscription caches
kapdon Sep 15, 2026
658cc63
feat: add group forms and destructive action confirmations
kapdon Sep 15, 2026
f55c78f
feat: add subscription group sidebar and group actions
kapdon Sep 15, 2026
b2c1387
feat: add searchable inline group membership editing
kapdon Sep 15, 2026
7147e69
feat: manage channel selection and group edit drafts
kapdon Sep 15, 2026
4967629
feat: show group editors for single selected channel rows
kapdon Sep 15, 2026
46edab7
feat: add group membership filters and bulk action toolbar
kapdon Sep 15, 2026
358224f
feat: assemble the subscription group management page
kapdon Sep 15, 2026
6696598
feat: integrate group filters into subscription views
kapdon Sep 15, 2026
8683521
docs: describe subscription group UX and verification
kapdon Sep 15, 2026
4b2e732
fix: preserve subscription feed after pagination errors
kapdon Sep 16, 2026
9cc1cf7
test: add varied subscription group fixture data
kapdon Sep 16, 2026
06bf5ce
test: serve a reproducible subscription group preview
kapdon Sep 16, 2026
0b4dd9a
feat: add viewport-aware subscription group pagination
web-flow Sep 16, 2026
c170675
style: compact group management with TypeType controls
web-flow Sep 16, 2026
51a40c3
fix: pause group editing after failed data refreshes
web-flow Sep 16, 2026
e43274c
test: cover group refresh recovery and document verification
web-flow Sep 16, 2026
93ebe61
docs: record TypeType's upstream design conventions
web-flow Sep 16, 2026
23386da
refactor: share subscription queries and cache refreshes
web-flow Sep 17, 2026
8de58af
fix: cancel subscription reads and preserve API errors
web-flow Sep 17, 2026
991c863
fix: bound membership writes and target query refreshes
web-flow Sep 17, 2026
3656733
fix: recover subscription filters and empty states
web-flow Sep 17, 2026
855e7a2
fix: add accessible group action menus
web-flow Sep 17, 2026
2ae956b
docs: narrow group management scope after review
web-flow Sep 17, 2026
f14b697
feat: query server pages and bounded membership selections
web-flow Sep 17, 2026
681c587
feat: retain channel selection across server-paginated results
web-flow Sep 17, 2026
ae115de
test: serve paginated subscription membership fixtures
web-flow Sep 17, 2026
d1716f5
chore: remove group management planning documents
web-flow Sep 17, 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
79 changes: 78 additions & 1 deletion apps/web/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
79 changes: 78 additions & 1 deletion apps/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
79 changes: 78 additions & 1 deletion apps/web/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
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, FileUp } from "lucide-react";
import { type DragEvent, useEffect, useMemo, useRef, useState } from "react";
import { usePersistedPortabilityJob } from "../hooks/use-persisted-portability-job";
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -183,6 +186,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" &&

@Priveetee Priveetee Sep 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proposed flow in TypeType #172 was to help organize newly imported channels, not just open the full manager. Is that intentionally deferred? If so, state that explicitly in the PR scope and acceptance criteria; otherwise this integration is incomplete.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, organizing only newly imported channels is intentionally deferred. I have made that explicit in the PR’s scope and acceptance criteria: the current post-import link opens the full manager and does not select or filter an imported cohort. This PR is now described as a partial implementation of #172 and does not close it.

((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
Loading