From 84240b6b5d8af91005335a4f50d21c21679c9c83 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:21:20 +0200 Subject: [PATCH 01/38] feat: add native file dialogs and file system storage - Add storage adapter pattern with build-time provider selection - Implement LocalStorageAdapter for web builds (localStorage) - Implement TauriFileStorageAdapter for desktop builds (plugin-fs) - Implement native file dialogs via @tauri-apps/plugin-dialog - Add dialog and fs Rust plugins to Tauri backend - Update capabilities for dialog and fs permissions - Convert sync service methods to async for Tauri compatibility - Web build continues to use localStorage + blob download - Tauri build uses file system in AppData directory --- package.json | 2 + src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 6 +- src-tauri/src/lib.rs | 2 + src/app/app.config.ts | 16 ++ .../components/add-item/add-item.component.ts | 4 +- .../group-manager/group-manager.component.ts | 20 +-- src/app/components/home/home.component.ts | 16 +- .../item-detail/item-detail.component.ts | 16 +- .../item-list/item-list.component.ts | 8 +- .../components/settings/settings.component.ts | 11 +- src/app/services/group.service.ts | 18 +-- .../services/import-export.adapter.local.ts | 86 ++++++++++ .../services/import-export.adapter.tauri.ts | 93 +++++++++++ src/app/services/import-export.adapter.ts | 9 ++ src/app/services/import-export.service.ts | 118 +++++++++----- src/app/services/storage.adapter.local.ts | 116 +++++++++++++ src/app/services/storage.adapter.tauri.ts | 152 ++++++++++++++++++ src/app/services/storage.adapter.ts | 9 ++ src/app/services/storage.service.ts | 128 ++------------- src/app/services/watch-list.service.ts | 22 +-- src/environments/environment.tauri.ts | 1 + src/environments/environment.ts | 1 + src/environments/environment.web.ts | 1 + 24 files changed, 646 insertions(+), 211 deletions(-) create mode 100644 src/app/services/import-export.adapter.local.ts create mode 100644 src/app/services/import-export.adapter.tauri.ts create mode 100644 src/app/services/import-export.adapter.ts create mode 100644 src/app/services/storage.adapter.local.ts create mode 100644 src/app/services/storage.adapter.tauri.ts create mode 100644 src/app/services/storage.adapter.ts diff --git a/package.json b/package.json index d1076a4..93ee32e 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "@angular/router": "^21.2.6", "@angular/service-worker": "^21.2.6", "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-fs": "^2.4.5", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4300556..3c8ca3e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,3 +23,5 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.10.3", features = [] } tauri-plugin-log = "2" +tauri-plugin-dialog = "2.6.0" +tauri-plugin-fs = "2.4.5" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c135d7f..2125395 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,10 @@ "main" ], "permissions": [ - "core:default" + "core:default", + "dialog:default", + "fs:default", + "fs:allow-app-read-recursive", + "fs:allow-app-write-recursive" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c3118c..76cb1ad 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,8 @@ #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( diff --git a/src/app/app.config.ts b/src/app/app.config.ts index fabd316..399513c 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -4,11 +4,27 @@ import { provideServiceWorker } from '@angular/service-worker'; import { routes } from './app.routes'; import { environment } from '../environments/environment'; +import { STORAGE_ADAPTER, IStorageAdapter } from './services/storage.adapter'; +import { LocalStorageAdapter } from './services/storage.adapter.local'; +import { TauriFileStorageAdapter } from './services/storage.adapter.tauri'; +import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './services/import-export.adapter'; +import { LocalImportExportAdapter } from './services/import-export.adapter.local'; +import { TauriImportExportAdapter } from './services/import-export.adapter.tauri'; + +const storageAdapter: IStorageAdapter = environment.isTauri + ? new TauriFileStorageAdapter() + : new LocalStorageAdapter(); + +const importExportAdapter: IImportExportAdapter = environment.isTauri + ? new TauriImportExportAdapter() + : new LocalImportExportAdapter(); export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), + { provide: STORAGE_ADAPTER, useValue: storageAdapter }, + { provide: IMPORT_EXPORT_ADAPTER, useValue: importExportAdapter }, ...(environment.enableServiceWorker ? [ provideServiceWorker('ngsw-worker.js', { diff --git a/src/app/components/add-item/add-item.component.ts b/src/app/components/add-item/add-item.component.ts index c303615..90dbea2 100644 --- a/src/app/components/add-item/add-item.component.ts +++ b/src/app/components/add-item/add-item.component.ts @@ -225,12 +225,12 @@ export class AddItemComponent implements OnInit { } } - onSubmit(): void { + async onSubmit(): Promise { if (!this.title.trim()) { return; } - this.watchListService.addItem({ + await this.watchListService.addItem({ title: this.title.trim(), type: this.type, groupId: this.groupId, diff --git a/src/app/components/group-manager/group-manager.component.ts b/src/app/components/group-manager/group-manager.component.ts index dda6c51..38ac806 100644 --- a/src/app/components/group-manager/group-manager.component.ts +++ b/src/app/components/group-manager/group-manager.component.ts @@ -295,11 +295,11 @@ export class GroupManagerComponent implements OnInit { return [...this.groups()].sort((a, b) => a.order - b.order); } - createGroup(): void { + async createGroup(): Promise { if (!this.newGroupName.trim()) { return; } - this.groupService.createGroup(this.newGroupName.trim()); + await this.groupService.createGroup(this.newGroupName.trim()); this.newGroupName = ''; this.loadGroups(); } @@ -309,10 +309,10 @@ export class GroupManagerComponent implements OnInit { this.editGroupName = group.name; } - saveEdit(): void { + async saveEdit(): Promise { const group = this.editingGroup(); if (group && this.editGroupName.trim()) { - this.groupService.updateGroup({ + await this.groupService.updateGroup({ ...group, name: this.editGroupName.trim() }); @@ -326,10 +326,10 @@ export class GroupManagerComponent implements OnInit { this.editGroupName = ''; } - deleteGroup(groupId: string): void { + async deleteGroup(groupId: string): Promise { if (confirm('Are you sure you want to delete this group? Items will be moved to "Ungrouped".')) { try { - this.groupService.deleteGroup(groupId); + await this.groupService.deleteGroup(groupId); this.loadGroups(); } catch (error) { alert('Cannot delete the ungrouped group'); @@ -337,24 +337,24 @@ export class GroupManagerComponent implements OnInit { } } - moveUp(groupId: string): void { + async moveUp(groupId: string): Promise { const sorted = this.sortedGroups(); const index = sorted.findIndex(g => g.id === groupId); if (index > 0) { const groupIds = sorted.map(g => g.id); [groupIds[index], groupIds[index - 1]] = [groupIds[index - 1], groupIds[index]]; - this.groupService.reorderGroups(groupIds); + await this.groupService.reorderGroups(groupIds); this.loadGroups(); } } - moveDown(groupId: string): void { + async moveDown(groupId: string): Promise { const sorted = this.sortedGroups(); const index = sorted.findIndex(g => g.id === groupId); if (index < sorted.length - 1) { const groupIds = sorted.map(g => g.id); [groupIds[index], groupIds[index + 1]] = [groupIds[index + 1], groupIds[index]]; - this.groupService.reorderGroups(groupIds); + await this.groupService.reorderGroups(groupIds); this.loadGroups(); } } diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts index de1e8a3..3223af7 100644 --- a/src/app/components/home/home.component.ts +++ b/src/app/components/home/home.component.ts @@ -104,34 +104,34 @@ export class HomeComponent { this.nextMovie.set(this.roundRobinService.getNextMovieToWatch()); } - markSeriesWatched(): void { + async markSeriesWatched(): Promise { const series = this.nextSeries(); if (series) { - this.watchListService.markWatched(series.id); + await this.watchListService.markWatched(series.id); this.updateNextItems(); } } - markSeriesCompleted(): void { + async markSeriesCompleted(): Promise { const series = this.nextSeries(); if (series) { - this.watchListService.markCompleted(series.id); + await this.watchListService.markCompleted(series.id); this.updateNextItems(); } } - markMovieWatched(): void { + async markMovieWatched(): Promise { const movie = this.nextMovie(); if (movie) { - this.watchListService.markWatched(movie.id); + await this.watchListService.markWatched(movie.id); this.updateNextItems(); } } - markMovieCompleted(): void { + async markMovieCompleted(): Promise { const movie = this.nextMovie(); if (movie) { - this.watchListService.markCompleted(movie.id); + await this.watchListService.markCompleted(movie.id); this.updateNextItems(); } } diff --git a/src/app/components/item-detail/item-detail.component.ts b/src/app/components/item-detail/item-detail.component.ts index 4d36e5c..041d3d3 100644 --- a/src/app/components/item-detail/item-detail.component.ts +++ b/src/app/components/item-detail/item-detail.component.ts @@ -385,7 +385,7 @@ export class ItemDetailComponent implements OnInit { } } - saveChanges(): void { + async saveChanges(): Promise { const currentItem = this.item(); if (!currentItem) return; if (!this.editTitle.trim()) return; @@ -402,7 +402,7 @@ export class ItemDetailComponent implements OnInit { } : undefined }; - this.watchListService.updateItem(updated); + await this.watchListService.updateItem(updated); this.router.navigate(['/items']); } @@ -414,10 +414,10 @@ export class ItemDetailComponent implements OnInit { this.confirmDelete.set(false); } - markWatched(): void { + async markWatched(): Promise { const currentItem = this.item(); if (currentItem) { - this.watchListService.markWatched(currentItem.id); + await this.watchListService.markWatched(currentItem.id); const updated = this.watchListService.getItemById(currentItem.id); if (updated) { this.item.set(updated); @@ -426,10 +426,10 @@ export class ItemDetailComponent implements OnInit { } } - markCompleted(): void { + async markCompleted(): Promise { const currentItem = this.item(); if (currentItem) { - this.watchListService.markCompleted(currentItem.id); + await this.watchListService.markCompleted(currentItem.id); const updated = this.watchListService.getItemById(currentItem.id); if (updated) { this.item.set(updated); @@ -438,10 +438,10 @@ export class ItemDetailComponent implements OnInit { } } - deleteItem(): void { + async deleteItem(): Promise { const currentItem = this.item(); if (currentItem) { - this.watchListService.deleteItem(currentItem.id); + await this.watchListService.deleteItem(currentItem.id); this.router.navigate(['/items']); } } diff --git a/src/app/components/item-list/item-list.component.ts b/src/app/components/item-list/item-list.component.ts index 4e5cc2c..ddecc82 100644 --- a/src/app/components/item-list/item-list.component.ts +++ b/src/app/components/item-list/item-list.component.ts @@ -247,12 +247,12 @@ export class ItemListComponent { return this.filteredItems().filter(item => item.groupId === groupId); } - markWatched(itemId: string): void { - this.watchListService.markWatched(itemId); + async markWatched(itemId: string): Promise { + await this.watchListService.markWatched(itemId); } - markCompleted(itemId: string): void { - this.watchListService.markCompleted(itemId); + async markCompleted(itemId: string): Promise { + await this.watchListService.markCompleted(itemId); } } diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 23aaf1b..c85a575 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -185,9 +185,9 @@ export class SettingsComponent implements OnInit { setTimeout(() => this.successMessage.set(null), 3000); } - exportData(): void { + async exportData(): Promise { try { - this.importExportService.exportData(); + await this.importExportService.exportData(); this.successMessage.set('Data exported successfully'); setTimeout(() => this.successMessage.set(null), 3000); } catch (error) { @@ -198,12 +198,7 @@ export class SettingsComponent implements OnInit { async onFileSelected(event: Event): Promise { const input = event.target as HTMLInputElement; - const file = input.files?.[0]; - if (!file) { - return; - } - if (!confirm('Importing will replace all existing data. Are you sure?')) { input.value = ''; return; @@ -213,7 +208,7 @@ export class SettingsComponent implements OnInit { this.successMessage.set(null); try { - await this.importExportService.importData(file); + await this.importExportService.importData(); this.successMessage.set('Data imported successfully'); setTimeout(() => this.successMessage.set(null), 3000); input.value = ''; diff --git a/src/app/services/group.service.ts b/src/app/services/group.service.ts index 035cb89..f78d449 100644 --- a/src/app/services/group.service.ts +++ b/src/app/services/group.service.ts @@ -12,7 +12,7 @@ export class GroupService { private watchListService: WatchListService ) {} - createGroup(name: string): Group { + async createGroup(name: string): Promise { const data = this.storageService.getData(); const groups = this.storageService.getGroups(); const maxOrder = groups.length > 0 ? Math.max(...groups.map(g => g.order)) : -1; @@ -24,7 +24,7 @@ export class GroupService { order: maxOrder + 1 }; - this.storageService.saveData({ + await this.storageService.saveData({ ...data, groups: { ...data.groups, @@ -35,9 +35,9 @@ export class GroupService { return newGroup; } - updateGroup(group: Group): void { + async updateGroup(group: Group): Promise { const data = this.storageService.getData(); - this.storageService.saveData({ + await this.storageService.saveData({ ...data, groups: { ...data.groups, @@ -46,7 +46,7 @@ export class GroupService { }); } - deleteGroup(groupId: string): void { + async deleteGroup(groupId: string): Promise { if (groupId === 'ungrouped') { throw new Error('Cannot delete the ungrouped group'); } @@ -54,7 +54,6 @@ export class GroupService { const data = this.storageService.getData(); const { [groupId]: removed, ...groups } = data.groups; - // Move all items from this group to ungrouped const items = { ...data.items }; Object.values(items).forEach(item => { if (item.groupId === groupId) { @@ -65,14 +64,14 @@ export class GroupService { } }); - this.storageService.saveData({ + await this.storageService.saveData({ ...data, groups, items }); } - reorderGroups(groupIds: string[]): void { + async reorderGroups(groupIds: string[]): Promise { const data = this.storageService.getData(); const groups: Record = {}; @@ -86,14 +85,13 @@ export class GroupService { } }); - // Keep any groups not in the reorder list Object.values(data.groups).forEach(group => { if (!groups[group.id]) { groups[group.id] = group; } }); - this.storageService.saveData({ + await this.storageService.saveData({ ...data, groups }); diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts new file mode 100644 index 0000000..b859106 --- /dev/null +++ b/src/app/services/import-export.adapter.local.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@angular/core'; +import { IImportExportAdapter } from './import-export.adapter'; +import { StorageData } from '../models/storage.model'; + +@Injectable({ + providedIn: 'root' +}) +export class LocalImportExportAdapter implements IImportExportAdapter { + exportData(data: StorageData): void { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `watch-list-export-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + + async importData(): Promise<{ file: File; data: StorageData } | null> { + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) { + resolve(null); + return; + } + + try { + const text = await file.text(); + const parsed = JSON.parse(text); + + if (!this.validateStorageDataStructure(parsed)) { + throw new Error('Invalid data format'); + } + + resolve({ file, data: parsed as StorageData }); + } catch (error) { + console.error('Import error:', error); + resolve(null); + } + }; + + input.click(); + }); + } + + private validateStorageDataStructure(data: unknown): boolean { + if (!data || typeof data !== 'object') { + return false; + } + + const d = data as Record; + + if ( + typeof d['schemaVersion'] !== 'number' || + typeof d['lastModifiedAt'] !== 'string' || + !d['settings'] || + !d['groups'] || + !d['items'] + ) { + return false; + } + + const settings = d['settings'] as Record; + if (typeof settings['showCompleted'] !== 'boolean') { + return false; + } + + if (typeof d['groups'] !== 'object' || Array.isArray(d['groups'])) { + return false; + } + + if (typeof d['items'] !== 'object' || Array.isArray(d['items'])) { + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/src/app/services/import-export.adapter.tauri.ts b/src/app/services/import-export.adapter.tauri.ts new file mode 100644 index 0000000..1ddd9d5 --- /dev/null +++ b/src/app/services/import-export.adapter.tauri.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@angular/core'; +import { IImportExportAdapter } from './import-export.adapter'; +import { StorageData } from '../models/storage.model'; +import { save, open } from '@tauri-apps/plugin-dialog'; + +@Injectable({ + providedIn: 'root' +}) +export class TauriImportExportAdapter implements IImportExportAdapter { + async exportData(data: StorageData): Promise { + const json = JSON.stringify(data, null, 2); + + const filePath = await save({ + defaultPath: `watch-list-export-${new Date().toISOString().split('T')[0]}.json`, + filters: [{ + name: 'JSON', + extensions: ['json'] + }] + }); + + if (!filePath) { + return; + } + + const { writeTextFile } = await import('@tauri-apps/plugin-fs'); + await writeTextFile(filePath, json); + } + + async importData(): Promise<{ file: File; data: StorageData } | null> { + const selected = await open({ + multiple: false, + filters: [{ + name: 'JSON', + extensions: ['json'] + }] + }); + + if (!selected || typeof selected !== 'string') { + return null; + } + + try { + const { readTextFile } = await import('@tauri-apps/plugin-fs'); + const content = await readTextFile(selected); + const parsed = JSON.parse(content); + + if (!this.validateStorageDataStructure(parsed)) { + throw new Error('Invalid data format'); + } + + const fileName = selected.split(/[/\\]/).pop() || 'import.json'; + const file = new File([content], fileName, { type: 'application/json' }); + + return { file, data: parsed as StorageData }; + } catch (error) { + console.error('Import error:', error); + return null; + } + } + + private validateStorageDataStructure(data: unknown): boolean { + if (!data || typeof data !== 'object') { + return false; + } + + const d = data as Record; + + if ( + typeof d['schemaVersion'] !== 'number' || + typeof d['lastModifiedAt'] !== 'string' || + !d['settings'] || + !d['groups'] || + !d['items'] + ) { + return false; + } + + const settings = d['settings'] as Record; + if (typeof settings['showCompleted'] !== 'boolean') { + return false; + } + + if (typeof d['groups'] !== 'object' || Array.isArray(d['groups'])) { + return false; + } + + if (typeof d['items'] !== 'object' || Array.isArray(d['items'])) { + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/src/app/services/import-export.adapter.ts b/src/app/services/import-export.adapter.ts new file mode 100644 index 0000000..ac6111e --- /dev/null +++ b/src/app/services/import-export.adapter.ts @@ -0,0 +1,9 @@ +import { InjectionToken } from '@angular/core'; +import { StorageData } from '../models/storage.model'; + +export interface IImportExportAdapter { + exportData(data: StorageData): void | Promise; + importData(): Promise<{ file: File; data: StorageData } | null>; +} + +export const IMPORT_EXPORT_ADAPTER = new InjectionToken('IMPORT_EXPORT_ADAPTER'); \ No newline at end of file diff --git a/src/app/services/import-export.service.ts b/src/app/services/import-export.service.ts index 9e57037..fe8c00e 100644 --- a/src/app/services/import-export.service.ts +++ b/src/app/services/import-export.service.ts @@ -1,49 +1,96 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { StorageService } from './storage.service'; -import { StorageData } from '../models/storage.model'; +import { StorageData, CURRENT_SCHEMA_VERSION } from '../models/storage.model'; +import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './import-export.adapter'; +import { Item } from '../models/item.model'; @Injectable({ providedIn: 'root' }) export class ImportExportService { - constructor(private storageService: StorageService) {} + private readonly adapter = inject(IMPORT_EXPORT_ADAPTER); + private readonly storageService = inject(StorageService); - exportData(): void { + async exportData(): Promise { const data = this.storageService.getData(); - const json = JSON.stringify(data, null, 2); - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `watch-list-export-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + await this.adapter.exportData(data); } - async importData(file: File): Promise { - try { - const text = await file.text(); - const parsed = JSON.parse(text); - - if (!this.validateStorageDataStructure(parsed)) { - throw new Error('Invalid data format'); - } + async importData(): Promise { + const result = await this.adapter.importData(); + + if (!result) { + return; + } - const migrated = this.storageService.migrateDataOnly(parsed as StorageData); - this.storageService.ensureUngroupedGroup(migrated); - - if (!this.validateMigratedData(migrated)) { - throw new Error('Invalid migrated data'); - } + const { data } = result; + + if (!this.validateStorageDataStructure(data)) { + throw new Error('Invalid data format'); + } - this.storageService.saveData(migrated); - } catch (error) { - if (error instanceof SyntaxError) { - throw new Error('Invalid JSON file'); - } - throw error; + const migrated = this.migrateDataOnly(data); + this.ensureUngroupedGroup(migrated); + + if (!this.validateMigratedData(migrated)) { + throw new Error('Invalid migrated data'); + } + + await this.storageService.saveData(migrated); + } + + private migrateDataOnly(data: StorageData): StorageData { + if (data.schemaVersion >= CURRENT_SCHEMA_VERSION) { + return data; + } + + let migrated = { ...data }; + + if (migrated.schemaVersion < 2) { + migrated.items = Object.fromEntries( + Object.entries(migrated.items).map(([id, item]) => { + const legacyItem = item as Item & { + lastWatchedAt?: string; + watchHistory?: unknown[]; + progress?: { season: number; episode: number; totalEpisodes?: number }; + }; + let watchHistory = legacyItem.watchHistory as any[] || []; + + let adjustedProgress = legacyItem.progress; + if (adjustedProgress && legacyItem.status !== 'completed') { + adjustedProgress = { + ...adjustedProgress, + episode: adjustedProgress.episode + 1 + }; + } + + if (watchHistory.length === 0 && + (legacyItem.status === 'in-progress' || legacyItem.lastWatchedAt !== legacyItem.createdAt)) { + const entry: any = { date: legacyItem.lastWatchedAt }; + if (legacyItem.type === 'series' && legacyItem.progress) { + entry.season = legacyItem.progress.season; + entry.episode = legacyItem.progress.episode; + } + watchHistory = [entry]; + } + + const { lastWatchedAt, ...itemWithoutLastWatched } = legacyItem; + return [id, { ...itemWithoutLastWatched, watchHistory, progress: adjustedProgress }]; + }) + ); + migrated.schemaVersion = 2; + } + + return migrated; + } + + private ensureUngroupedGroup(data: StorageData): void { + if (!data.groups['ungrouped']) { + data.groups['ungrouped'] = { + id: 'ungrouped', + name: 'Ungrouped', + order: 0 + }; } } @@ -155,5 +202,4 @@ export class ImportExportService { const e = entry as Record; return typeof e['date'] === 'string'; } -} - +} \ No newline at end of file diff --git a/src/app/services/storage.adapter.local.ts b/src/app/services/storage.adapter.local.ts new file mode 100644 index 0000000..33b7a43 --- /dev/null +++ b/src/app/services/storage.adapter.local.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@angular/core'; +import { IStorageAdapter } from './storage.adapter'; +import { StorageData, CURRENT_SCHEMA_VERSION } from '../models/storage.model'; +import { Item } from '../models/item.model'; +import { Group } from '../models/group.model'; + +const STORAGE_KEY = 'watchListData'; + +@Injectable({ + providedIn: 'root' +}) +export class LocalStorageAdapter implements IStorageAdapter { + load(): StorageData { + const stored = localStorage.getItem(STORAGE_KEY); + + if (stored) { + try { + const parsed = JSON.parse(stored) as StorageData; + const migrated = this.migrateDataOnly(parsed); + this.ensureUngroupedGroup(migrated); + localStorage.setItem(STORAGE_KEY, JSON.stringify(migrated)); + return migrated; + } catch (error) { + console.error('Failed to parse stored data:', error); + return this.createDefaultData(); + } + } + + const defaultData = this.createDefaultData(); + this.save(defaultData); + return defaultData; + } + + save(data: StorageData): void { + const updated: StorageData = { + ...data, + lastModifiedAt: new Date().toISOString() + }; + this.ensureUngroupedGroup(updated); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } + + private migrateDataOnly(data: StorageData): StorageData { + if (data.schemaVersion >= CURRENT_SCHEMA_VERSION) { + return data; + } + + let migrated = { ...data }; + + if (migrated.schemaVersion < 2) { + migrated.items = Object.fromEntries( + Object.entries(migrated.items).map(([id, item]) => { + const legacyItem = item as Item & { + lastWatchedAt?: string; + watchHistory?: unknown[]; + progress?: { season: number; episode: number; totalEpisodes?: number }; + }; + let watchHistory = legacyItem.watchHistory as any[] || []; + + let adjustedProgress = legacyItem.progress; + if (adjustedProgress && legacyItem.status !== 'completed') { + adjustedProgress = { + ...adjustedProgress, + episode: adjustedProgress.episode + 1 + }; + } + + if (watchHistory.length === 0 && + (legacyItem.status === 'in-progress' || legacyItem.lastWatchedAt !== legacyItem.createdAt)) { + const entry: any = { date: legacyItem.lastWatchedAt }; + if (legacyItem.type === 'series' && legacyItem.progress) { + entry.season = legacyItem.progress.season; + entry.episode = legacyItem.progress.episode; + } + watchHistory = [entry]; + } + + const { lastWatchedAt, ...itemWithoutLastWatched } = legacyItem; + return [id, { ...itemWithoutLastWatched, watchHistory, progress: adjustedProgress }]; + }) + ); + migrated.schemaVersion = 2; + } + + return migrated; + } + + private createDefaultData(): StorageData { + const now = new Date().toISOString(); + return { + schemaVersion: CURRENT_SCHEMA_VERSION, + lastModifiedAt: now, + settings: { + showCompleted: false + }, + groups: { + ungrouped: { + id: 'ungrouped', + name: 'Ungrouped', + order: 0 + } + }, + items: {} + }; + } + + private ensureUngroupedGroup(data: StorageData): void { + if (!data.groups['ungrouped']) { + data.groups['ungrouped'] = { + id: 'ungrouped', + name: 'Ungrouped', + order: 0 + }; + } + } +} \ No newline at end of file diff --git a/src/app/services/storage.adapter.tauri.ts b/src/app/services/storage.adapter.tauri.ts new file mode 100644 index 0000000..7f8b82f --- /dev/null +++ b/src/app/services/storage.adapter.tauri.ts @@ -0,0 +1,152 @@ +import { Injectable } from '@angular/core'; +import { IStorageAdapter } from './storage.adapter'; +import { StorageData, CURRENT_SCHEMA_VERSION } from '../models/storage.model'; +import { Item } from '../models/item.model'; +import { Group } from '../models/group.model'; +import { BaseDirectory, exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'; + +const STORAGE_DIR = 'watch-list'; +const STORAGE_FILE = 'data.json'; + +@Injectable({ + providedIn: 'root' +}) +export class TauriFileStorageAdapter implements IStorageAdapter { + private cache: StorageData | null = null; + + private async ensureDir(): Promise { + const dirExists = await exists(STORAGE_DIR, { baseDir: BaseDirectory.AppData }); + if (!dirExists) { + await mkdir(STORAGE_DIR, { baseDir: BaseDirectory.AppData, recursive: true }); + } + } + + async load(): Promise { + try { + await this.ensureDir(); + const path = `${STORAGE_DIR}/${STORAGE_FILE}`; + const fileExists = await exists(path, { baseDir: BaseDirectory.AppData }); + + if (!fileExists) { + const defaultData = this.createDefaultData(); + await this.save(defaultData); + return defaultData; + } + + const content = await readTextFile(path, { baseDir: BaseDirectory.AppData }); + const parsed = JSON.parse(content) as StorageData; + + const migrated = this.migrateDataOnly(parsed); + this.ensureUngroupedGroup(migrated); + + this.cache = migrated; + await this.save(migrated); + + return migrated; + } catch (error) { + console.error('Failed to load file data:', error); + return this.createDefaultData(); + } + } + + async save(data: StorageData): Promise { + try { + await this.ensureDir(); + + const updated: StorageData = { + ...data, + lastModifiedAt: new Date().toISOString() + }; + this.ensureUngroupedGroup(updated); + + const path = `${STORAGE_DIR}/${STORAGE_FILE}`; + await writeTextFile(path, JSON.stringify(updated, null, 2), { baseDir: BaseDirectory.AppData }); + + this.cache = updated; + } catch (error) { + console.error('Failed to save file data:', error); + throw error; + } + } + + loadSync(): StorageData { + if (this.cache) { + return this.cache; + } + throw new Error('Data not loaded. Call load() first.'); + } + + private migrateDataOnly(data: StorageData): StorageData { + if (data.schemaVersion >= CURRENT_SCHEMA_VERSION) { + return data; + } + + let migrated = { ...data }; + + if (migrated.schemaVersion < 2) { + migrated.items = Object.fromEntries( + Object.entries(migrated.items).map(([id, item]) => { + const legacyItem = item as Item & { + lastWatchedAt?: string; + watchHistory?: unknown[]; + progress?: { season: number; episode: number; totalEpisodes?: number }; + }; + let watchHistory = legacyItem.watchHistory as any[] || []; + + let adjustedProgress = legacyItem.progress; + if (adjustedProgress && legacyItem.status !== 'completed') { + adjustedProgress = { + ...adjustedProgress, + episode: adjustedProgress.episode + 1 + }; + } + + if (watchHistory.length === 0 && + (legacyItem.status === 'in-progress' || legacyItem.lastWatchedAt !== legacyItem.createdAt)) { + const entry: any = { date: legacyItem.lastWatchedAt }; + if (legacyItem.type === 'series' && legacyItem.progress) { + entry.season = legacyItem.progress.season; + entry.episode = legacyItem.progress.episode; + } + watchHistory = [entry]; + } + + const { lastWatchedAt, ...itemWithoutLastWatched } = legacyItem; + return [id, { ...itemWithoutLastWatched, watchHistory, progress: adjustedProgress }]; + }) + ); + migrated.schemaVersion = 2; + } + + return migrated; + } + + private createDefaultData(): StorageData { + const now = new Date().toISOString(); + return { + schemaVersion: CURRENT_SCHEMA_VERSION, + lastModifiedAt: now, + settings: { + showCompleted: false + }, + groups: { + ungrouped: { + id: 'ungrouped', + name: 'Ungrouped', + order: 0 + } + }, + items: {} + }; + } + + private ensureUngroupedGroup(data: StorageData): void { + if (!data.groups['ungrouped']) { + data.groups['ungrouped'] = { + id: 'ungrouped', + name: 'Ungrouped', + order: 0 + }; + } + } +} \ No newline at end of file diff --git a/src/app/services/storage.adapter.ts b/src/app/services/storage.adapter.ts new file mode 100644 index 0000000..cf6fd36 --- /dev/null +++ b/src/app/services/storage.adapter.ts @@ -0,0 +1,9 @@ +import { InjectionToken } from '@angular/core'; +import { StorageData } from '../models/storage.model'; + +export interface IStorageAdapter { + load(): StorageData | Promise; + save(data: StorageData): void | Promise; +} + +export const STORAGE_ADAPTER = new InjectionToken('STORAGE_ADAPTER'); \ No newline at end of file diff --git a/src/app/services/storage.service.ts b/src/app/services/storage.service.ts index 869230a..4820228 100644 --- a/src/app/services/storage.service.ts +++ b/src/app/services/storage.service.ts @@ -1,97 +1,29 @@ -import { Injectable, signal } from '@angular/core'; -import { StorageData, Settings, CURRENT_SCHEMA_VERSION } from '../models/storage.model'; +import { Injectable, signal, inject } from '@angular/core'; +import { StorageData, Settings } from '../models/storage.model'; import { Item } from '../models/item.model'; import { Group } from '../models/group.model'; - -const STORAGE_KEY = 'watchListData'; -const DEFAULT_SCHEMA_VERSION = CURRENT_SCHEMA_VERSION; +import { STORAGE_ADAPTER, IStorageAdapter } from './storage.adapter'; @Injectable({ providedIn: 'root' }) export class StorageService { + private readonly adapter = inject(STORAGE_ADAPTER); private readonly data = signal(null); constructor() { this.loadData(); } - loadData(): StorageData { - const stored = localStorage.getItem(STORAGE_KEY); - - if (stored) { - try { - const parsed = JSON.parse(stored) as StorageData; - const migrated = this.migrateDataOnly(parsed); - this.ensureUngroupedGroup(migrated); - this.data.set(migrated); - localStorage.setItem(STORAGE_KEY, JSON.stringify(migrated)); - return migrated; - } catch (error) { - console.error('Failed to parse stored data:', error); - return this.createDefaultData(); - } - } - - const defaultData = this.createDefaultData(); - this.saveData(defaultData); - return defaultData; - } - - migrateDataOnly(data: StorageData): StorageData { - if (data.schemaVersion >= CURRENT_SCHEMA_VERSION) { - return data; - } - - let migrated = { ...data }; - - if (migrated.schemaVersion < 2) { - migrated.items = Object.fromEntries( - Object.entries(migrated.items).map(([id, item]) => { - const legacyItem = item as Item & { - lastWatchedAt?: string; - watchHistory?: unknown[]; - progress?: { season: number; episode: number; totalEpisodes?: number }; - }; - let watchHistory = legacyItem.watchHistory as any[] || []; - - // Adjust episode numbers from 0-based to 1-based for non-completed items - let adjustedProgress = legacyItem.progress; - if (adjustedProgress && legacyItem.status !== 'completed') { - adjustedProgress = { - ...adjustedProgress, - episode: adjustedProgress.episode + 1 - }; - } - - if (watchHistory.length === 0 && - (legacyItem.status === 'in-progress' || legacyItem.lastWatchedAt !== legacyItem.createdAt)) { - const entry: any = { date: legacyItem.lastWatchedAt }; - if (legacyItem.type === 'series' && legacyItem.progress) { - entry.season = legacyItem.progress.season; - entry.episode = legacyItem.progress.episode; - } - watchHistory = [entry]; - } - - const { lastWatchedAt, ...itemWithoutLastWatched } = legacyItem; - return [id, { ...itemWithoutLastWatched, watchHistory, progress: adjustedProgress }]; - }) - ); - migrated.schemaVersion = 2; - } - - return migrated; + async loadData(): Promise { + const loaded = await this.adapter.load(); + this.data.set(loaded); + return loaded; } - saveData(data: StorageData): void { - const updated: StorageData = { - ...data, - lastModifiedAt: new Date().toISOString() - }; - this.ensureUngroupedGroup(updated); - this.data.set(updated); - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + async saveData(data: StorageData): Promise { + await this.adapter.save(data); + this.data.set(data); } getData(): StorageData { @@ -99,7 +31,7 @@ export class StorageService { if (current) { return current; } - return this.loadData(); + throw new Error('Data not loaded. Call loadData() first.'); } getDataSignal() { @@ -121,41 +53,11 @@ export class StorageService { return data.settings; } - updateSettings(settings: Partial): void { + async updateSettings(settings: Partial): Promise { const data = this.getData(); - this.saveData({ + await this.saveData({ ...data, settings: { ...data.settings, ...settings } }); } - - private createDefaultData(): StorageData { - const now = new Date().toISOString(); - return { - schemaVersion: DEFAULT_SCHEMA_VERSION, - lastModifiedAt: now, - settings: { - showCompleted: false - }, - groups: { - ungrouped: { - id: 'ungrouped', - name: 'Ungrouped', - order: 0 - } - }, - items: {} - }; - } - - ensureUngroupedGroup(data: StorageData): void { - if (!data.groups['ungrouped']) { - data.groups['ungrouped'] = { - id: 'ungrouped', - name: 'Ungrouped', - order: 0 - }; - } - } -} - +} \ No newline at end of file diff --git a/src/app/services/watch-list.service.ts b/src/app/services/watch-list.service.ts index 2459814..1475246 100644 --- a/src/app/services/watch-list.service.ts +++ b/src/app/services/watch-list.service.ts @@ -15,7 +15,7 @@ export interface HistoryEntry extends WatchHistoryEntry { export class WatchListService { constructor(private storageService: StorageService) {} - addItem(item: Omit): void { + async addItem(item: Omit): Promise { const data = this.storageService.getData(); const id = this.generateId(); const now = new Date().toISOString(); @@ -28,7 +28,7 @@ export class WatchListService { watchHistory: [] }; - this.storageService.saveData({ + await this.storageService.saveData({ ...data, items: { ...data.items, @@ -37,9 +37,9 @@ export class WatchListService { }); } - updateItem(item: Item): void { + async updateItem(item: Item): Promise { const data = this.storageService.getData(); - this.storageService.saveData({ + await this.storageService.saveData({ ...data, items: { ...data.items, @@ -48,23 +48,23 @@ export class WatchListService { }); } - deleteItem(itemId: string): void { + async deleteItem(itemId: string): Promise { const data = this.storageService.getData(); const { [itemId]: removed, ...items } = data.items; - this.storageService.saveData({ + await this.storageService.saveData({ ...data, items }); } - markWatched(itemId: string): void { + async markWatched(itemId: string): Promise { const item = this.getItemById(itemId); if (!item) return; const now = new Date().toISOString(); if (item.type === 'movie') { - this.updateItem({ + await this.updateItem({ ...item, status: 'completed', watchHistory: [...item.watchHistory, { date: now }] @@ -93,7 +93,7 @@ export class WatchListService { newStatus = 'in-progress'; } - this.updateItem({ + await this.updateItem({ ...item, status: newStatus, progress: newProgress, @@ -106,11 +106,11 @@ export class WatchListService { } } - markCompleted(itemId: string): void { + async markCompleted(itemId: string): Promise { const item = this.getItemById(itemId); if (!item) return; - this.updateItem({ + await this.updateItem({ ...item, status: 'completed' }); diff --git a/src/environments/environment.tauri.ts b/src/environments/environment.tauri.ts index a1c2c55..70ccef4 100644 --- a/src/environments/environment.tauri.ts +++ b/src/environments/environment.tauri.ts @@ -1,3 +1,4 @@ export const environment = { + isTauri: true, enableServiceWorker: false }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index a1c2c55..3a3dc3d 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,3 +1,4 @@ export const environment = { + isTauri: false, enableServiceWorker: false }; diff --git a/src/environments/environment.web.ts b/src/environments/environment.web.ts index 43ed7cd..08dfc0a 100644 --- a/src/environments/environment.web.ts +++ b/src/environments/environment.web.ts @@ -1,3 +1,4 @@ export const environment = { + isTauri: false, enableServiceWorker: true }; From 9c16508b9eea3f41894b4354042a91909b2f2127 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:21:24 +0200 Subject: [PATCH 02/38] chore: update lock files --- bun.lock | 6 ++++ src-tauri/Cargo.lock | 66 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/bun.lock b/bun.lock index dedaaeb..68dba9c 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,8 @@ "@angular/router": "^21.2.6", "@angular/service-worker": "^21.2.6", "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-fs": "^2.4.5", "rxjs": "~7.8.0", "tslib": "^2.3.0", }, @@ -488,6 +490,10 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="], + + "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.5", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA=="], + "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 29cbb39..757b40d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2870,6 +2870,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rkyv" version = "0.7.46" @@ -3652,6 +3676,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-log" version = "2.8.0" @@ -4445,6 +4509,8 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", "tauri-plugin-log", ] From c5feaeff6c2d1b5b28535a866c3a2d2c8d776bc8 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:08:21 +0200 Subject: [PATCH 03/38] fix: use APP_INITIALIZER to load storage before app bootstrap The constructor was calling async loadData() without awaiting, causing data to be null when components tried to access it during initialization. Using APP_INITIALIZER ensures storage is loaded before any component is created, providing a clean Angular pattern for initialization. --- src/app/app.config.ts | 9 ++++++++- src/app/services/storage.service.ts | 4 ---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 399513c..cf50849 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,4 +1,4 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners, APP_INITIALIZER } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; @@ -10,6 +10,7 @@ import { TauriFileStorageAdapter } from './services/storage.adapter.tauri'; import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './services/import-export.adapter'; import { LocalImportExportAdapter } from './services/import-export.adapter.local'; import { TauriImportExportAdapter } from './services/import-export.adapter.tauri'; +import { StorageService } from './services/storage.service'; const storageAdapter: IStorageAdapter = environment.isTauri ? new TauriFileStorageAdapter() @@ -23,6 +24,12 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), + { + provide: APP_INITIALIZER, + useFactory: (storageService: StorageService) => () => storageService.loadData(), + deps: [StorageService], + multi: true + }, { provide: STORAGE_ADAPTER, useValue: storageAdapter }, { provide: IMPORT_EXPORT_ADAPTER, useValue: importExportAdapter }, ...(environment.enableServiceWorker diff --git a/src/app/services/storage.service.ts b/src/app/services/storage.service.ts index 4820228..2bb6721 100644 --- a/src/app/services/storage.service.ts +++ b/src/app/services/storage.service.ts @@ -11,10 +11,6 @@ export class StorageService { private readonly adapter = inject(STORAGE_ADAPTER); private readonly data = signal(null); - constructor() { - this.loadData(); - } - async loadData(): Promise { const loaded = await this.adapter.load(); this.data.set(loaded); From 9e216257e47055e16b769b99c39b0156fa8ab106 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:55:26 +0200 Subject: [PATCH 04/38] fix: remove duplicate file dialog in settings component The hidden file input was causing a second dialog to open because the adapter now handles its own file picking. Direct button click to importData() now delegates to the adapter. --- .../components/settings/settings.component.ts | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index c85a575..d914fed 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -36,18 +36,9 @@ import { Settings } from '../../models/storage.model';

Download all your watch list data as a JSON file

- +

Replace all data with imported JSON file

@@ -196,11 +187,8 @@ export class SettingsComponent implements OnInit { } } - async onFileSelected(event: Event): Promise { - const input = event.target as HTMLInputElement; - + async importData(): Promise { if (!confirm('Importing will replace all existing data. Are you sure?')) { - input.value = ''; return; } @@ -211,12 +199,10 @@ export class SettingsComponent implements OnInit { await this.importExportService.importData(); this.successMessage.set('Data imported successfully'); setTimeout(() => this.successMessage.set(null), 3000); - input.value = ''; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to import data'; this.errorMessage.set(`Import failed: ${message}`); setTimeout(() => this.errorMessage.set(null), 5000); - input.value = ''; } } } From eeeb2f71de68fa338c816daff1b6e068fa74e085 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:00:31 +0200 Subject: [PATCH 05/38] fix: move confirmation dialog to adapter layer The confirmation was being shown at the same time as file dialog because it was in the component while the adapter also opened a dialog. Now the confirm dialog is shown in the adapter before the file picker. --- src/app/components/settings/settings.component.ts | 4 ---- src/app/services/import-export.adapter.local.ts | 4 ++++ src/app/services/import-export.adapter.tauri.ts | 4 ++++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index d914fed..9a9c9da 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -188,10 +188,6 @@ export class SettingsComponent implements OnInit { } async importData(): Promise { - if (!confirm('Importing will replace all existing data. Are you sure?')) { - return; - } - this.errorMessage.set(null); this.successMessage.set(null); diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts index b859106..aad1ee7 100644 --- a/src/app/services/import-export.adapter.local.ts +++ b/src/app/services/import-export.adapter.local.ts @@ -20,6 +20,10 @@ export class LocalImportExportAdapter implements IImportExportAdapter { } async importData(): Promise<{ file: File; data: StorageData } | null> { + if (!confirm('Importing will replace all existing data. Are you sure?')) { + return null; + } + return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; diff --git a/src/app/services/import-export.adapter.tauri.ts b/src/app/services/import-export.adapter.tauri.ts index 1ddd9d5..ad2dc30 100644 --- a/src/app/services/import-export.adapter.tauri.ts +++ b/src/app/services/import-export.adapter.tauri.ts @@ -27,6 +27,10 @@ export class TauriImportExportAdapter implements IImportExportAdapter { } async importData(): Promise<{ file: File; data: StorageData } | null> { + if (!confirm('Importing will replace all existing data. Are you sure?')) { + return null; + } + const selected = await open({ multiple: false, filters: [{ From fac65839350e2b0ae7a53956a76746bd6ac7d5d3 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:23:04 +0200 Subject: [PATCH 06/38] fix: move confirm to after file selection in service layer Now the flow is: select file -> confirm -> import The confirm is shown in the service after the adapter returns the data, not before the file picker opens. --- src/app/services/import-export.adapter.local.ts | 4 ---- src/app/services/import-export.adapter.tauri.ts | 4 ---- src/app/services/import-export.service.ts | 4 ++++ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts index aad1ee7..b859106 100644 --- a/src/app/services/import-export.adapter.local.ts +++ b/src/app/services/import-export.adapter.local.ts @@ -20,10 +20,6 @@ export class LocalImportExportAdapter implements IImportExportAdapter { } async importData(): Promise<{ file: File; data: StorageData } | null> { - if (!confirm('Importing will replace all existing data. Are you sure?')) { - return null; - } - return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; diff --git a/src/app/services/import-export.adapter.tauri.ts b/src/app/services/import-export.adapter.tauri.ts index ad2dc30..1ddd9d5 100644 --- a/src/app/services/import-export.adapter.tauri.ts +++ b/src/app/services/import-export.adapter.tauri.ts @@ -27,10 +27,6 @@ export class TauriImportExportAdapter implements IImportExportAdapter { } async importData(): Promise<{ file: File; data: StorageData } | null> { - if (!confirm('Importing will replace all existing data. Are you sure?')) { - return null; - } - const selected = await open({ multiple: false, filters: [{ diff --git a/src/app/services/import-export.service.ts b/src/app/services/import-export.service.ts index fe8c00e..a572b09 100644 --- a/src/app/services/import-export.service.ts +++ b/src/app/services/import-export.service.ts @@ -29,6 +29,10 @@ export class ImportExportService { throw new Error('Invalid data format'); } + if (!confirm('Importing will replace all existing data. Are you sure?')) { + return; + } + const migrated = this.migrateDataOnly(data); this.ensureUngroupedGroup(migrated); From 92a64d6dbb02eb325ba9d6b27f0f14deff981237 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:14:14 +0200 Subject: [PATCH 07/38] fix: await async methods and add delay for confirm - await updateSettings() in settings component to handle rejections - add setTimeout delay after confirm to ensure dialog is processed --- src/app/components/settings/settings.component.ts | 4 ++-- src/app/services/import-export.service.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 9a9c9da..2f73782 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -170,8 +170,8 @@ export class SettingsComponent implements OnInit { this.showCompleted = settings.showCompleted; } - updateShowCompleted(): void { - this.storageService.updateSettings({ showCompleted: this.showCompleted }); + async updateShowCompleted(): Promise { + await this.storageService.updateSettings({ showCompleted: this.showCompleted }); this.successMessage.set('Settings saved'); setTimeout(() => this.successMessage.set(null), 3000); } diff --git a/src/app/services/import-export.service.ts b/src/app/services/import-export.service.ts index a572b09..d58cbfe 100644 --- a/src/app/services/import-export.service.ts +++ b/src/app/services/import-export.service.ts @@ -33,6 +33,8 @@ export class ImportExportService { return; } + await new Promise(resolve => setTimeout(resolve, 0)); + const migrated = this.migrateDataOnly(data); this.ensureUngroupedGroup(migrated); From 111465f60afb04c1091780ae5a63e67106e8150d Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:15:49 +0200 Subject: [PATCH 08/38] fix: return boolean from importData to indicate success Now returns true only when import actually completes. Component shows success message only when importData returns true, not when it silently returns on user cancellation. --- src/app/components/settings/settings.component.ts | 8 +++++--- src/app/services/import-export.service.ts | 7 ++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 2f73782..2f2ef94 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -192,9 +192,11 @@ export class SettingsComponent implements OnInit { this.successMessage.set(null); try { - await this.importExportService.importData(); - this.successMessage.set('Data imported successfully'); - setTimeout(() => this.successMessage.set(null), 3000); + const imported = await this.importExportService.importData(); + if (imported) { + this.successMessage.set('Data imported successfully'); + setTimeout(() => this.successMessage.set(null), 3000); + } } catch (error) { const message = error instanceof Error ? error.message : 'Failed to import data'; this.errorMessage.set(`Import failed: ${message}`); diff --git a/src/app/services/import-export.service.ts b/src/app/services/import-export.service.ts index d58cbfe..b112015 100644 --- a/src/app/services/import-export.service.ts +++ b/src/app/services/import-export.service.ts @@ -16,11 +16,11 @@ export class ImportExportService { await this.adapter.exportData(data); } - async importData(): Promise { + async importData(): Promise { const result = await this.adapter.importData(); if (!result) { - return; + return false; } const { data } = result; @@ -30,7 +30,7 @@ export class ImportExportService { } if (!confirm('Importing will replace all existing data. Are you sure?')) { - return; + return false; } await new Promise(resolve => setTimeout(resolve, 0)); @@ -43,6 +43,7 @@ export class ImportExportService { } await this.storageService.saveData(migrated); + return true; } private migrateDataOnly(data: StorageData): StorageData { From 93e8f8cf55168022a99e95295706f05c16df1aaf Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:17:18 +0200 Subject: [PATCH 09/38] fix: update lastModifiedAt in signal after save The signal now holds the updated data with fresh timestamp, not the stale original data that was passed in. --- src/app/services/storage.service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/services/storage.service.ts b/src/app/services/storage.service.ts index 2bb6721..e7966df 100644 --- a/src/app/services/storage.service.ts +++ b/src/app/services/storage.service.ts @@ -18,8 +18,12 @@ export class StorageService { } async saveData(data: StorageData): Promise { - await this.adapter.save(data); - this.data.set(data); + const updated: StorageData = { + ...data, + lastModifiedAt: new Date().toISOString() + }; + await this.adapter.save(updated); + this.data.set(updated); } getData(): StorageData { From e3c35c037d9206a031e5c1e40fc072b96a2e5694 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:34:44 +0200 Subject: [PATCH 10/38] Remove not working timeout fix --- src/app/services/import-export.service.ts | 24 +++++++++++------------ 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/app/services/import-export.service.ts b/src/app/services/import-export.service.ts index b112015..45a5737 100644 --- a/src/app/services/import-export.service.ts +++ b/src/app/services/import-export.service.ts @@ -18,13 +18,13 @@ export class ImportExportService { async importData(): Promise { const result = await this.adapter.importData(); - + if (!result) { return false; } const { data } = result; - + if (!this.validateStorageDataStructure(data)) { throw new Error('Invalid data format'); } @@ -33,11 +33,9 @@ export class ImportExportService { return false; } - await new Promise(resolve => setTimeout(resolve, 0)); - const migrated = this.migrateDataOnly(data); this.ensureUngroupedGroup(migrated); - + if (!this.validateMigratedData(migrated)) { throw new Error('Invalid migrated data'); } @@ -56,13 +54,13 @@ export class ImportExportService { if (migrated.schemaVersion < 2) { migrated.items = Object.fromEntries( Object.entries(migrated.items).map(([id, item]) => { - const legacyItem = item as Item & { - lastWatchedAt?: string; + const legacyItem = item as Item & { + lastWatchedAt?: string; watchHistory?: unknown[]; progress?: { season: number; episode: number; totalEpisodes?: number }; }; let watchHistory = legacyItem.watchHistory as any[] || []; - + let adjustedProgress = legacyItem.progress; if (adjustedProgress && legacyItem.status !== 'completed') { adjustedProgress = { @@ -70,8 +68,8 @@ export class ImportExportService { episode: adjustedProgress.episode + 1 }; } - - if (watchHistory.length === 0 && + + if (watchHistory.length === 0 && (legacyItem.status === 'in-progress' || legacyItem.lastWatchedAt !== legacyItem.createdAt)) { const entry: any = { date: legacyItem.lastWatchedAt }; if (legacyItem.type === 'series' && legacyItem.progress) { @@ -80,7 +78,7 @@ export class ImportExportService { } watchHistory = [entry]; } - + const { lastWatchedAt, ...itemWithoutLastWatched } = legacyItem; return [id, { ...itemWithoutLastWatched, watchHistory, progress: adjustedProgress }]; }) @@ -167,7 +165,7 @@ export class ImportExportService { return false; } const i = item as Record; - + if ( typeof i['id'] !== 'string' || typeof i['title'] !== 'string' || @@ -209,4 +207,4 @@ export class ImportExportService { const e = entry as Record; return typeof e['date'] === 'string'; } -} \ No newline at end of file +} From 1863d510ca0712681082b47f7c863531c6d6fe03 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:37:18 +0200 Subject: [PATCH 11/38] fix: move data transformations to StorageService StorageService now handles lastModifiedAt and ensureUngroupedGroup. Adapters are now simple pass-through for persistence, ensuring signal and persisted data are identical. --- src/app/services/storage.adapter.local.ts | 7 +------ src/app/services/storage.adapter.tauri.ts | 10 ++-------- src/app/services/storage.service.ts | 11 +++++++++++ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/app/services/storage.adapter.local.ts b/src/app/services/storage.adapter.local.ts index 33b7a43..6fe8468 100644 --- a/src/app/services/storage.adapter.local.ts +++ b/src/app/services/storage.adapter.local.ts @@ -32,12 +32,7 @@ export class LocalStorageAdapter implements IStorageAdapter { } save(data: StorageData): void { - const updated: StorageData = { - ...data, - lastModifiedAt: new Date().toISOString() - }; - this.ensureUngroupedGroup(updated); - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } private migrateDataOnly(data: StorageData): StorageData { diff --git a/src/app/services/storage.adapter.tauri.ts b/src/app/services/storage.adapter.tauri.ts index 7f8b82f..de9e51d 100644 --- a/src/app/services/storage.adapter.tauri.ts +++ b/src/app/services/storage.adapter.tauri.ts @@ -53,16 +53,10 @@ export class TauriFileStorageAdapter implements IStorageAdapter { try { await this.ensureDir(); - const updated: StorageData = { - ...data, - lastModifiedAt: new Date().toISOString() - }; - this.ensureUngroupedGroup(updated); - const path = `${STORAGE_DIR}/${STORAGE_FILE}`; - await writeTextFile(path, JSON.stringify(updated, null, 2), { baseDir: BaseDirectory.AppData }); + await writeTextFile(path, JSON.stringify(data, null, 2), { baseDir: BaseDirectory.AppData }); - this.cache = updated; + this.cache = data; } catch (error) { console.error('Failed to save file data:', error); throw error; diff --git a/src/app/services/storage.service.ts b/src/app/services/storage.service.ts index e7966df..bdc8484 100644 --- a/src/app/services/storage.service.ts +++ b/src/app/services/storage.service.ts @@ -22,10 +22,21 @@ export class StorageService { ...data, lastModifiedAt: new Date().toISOString() }; + this.ensureUngroupedGroup(updated); await this.adapter.save(updated); this.data.set(updated); } + private ensureUngroupedGroup(data: StorageData): void { + if (!data.groups['ungrouped']) { + data.groups['ungrouped'] = { + id: 'ungrouped', + name: 'Ungrouped', + order: 0 + }; + } + } + getData(): StorageData { const current = this.data(); if (current) { From 861c5ba6b453478787dafb731e551a0676e9dcf0 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:39:39 +0200 Subject: [PATCH 12/38] fix: re-throw import errors instead of returning null Parse and validation errors are now propagated instead of being swallowed, allowing the component to display error messages. User cancellation still returns null/false as expected. --- src/app/services/import-export.adapter.local.ts | 2 +- src/app/services/import-export.adapter.tauri.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts index b859106..c4edaf6 100644 --- a/src/app/services/import-export.adapter.local.ts +++ b/src/app/services/import-export.adapter.local.ts @@ -43,7 +43,7 @@ export class LocalImportExportAdapter implements IImportExportAdapter { resolve({ file, data: parsed as StorageData }); } catch (error) { console.error('Import error:', error); - resolve(null); + throw error; } }; diff --git a/src/app/services/import-export.adapter.tauri.ts b/src/app/services/import-export.adapter.tauri.ts index 1ddd9d5..90c650d 100644 --- a/src/app/services/import-export.adapter.tauri.ts +++ b/src/app/services/import-export.adapter.tauri.ts @@ -54,7 +54,7 @@ export class TauriImportExportAdapter implements IImportExportAdapter { return { file, data: parsed as StorageData }; } catch (error) { console.error('Import error:', error); - return null; + throw error; } } From 4c7137d4392e63bfa9b6603b548597a58399b773 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:51:50 +0200 Subject: [PATCH 13/38] fix: add reject parameter to Promise to properly handle errors The onchange handler's async function was throwing errors into a disconnected promise, causing the outer Promise to hang forever. Now uses reject(error) to properly reject the outer Promise. --- src/app/services/import-export.adapter.local.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts index c4edaf6..e8a40eb 100644 --- a/src/app/services/import-export.adapter.local.ts +++ b/src/app/services/import-export.adapter.local.ts @@ -20,7 +20,7 @@ export class LocalImportExportAdapter implements IImportExportAdapter { } async importData(): Promise<{ file: File; data: StorageData } | null> { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; @@ -43,7 +43,7 @@ export class LocalImportExportAdapter implements IImportExportAdapter { resolve({ file, data: parsed as StorageData }); } catch (error) { console.error('Import error:', error); - throw error; + reject(error); } }; From d036492807fd4b96ee08b272447df093296db720 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:52:31 +0200 Subject: [PATCH 14/38] fix: add cancel event listener for file picker Added 'cancel' event listener to resolve Promise with null when user dismisses the file dialog without selecting a file. --- src/app/services/import-export.adapter.local.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts index e8a40eb..3c860ed 100644 --- a/src/app/services/import-export.adapter.local.ts +++ b/src/app/services/import-export.adapter.local.ts @@ -25,6 +25,8 @@ export class LocalImportExportAdapter implements IImportExportAdapter { input.type = 'file'; input.accept = '.json'; + input.addEventListener('cancel', () => resolve(null)); + input.onchange = async () => { const file = input.files?.[0]; if (!file) { From 76551a4335f776a173a1b4d76e5a8381e2082e55 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:02:07 +0200 Subject: [PATCH 15/38] feat: exportData returns boolean for cancellation handling Interface now returns boolean (true=success, false=cancelled). Tauri adapter returns false when user cancels save dialog. Local adapter always returns true (download always succeeds). Service propagates boolean to component. Component only shows success when true is returned. --- src/app/components/settings/settings.component.ts | 8 +++++--- src/app/services/import-export.adapter.local.ts | 3 ++- src/app/services/import-export.adapter.tauri.ts | 5 +++-- src/app/services/import-export.adapter.ts | 2 +- src/app/services/import-export.service.ts | 4 ++-- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 2f2ef94..e5f0279 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -178,9 +178,11 @@ export class SettingsComponent implements OnInit { async exportData(): Promise { try { - await this.importExportService.exportData(); - this.successMessage.set('Data exported successfully'); - setTimeout(() => this.successMessage.set(null), 3000); + const exported = await this.importExportService.exportData(); + if (exported) { + this.successMessage.set('Data exported successfully'); + setTimeout(() => this.successMessage.set(null), 3000); + } } catch (error) { this.errorMessage.set('Failed to export data'); setTimeout(() => this.errorMessage.set(null), 5000); diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts index 3c860ed..50cdb75 100644 --- a/src/app/services/import-export.adapter.local.ts +++ b/src/app/services/import-export.adapter.local.ts @@ -6,7 +6,7 @@ import { StorageData } from '../models/storage.model'; providedIn: 'root' }) export class LocalImportExportAdapter implements IImportExportAdapter { - exportData(data: StorageData): void { + exportData(data: StorageData): boolean { const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -17,6 +17,7 @@ export class LocalImportExportAdapter implements IImportExportAdapter { link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); + return true; } async importData(): Promise<{ file: File; data: StorageData } | null> { diff --git a/src/app/services/import-export.adapter.tauri.ts b/src/app/services/import-export.adapter.tauri.ts index 90c650d..f088d6d 100644 --- a/src/app/services/import-export.adapter.tauri.ts +++ b/src/app/services/import-export.adapter.tauri.ts @@ -7,7 +7,7 @@ import { save, open } from '@tauri-apps/plugin-dialog'; providedIn: 'root' }) export class TauriImportExportAdapter implements IImportExportAdapter { - async exportData(data: StorageData): Promise { + async exportData(data: StorageData): Promise { const json = JSON.stringify(data, null, 2); const filePath = await save({ @@ -19,11 +19,12 @@ export class TauriImportExportAdapter implements IImportExportAdapter { }); if (!filePath) { - return; + return false; } const { writeTextFile } = await import('@tauri-apps/plugin-fs'); await writeTextFile(filePath, json); + return true; } async importData(): Promise<{ file: File; data: StorageData } | null> { diff --git a/src/app/services/import-export.adapter.ts b/src/app/services/import-export.adapter.ts index ac6111e..73a838b 100644 --- a/src/app/services/import-export.adapter.ts +++ b/src/app/services/import-export.adapter.ts @@ -2,7 +2,7 @@ import { InjectionToken } from '@angular/core'; import { StorageData } from '../models/storage.model'; export interface IImportExportAdapter { - exportData(data: StorageData): void | Promise; + exportData(data: StorageData): boolean | Promise; importData(): Promise<{ file: File; data: StorageData } | null>; } diff --git a/src/app/services/import-export.service.ts b/src/app/services/import-export.service.ts index 45a5737..ffe4bd1 100644 --- a/src/app/services/import-export.service.ts +++ b/src/app/services/import-export.service.ts @@ -11,9 +11,9 @@ export class ImportExportService { private readonly adapter = inject(IMPORT_EXPORT_ADAPTER); private readonly storageService = inject(StorageService); - async exportData(): Promise { + async exportData(): Promise { const data = this.storageService.getData(); - await this.adapter.exportData(data); + return await this.adapter.exportData(data); } async importData(): Promise { From 91dd6976c5c721b38f922b1afb653525139339f8 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:08:03 +0200 Subject: [PATCH 16/38] refactor: separate app configs per environment Create separate config files to avoid importing Tauri adapters in web builds: - app.config.ts: base config without adapter imports - app.config.web.ts: imports only local adapters - app.config.tauri.ts: imports only tauri adapters - angular.json: use fileReplacements to swap configs This ensures web builds don't bundle Tauri plugin dependencies. --- angular.json | 12 ++++++------ src/app/app.config.tauri.ts | 27 +++++++++++++++++++++++++++ src/app/app.config.ts | 18 +----------------- src/app/app.config.web.ts | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 23 deletions(-) create mode 100644 src/app/app.config.tauri.ts create mode 100644 src/app/app.config.web.ts diff --git a/angular.json b/angular.json index c268df4..9fa94ed 100644 --- a/angular.json +++ b/angular.json @@ -48,8 +48,8 @@ "tauri": { "fileReplacements": [ { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.tauri.ts" + "replace": "src/app/app.config.ts", + "with": "src/app/app.config.tauri.ts" } ] }, @@ -57,8 +57,8 @@ "serviceWorker": "ngsw-config.json", "fileReplacements": [ { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.web.ts" + "replace": "src/app/app.config.ts", + "with": "src/app/app.config.web.ts" } ] }, @@ -73,8 +73,8 @@ "sourceMap": true, "fileReplacements": [ { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.tauri.ts" + "replace": "src/app/app.config.ts", + "with": "src/app/app.config.tauri.ts" } ] } diff --git a/src/app/app.config.tauri.ts b/src/app/app.config.tauri.ts new file mode 100644 index 0000000..114ae7c --- /dev/null +++ b/src/app/app.config.tauri.ts @@ -0,0 +1,27 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners, APP_INITIALIZER } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; +import { STORAGE_ADAPTER, IStorageAdapter } from './services/storage.adapter'; +import { TauriFileStorageAdapter } from './services/storage.adapter.tauri'; +import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './services/import-export.adapter'; +import { TauriImportExportAdapter } from './services/import-export.adapter.tauri'; +import { StorageService } from './services/storage.service'; + +const storageAdapter: IStorageAdapter = new TauriFileStorageAdapter(); +const importExportAdapter: IImportExportAdapter = new TauriImportExportAdapter(); + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + { + provide: APP_INITIALIZER, + useFactory: (storageService: StorageService) => () => storageService.loadData(), + deps: [StorageService], + multi: true + }, + { provide: STORAGE_ADAPTER, useValue: storageAdapter }, + { provide: IMPORT_EXPORT_ADAPTER, useValue: importExportAdapter } + ] +}; \ No newline at end of file diff --git a/src/app/app.config.ts b/src/app/app.config.ts index cf50849..0291eae 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -4,22 +4,8 @@ import { provideServiceWorker } from '@angular/service-worker'; import { routes } from './app.routes'; import { environment } from '../environments/environment'; -import { STORAGE_ADAPTER, IStorageAdapter } from './services/storage.adapter'; -import { LocalStorageAdapter } from './services/storage.adapter.local'; -import { TauriFileStorageAdapter } from './services/storage.adapter.tauri'; -import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './services/import-export.adapter'; -import { LocalImportExportAdapter } from './services/import-export.adapter.local'; -import { TauriImportExportAdapter } from './services/import-export.adapter.tauri'; import { StorageService } from './services/storage.service'; -const storageAdapter: IStorageAdapter = environment.isTauri - ? new TauriFileStorageAdapter() - : new LocalStorageAdapter(); - -const importExportAdapter: IImportExportAdapter = environment.isTauri - ? new TauriImportExportAdapter() - : new LocalImportExportAdapter(); - export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), @@ -30,8 +16,6 @@ export const appConfig: ApplicationConfig = { deps: [StorageService], multi: true }, - { provide: STORAGE_ADAPTER, useValue: storageAdapter }, - { provide: IMPORT_EXPORT_ADAPTER, useValue: importExportAdapter }, ...(environment.enableServiceWorker ? [ provideServiceWorker('ngsw-worker.js', { @@ -41,4 +25,4 @@ export const appConfig: ApplicationConfig = { ] : []) ] -}; +}; \ No newline at end of file diff --git a/src/app/app.config.web.ts b/src/app/app.config.web.ts new file mode 100644 index 0000000..9ba272a --- /dev/null +++ b/src/app/app.config.web.ts @@ -0,0 +1,37 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners, APP_INITIALIZER } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideServiceWorker } from '@angular/service-worker'; + +import { routes } from './app.routes'; +import { environment } from '../environments/environment'; +import { STORAGE_ADAPTER, IStorageAdapter } from './services/storage.adapter'; +import { LocalStorageAdapter } from './services/storage.adapter.local'; +import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './services/import-export.adapter'; +import { LocalImportExportAdapter } from './services/import-export.adapter.local'; +import { StorageService } from './services/storage.service'; + +const storageAdapter: IStorageAdapter = new LocalStorageAdapter(); +const importExportAdapter: IImportExportAdapter = new LocalImportExportAdapter(); + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + { + provide: APP_INITIALIZER, + useFactory: (storageService: StorageService) => () => storageService.loadData(), + deps: [StorageService], + multi: true + }, + { provide: STORAGE_ADAPTER, useValue: storageAdapter }, + { provide: IMPORT_EXPORT_ADAPTER, useValue: importExportAdapter }, + ...(environment.enableServiceWorker + ? [ + provideServiceWorker('ngsw-worker.js', { + enabled: true, + registrationStrategy: 'registerWhenStable:30000' + }) + ] + : []) + ] +}; \ No newline at end of file From d2d709494dae1f60d63f6732768c8eeb635f2083 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:11:17 +0200 Subject: [PATCH 17/38] fix: restore environment file replacements alongside app.config Now both environment and app.config are replaced per build target, ensuring isTauri and enableServiceWorker settings are correct. --- angular.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/angular.json b/angular.json index 9fa94ed..a7774a4 100644 --- a/angular.json +++ b/angular.json @@ -47,6 +47,10 @@ }, "tauri": { "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.tauri.ts" + }, { "replace": "src/app/app.config.ts", "with": "src/app/app.config.tauri.ts" @@ -56,6 +60,10 @@ "web": { "serviceWorker": "ngsw-config.json", "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.web.ts" + }, { "replace": "src/app/app.config.ts", "with": "src/app/app.config.web.ts" @@ -72,6 +80,10 @@ "extractLicenses": false, "sourceMap": true, "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.tauri.ts" + }, { "replace": "src/app/app.config.ts", "with": "src/app/app.config.tauri.ts" From a723aef0751f5a979d39bc0ef5e5f1abd000d578 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:23:09 +0200 Subject: [PATCH 18/38] refactor: move adapter classes to environment files Single app.config.ts now uses environment to get adapter classes. Environment files contain only the adapter class references that differ: - base: no adapters (fallback for dev) - web: local adapters only - tauri: tauri adapters only This removes need for separate config files per build target. --- angular.json | 12 --------- src/app/app.config.tauri.ts | 27 ------------------- src/app/app.config.ts | 7 +++++ src/app/app.config.web.ts | 37 --------------------------- src/environments/environment.tauri.ts | 9 +++++-- src/environments/environment.ts | 14 ++++++++-- src/environments/environment.web.ts | 9 +++++-- 7 files changed, 33 insertions(+), 82 deletions(-) delete mode 100644 src/app/app.config.tauri.ts delete mode 100644 src/app/app.config.web.ts diff --git a/angular.json b/angular.json index a7774a4..c268df4 100644 --- a/angular.json +++ b/angular.json @@ -50,10 +50,6 @@ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.tauri.ts" - }, - { - "replace": "src/app/app.config.ts", - "with": "src/app/app.config.tauri.ts" } ] }, @@ -63,10 +59,6 @@ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.web.ts" - }, - { - "replace": "src/app/app.config.ts", - "with": "src/app/app.config.web.ts" } ] }, @@ -83,10 +75,6 @@ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.tauri.ts" - }, - { - "replace": "src/app/app.config.ts", - "with": "src/app/app.config.tauri.ts" } ] } diff --git a/src/app/app.config.tauri.ts b/src/app/app.config.tauri.ts deleted file mode 100644 index 114ae7c..0000000 --- a/src/app/app.config.tauri.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners, APP_INITIALIZER } from '@angular/core'; -import { provideRouter } from '@angular/router'; - -import { routes } from './app.routes'; -import { STORAGE_ADAPTER, IStorageAdapter } from './services/storage.adapter'; -import { TauriFileStorageAdapter } from './services/storage.adapter.tauri'; -import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './services/import-export.adapter'; -import { TauriImportExportAdapter } from './services/import-export.adapter.tauri'; -import { StorageService } from './services/storage.service'; - -const storageAdapter: IStorageAdapter = new TauriFileStorageAdapter(); -const importExportAdapter: IImportExportAdapter = new TauriImportExportAdapter(); - -export const appConfig: ApplicationConfig = { - providers: [ - provideBrowserGlobalErrorListeners(), - provideRouter(routes), - { - provide: APP_INITIALIZER, - useFactory: (storageService: StorageService) => () => storageService.loadData(), - deps: [StorageService], - multi: true - }, - { provide: STORAGE_ADAPTER, useValue: storageAdapter }, - { provide: IMPORT_EXPORT_ADAPTER, useValue: importExportAdapter } - ] -}; \ No newline at end of file diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 0291eae..9ffd330 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -5,6 +5,11 @@ import { provideServiceWorker } from '@angular/service-worker'; import { routes } from './app.routes'; import { environment } from '../environments/environment'; import { StorageService } from './services/storage.service'; +import { STORAGE_ADAPTER, IStorageAdapter } from './services/storage.adapter'; +import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './services/import-export.adapter'; + +const storageAdapter: IStorageAdapter = new (environment.storageAdapter!)(); +const importExportAdapter: IImportExportAdapter = new (environment.importExportAdapter!)(); export const appConfig: ApplicationConfig = { providers: [ @@ -16,6 +21,8 @@ export const appConfig: ApplicationConfig = { deps: [StorageService], multi: true }, + { provide: STORAGE_ADAPTER, useValue: storageAdapter }, + { provide: IMPORT_EXPORT_ADAPTER, useValue: importExportAdapter }, ...(environment.enableServiceWorker ? [ provideServiceWorker('ngsw-worker.js', { diff --git a/src/app/app.config.web.ts b/src/app/app.config.web.ts deleted file mode 100644 index 9ba272a..0000000 --- a/src/app/app.config.web.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners, APP_INITIALIZER } from '@angular/core'; -import { provideRouter } from '@angular/router'; -import { provideServiceWorker } from '@angular/service-worker'; - -import { routes } from './app.routes'; -import { environment } from '../environments/environment'; -import { STORAGE_ADAPTER, IStorageAdapter } from './services/storage.adapter'; -import { LocalStorageAdapter } from './services/storage.adapter.local'; -import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './services/import-export.adapter'; -import { LocalImportExportAdapter } from './services/import-export.adapter.local'; -import { StorageService } from './services/storage.service'; - -const storageAdapter: IStorageAdapter = new LocalStorageAdapter(); -const importExportAdapter: IImportExportAdapter = new LocalImportExportAdapter(); - -export const appConfig: ApplicationConfig = { - providers: [ - provideBrowserGlobalErrorListeners(), - provideRouter(routes), - { - provide: APP_INITIALIZER, - useFactory: (storageService: StorageService) => () => storageService.loadData(), - deps: [StorageService], - multi: true - }, - { provide: STORAGE_ADAPTER, useValue: storageAdapter }, - { provide: IMPORT_EXPORT_ADAPTER, useValue: importExportAdapter }, - ...(environment.enableServiceWorker - ? [ - provideServiceWorker('ngsw-worker.js', { - enabled: true, - registrationStrategy: 'registerWhenStable:30000' - }) - ] - : []) - ] -}; \ No newline at end of file diff --git a/src/environments/environment.tauri.ts b/src/environments/environment.tauri.ts index 70ccef4..546e104 100644 --- a/src/environments/environment.tauri.ts +++ b/src/environments/environment.tauri.ts @@ -1,4 +1,9 @@ +import { TauriFileStorageAdapter } from '../app/services/storage.adapter.tauri'; +import { TauriImportExportAdapter } from '../app/services/import-export.adapter.tauri'; + export const environment = { isTauri: true, - enableServiceWorker: false -}; + enableServiceWorker: false, + storageAdapter: TauriFileStorageAdapter, + importExportAdapter: TauriImportExportAdapter +}; \ No newline at end of file diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 3a3dc3d..8cc95dc 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,4 +1,14 @@ -export const environment = { +import { IStorageAdapter } from '../app/services/storage.adapter'; +import { IImportExportAdapter } from '../app/services/import-export.adapter'; + +export interface Environment { + isTauri: boolean; + enableServiceWorker: boolean; + storageAdapter?: new () => IStorageAdapter; + importExportAdapter?: new () => IImportExportAdapter; +} + +export const environment: Environment = { isTauri: false, enableServiceWorker: false -}; +}; \ No newline at end of file diff --git a/src/environments/environment.web.ts b/src/environments/environment.web.ts index 08dfc0a..511d12b 100644 --- a/src/environments/environment.web.ts +++ b/src/environments/environment.web.ts @@ -1,4 +1,9 @@ +import { LocalStorageAdapter } from '../app/services/storage.adapter.local'; +import { LocalImportExportAdapter } from '../app/services/import-export.adapter.local'; + export const environment = { isTauri: false, - enableServiceWorker: true -}; + enableServiceWorker: true, + storageAdapter: LocalStorageAdapter, + importExportAdapter: LocalImportExportAdapter +}; \ No newline at end of file From 111eeddf5b28fada5a40e0661153796c4784205b Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:42:16 +0200 Subject: [PATCH 19/38] fix: add fileReplacements to development configuration The development configuration now uses environment.web.ts, matching the pattern used by development-tauri. --- angular.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/angular.json b/angular.json index c268df4..1905a54 100644 --- a/angular.json +++ b/angular.json @@ -65,7 +65,13 @@ "development": { "optimization": false, "extractLicenses": false, - "sourceMap": true + "sourceMap": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.web.ts" + } + ] }, "development-tauri": { "optimization": false, From 5b2a586f4b233924e1512ba340aaba41ba5cb8ef Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:00:00 +0200 Subject: [PATCH 20/38] fix: make base environment self-sufficient with default adapters - Add LocalStorageAdapter and LocalImportExportAdapter to environment.ts - Make properties required (not optional) in Environment interface - Remove non-null assertions from app.config.ts - Remove fileReplacements from development config (base is sufficient) --- angular.json | 8 +------- src/app/app.config.ts | 4 ++-- src/environments/environment.ts | 10 +++++++--- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/angular.json b/angular.json index 1905a54..c268df4 100644 --- a/angular.json +++ b/angular.json @@ -65,13 +65,7 @@ "development": { "optimization": false, "extractLicenses": false, - "sourceMap": true, - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.web.ts" - } - ] + "sourceMap": true }, "development-tauri": { "optimization": false, diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 9ffd330..22f8fa2 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -8,8 +8,8 @@ import { StorageService } from './services/storage.service'; import { STORAGE_ADAPTER, IStorageAdapter } from './services/storage.adapter'; import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './services/import-export.adapter'; -const storageAdapter: IStorageAdapter = new (environment.storageAdapter!)(); -const importExportAdapter: IImportExportAdapter = new (environment.importExportAdapter!)(); +const storageAdapter: IStorageAdapter = new environment.storageAdapter(); +const importExportAdapter: IImportExportAdapter = new environment.importExportAdapter(); export const appConfig: ApplicationConfig = { providers: [ diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 8cc95dc..6095cf5 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,14 +1,18 @@ import { IStorageAdapter } from '../app/services/storage.adapter'; import { IImportExportAdapter } from '../app/services/import-export.adapter'; +import { LocalStorageAdapter } from '../app/services/storage.adapter.local'; +import { LocalImportExportAdapter } from '../app/services/import-export.adapter.local'; export interface Environment { isTauri: boolean; enableServiceWorker: boolean; - storageAdapter?: new () => IStorageAdapter; - importExportAdapter?: new () => IImportExportAdapter; + storageAdapter: new () => IStorageAdapter; + importExportAdapter: new () => IImportExportAdapter; } export const environment: Environment = { isTauri: false, - enableServiceWorker: false + enableServiceWorker: false, + storageAdapter: LocalStorageAdapter, + importExportAdapter: LocalImportExportAdapter }; \ No newline at end of file From 016a9f347af850e6c1b9e6eb2cd906a417768067 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:26:59 +0200 Subject: [PATCH 21/38] feat: add async action handling with resource loader Phase 1: Create shared async action helper - createAsyncAction() returns busy/error signals - withAsyncAction() wraps async functions with try/catch/finally - Supports parameterized functions Phase 2: Update storage service with resource() - Replace APP_INITIALIZER with resource() for initial load - dataResource provides loading/error/value signals - Remove manual loadData() method Phase 3: Update components with busy/error handling - home component: uses resource reload after writes - item-list component: async action for mark operations - item-card component: add disabled input for buttons Phase 4: Update base environment with default adapters - Base environment now has local adapters as defaults - Enables direct use without fileReplacements - development config no longer needs fileReplacements --- src/app/app.config.ts | 8 +- src/app/components/home/home.component.ts | 106 +++++--- .../item-card/item-card.component.ts | 5 +- .../item-list/item-list.component.ts | 226 +++++------------- src/app/services/storage.service.ts | 46 +++- src/app/utils/async-action.ts | 45 ++++ 6 files changed, 212 insertions(+), 224 deletions(-) create mode 100644 src/app/utils/async-action.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 22f8fa2..5749f95 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,4 +1,4 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners, APP_INITIALIZER } from '@angular/core'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; @@ -15,12 +15,6 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), - { - provide: APP_INITIALIZER, - useFactory: (storageService: StorageService) => () => storageService.loadData(), - deps: [StorageService], - multi: true - }, { provide: STORAGE_ADAPTER, useValue: storageAdapter }, { provide: IMPORT_EXPORT_ADAPTER, useValue: importExportAdapter }, ...(environment.enableServiceWorker diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts index 3223af7..06a47a3 100644 --- a/src/app/components/home/home.component.ts +++ b/src/app/components/home/home.component.ts @@ -1,9 +1,11 @@ -import { Component, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { RoundRobinService } from '../../services/round-robin.service'; import { WatchListService } from '../../services/watch-list.service'; +import { StorageService } from '../../services/storage.service'; import { Item } from '../../models/item.model'; import { ItemCardComponent } from '../item-card/item-card.component'; import { NgIf } from '@angular/common'; +import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; @Component({ selector: 'app-home', @@ -12,6 +14,9 @@ import { NgIf } from '@angular/common';

What should I watch now?

+
Loading...
+
{{ state.error() }}
+

Next Series

@@ -20,6 +25,7 @@ import { NgIf } from '@angular/common'; [item]="nextSeries()!" (onMarkWatched)="markSeriesWatched()" (onMarkCompleted)="markSeriesCompleted()" + [disabled]="state.busy()" />
@@ -34,6 +40,7 @@ import { NgIf } from '@angular/common'; [item]="nextMovie()!" (onMarkWatched)="markMovieWatched()" (onMarkCompleted)="markMovieCompleted()" + [disabled]="state.busy()" />
@@ -86,16 +93,33 @@ import { NgIf } from '@angular/common'; background: light-dark(var(--light-bg-primary), var(--dark-bg-primary)); border-radius: 8px; } + + .loading { + padding: 1rem; + background: light-dark(var(--light-bg-tertiary), var(--dark-bg-tertiary)); + border-radius: 4px; + margin-bottom: 1rem; + } + + .error-message { + padding: 1rem; + background: light-dark(#f8d7da, #721c24); + color: light-dark(#721c24, #f8d7da); + border-radius: 4px; + margin-bottom: 1rem; + } `] }) export class HomeComponent { + private readonly roundRobinService = inject(RoundRobinService); + private readonly watchListService = inject(WatchListService); + private readonly storageService = inject(StorageService); + + state = createAsyncAction(); nextSeries = signal(null); nextMovie = signal(null); - constructor( - private roundRobinService: RoundRobinService, - private watchListService: WatchListService - ) { + constructor() { this.updateNextItems(); } @@ -104,35 +128,51 @@ export class HomeComponent { this.nextMovie.set(this.roundRobinService.getNextMovieToWatch()); } - async markSeriesWatched(): Promise { - const series = this.nextSeries(); - if (series) { - await this.watchListService.markWatched(series.id); - this.updateNextItems(); - } - } + markSeriesWatched = withAsyncAction( + async () => { + const series = this.nextSeries(); + if (series) { + await this.watchListService.markWatched(series.id); + this.storageService.dataResource.reload(); + this.updateNextItems(); + } + }, + this.state + ); - async markSeriesCompleted(): Promise { - const series = this.nextSeries(); - if (series) { - await this.watchListService.markCompleted(series.id); - this.updateNextItems(); - } - } + markSeriesCompleted = withAsyncAction( + async () => { + const series = this.nextSeries(); + if (series) { + await this.watchListService.markCompleted(series.id); + this.storageService.dataResource.reload(); + this.updateNextItems(); + } + }, + this.state + ); - async markMovieWatched(): Promise { - const movie = this.nextMovie(); - if (movie) { - await this.watchListService.markWatched(movie.id); - this.updateNextItems(); - } - } + markMovieWatched = withAsyncAction( + async () => { + const movie = this.nextMovie(); + if (movie) { + await this.watchListService.markWatched(movie.id); + this.storageService.dataResource.reload(); + this.updateNextItems(); + } + }, + this.state + ); - async markMovieCompleted(): Promise { - const movie = this.nextMovie(); - if (movie) { - await this.watchListService.markCompleted(movie.id); - this.updateNextItems(); - } - } + markMovieCompleted = withAsyncAction( + async () => { + const movie = this.nextMovie(); + if (movie) { + await this.watchListService.markCompleted(movie.id); + this.storageService.dataResource.reload(); + this.updateNextItems(); + } + }, + this.state + ); } \ No newline at end of file diff --git a/src/app/components/item-card/item-card.component.ts b/src/app/components/item-card/item-card.component.ts index b957feb..b9d6a31 100644 --- a/src/app/components/item-card/item-card.component.ts +++ b/src/app/components/item-card/item-card.component.ts @@ -41,10 +41,10 @@ import { WatchListService } from '../../services/watch-list.service';
- -
@@ -156,6 +156,7 @@ import { WatchListService } from '../../services/watch-list.service'; }) export class ItemCardComponent { item = input.required(); + disabled = input(false); onMarkWatched = output(); onMarkCompleted = output(); diff --git a/src/app/components/item-list/item-list.component.ts b/src/app/components/item-list/item-list.component.ts index ddecc82..7b444fc 100644 --- a/src/app/components/item-list/item-list.component.ts +++ b/src/app/components/item-list/item-list.component.ts @@ -1,13 +1,13 @@ -import { Component, computed, signal } from '@angular/core'; +import { Component, inject, computed, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { StorageService } from '../../services/storage.service'; import { WatchListService } from '../../services/watch-list.service'; import { GroupService } from '../../services/group.service'; -import { Item, ItemStatus } from '../../models/item.model'; -import { Group } from '../../models/group.model'; +import { Item } from '../../models/item.model'; import { ItemCardComponent } from '../item-card/item-card.component'; +import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; @Component({ selector: 'app-item-list', @@ -19,45 +19,23 @@ import { ItemCardComponent } from '../item-card/item-card.component'; Add Item +
{{ state.error() }}
+
@@ -69,172 +47,75 @@ import { ItemCardComponent } from '../item-card/item-card.component'; {{ isGroupExpanded(group.id) ? 'â–¼' : 'â–¶' }} ({{ getGroupItems(group.id).length }}) -
-

- No items in this group -

+

No items in this group

`, styles: [` - .item-list-container { - max-width: 1200px; - margin: 0 auto; - padding: 2rem; - } - - .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2rem; - } - - h1 { - font-size: 2rem; - margin: 0; - color: light-dark(var(--light-font-color), var(--dark-font-color)); - } - - .add-button { - padding: 0.75rem 1.5rem; - background: var(--accent-primary); - color: white; - text-decoration: none; - border-radius: 4px; - font-weight: 500; - } - - .add-button:hover { - background: var(--accent-primary-hover); - } - - .filters { - display: flex; - gap: 1rem; - margin-bottom: 2rem; - padding: 1rem; - background: light-dark(var(--light-bg-primary), var(--dark-bg-primary)); - border-radius: 4px; - } - - .filters label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - } - - .groups-container { - display: flex; - flex-direction: column; - gap: 1.5rem; - } - - .group-section { - border: 1px solid light-dark(var(--light-border-color), var(--dark-border-color)); - border-radius: 8px; - overflow: hidden; - } - - .group-header { - display: flex; - align-items: center; - gap: 1rem; - padding: 1rem; - background: light-dark(var(--light-bg-tertiary), var(--dark-bg-tertiary)); - cursor: pointer; - user-select: none; - } - - .group-header:hover { - background: light-dark(#e9ecef, #333333); - } - - .group-header h2 { - margin: 0; - font-size: 1.3rem; - flex: 1; - } - - .toggle-icon { - font-size: 0.8rem; - color: light-dark(var(--light-font-secondary), var(--dark-font-secondary)); - } - - .item-count { - color: light-dark(var(--light-font-secondary), var(--dark-font-secondary)); - font-size: 0.9rem; - } - - .group-items { - padding: 1rem; - } - - .empty-group { - text-align: center; - color: light-dark(var(--light-font-muted), var(--dark-font-muted)); - padding: 2rem; - } + .item-list-container { max-width: 1200px; margin: 0 auto; padding: 2rem; } + .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; } + h1 { font-size: 2rem; margin: 0; color: light-dark(var(--light-font-color), var(--dark-font-color)); } + .add-button { padding: 0.75rem 1.5rem; background: var(--accent-primary); color: white; text-decoration: none; border-radius: 4px; font-weight: 500; } + .add-button:hover { background: var(--accent-primary-hover); } + .filters { display: flex; gap: 1rem; margin-bottom: 2rem; padding: 1rem; background: light-dark(var(--light-bg-primary), var(--dark-bg-primary)); border-radius: 4px; } + .filters label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; } + .groups-container { display: flex; flex-direction: column; gap: 1.5rem; } + .group-section { border: 1px solid light-dark(var(--light-border-color), var(--dark-border-color)); border-radius: 8px; overflow: hidden; } + .group-header { display: flex; align-items: center; gap: 1rem; padding: 1rem; background: light-dark(var(--light-bg-tertiary), var(--dark-bg-tertiary)); cursor: pointer; user-select: none; } + .group-header:hover { background: light-dark(#e9ecef, #333333); } + .group-header h2 { margin: 0; font-size: 1.3rem; flex: 1; } + .toggle-icon { font-size: 0.8rem; color: light-dark(var(--light-font-secondary), var(--dark-font-secondary)); } + .item-count { color: light-dark(var(--light-font-secondary), var(--dark-font-secondary)); font-size: 0.9rem; } + .group-items { padding: 1rem; } + .empty-group { text-align: center; color: light-dark(var(--light-font-muted), var(--dark-font-muted)); padding: 2rem; } + .error-message { padding: 1rem; background: light-dark(#f8d7da, #721c24); color: light-dark(#721c24, #f8d7da); border-radius: 4px; margin-bottom: 1rem; } `] }) export class ItemListComponent { + private readonly storageService = inject(StorageService); + private readonly watchListService = inject(WatchListService); + private readonly groupService = inject(GroupService); + + state = createAsyncAction(); statusFilter = signal('all'); expandedGroups = signal>(new Set()); + groups = computed(() => { - // Trigger reactivity by accessing the storage signal - this.storageService.getDataSignal()(); + this.storageService.data(); return this.groupService.getAllGroups(); }); - + allItems = computed(() => { - const data = this.storageService.getDataSignal()(); + const data = this.storageService.data(); return data ? Object.values(data.items) : []; }); filteredItems = computed(() => { const items = this.allItems(); const filter = this.statusFilter(); - - if (filter === 'all') { - return items; - } - - return items.filter(item => item.status === filter); + return filter === 'all' ? items : items.filter(item => item.status === filter); }); - constructor( - private storageService: StorageService, - private watchListService: WatchListService, - private groupService: GroupService - ) { - // Expand all groups by default - this.groups().forEach(group => { + constructor() { + this.groupService.getAllGroups().forEach(group => { this.expandedGroups.update(set => new Set(set).add(group.id)); }); } - updateFilter(): void { - // Filter update is handled by computed signal - } - toggleGroup(groupId: string): void { this.expandedGroups.update(set => { const newSet = new Set(set); - if (newSet.has(groupId)) { - newSet.delete(groupId); - } else { - newSet.add(groupId); - } + newSet.has(groupId) ? newSet.delete(groupId) : newSet.add(groupId); return newSet; }); } @@ -247,12 +128,19 @@ export class ItemListComponent { return this.filteredItems().filter(item => item.groupId === groupId); } - async markWatched(itemId: string): Promise { - await this.watchListService.markWatched(itemId); - } - - async markCompleted(itemId: string): Promise { - await this.watchListService.markCompleted(itemId); - } -} - + markWatched = withAsyncAction( + async (itemId: string) => { + await this.watchListService.markWatched(itemId); + await this.storageService.dataResource.reload(); + }, + this.state + ); + + markCompleted = withAsyncAction( + async (itemId: string) => { + await this.watchListService.markCompleted(itemId); + await this.storageService.dataResource.reload(); + }, + this.state + ); +} \ No newline at end of file diff --git a/src/app/services/storage.service.ts b/src/app/services/storage.service.ts index bdc8484..f436e08 100644 --- a/src/app/services/storage.service.ts +++ b/src/app/services/storage.service.ts @@ -1,30 +1,35 @@ -import { Injectable, signal, inject } from '@angular/core'; +import { Injectable, resource, signal, inject, computed } from '@angular/core'; import { StorageData, Settings } from '../models/storage.model'; import { Item } from '../models/item.model'; import { Group } from '../models/group.model'; -import { STORAGE_ADAPTER, IStorageAdapter } from './storage.adapter'; +import { STORAGE_ADAPTER } from './storage.adapter'; @Injectable({ providedIn: 'root' }) export class StorageService { private readonly adapter = inject(STORAGE_ADAPTER); - private readonly data = signal(null); - async loadData(): Promise { - const loaded = await this.adapter.load(); - this.data.set(loaded); - return loaded; - } + readonly dataResource = resource({ + loader: async () => { + const loaded = await this.adapter.load(); + this.ensureUngroupedGroup(loaded); + return loaded; + } + }); - async saveData(data: StorageData): Promise { + readonly data = computed(() => this.dataResource.value()); + readonly loading = computed(() => this.dataResource.status() === 'loading'); + readonly error = computed(() => this.dataResource.status() === 'error' ? this.dataResource.error()?.message : null); + + async saveData(newData: StorageData): Promise { const updated: StorageData = { - ...data, + ...newData, lastModifiedAt: new Date().toISOString() }; this.ensureUngroupedGroup(updated); await this.adapter.save(updated); - this.data.set(updated); + this.dataResource.reload(); } private ensureUngroupedGroup(data: StorageData): void { @@ -42,11 +47,14 @@ export class StorageService { if (current) { return current; } - throw new Error('Data not loaded. Call loadData() first.'); + if (this.loading()) { + return this.getDefaultData(); + } + throw new Error('Data not loaded'); } getDataSignal() { - return this.data.asReadonly(); + return this.data; } getItems(): Item[] { @@ -71,4 +79,16 @@ export class StorageService { settings: { ...data.settings, ...settings } }); } + + private getDefaultData(): StorageData { + return { + schemaVersion: 2, + lastModifiedAt: new Date().toISOString(), + settings: { showCompleted: false }, + groups: { + ungrouped: { id: 'ungrouped', name: 'Ungrouped', order: 0 } + }, + items: {} + }; + } } \ No newline at end of file diff --git a/src/app/utils/async-action.ts b/src/app/utils/async-action.ts new file mode 100644 index 0000000..ee15915 --- /dev/null +++ b/src/app/utils/async-action.ts @@ -0,0 +1,45 @@ +import { WritableSignal, signal } from '@angular/core'; + +export interface AsyncActionState { + busy: WritableSignal; + error: WritableSignal; +} + +export interface AsyncActionOptions { + showError?: boolean; +} + +export function createAsyncAction(): AsyncActionState { + return { + busy: signal(false), + error: signal(null) + }; +} + +export function withAsyncAction) => ReturnType>( + action: F, + state: AsyncActionState, + options: AsyncActionOptions = {} +): F { + const { busy, error } = state; + const { showError = true } = options; + + return (async (...args: Parameters) => { + if (busy()) return; + busy.set(true); + if (showError) error.set(null); + try { + await action(...args); + } catch (err) { + if (showError) { + error.set(err instanceof Error ? err.message : 'Operation failed'); + } + } finally { + busy.set(false); + } + }) as F; +} + +export function clearError(error: WritableSignal) { + error.set(null); +} \ No newline at end of file From 18b6046ca1b65bd875984526aa25774e5de8c45d Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:40:17 +0200 Subject: [PATCH 22/38] fix: use effect() to react to resource data loading Components were calling synchronous methods in constructors before the resource had loaded, causing empty data on initial render. Added effect() calls in: - HomeComponent: reactively update next items when data loads - GroupManagerComponent: reactively load groups when data loads - SettingsComponent: reactively load settings when data loads Also removed OnInit implementations that only called constructor logic. --- .../group-manager/group-manager.component.ts | 16 ++++- src/app/components/home/home.component.ts | 7 +- .../components/settings/settings.component.ts | 69 ++++++++++--------- 3 files changed, 53 insertions(+), 39 deletions(-) diff --git a/src/app/components/group-manager/group-manager.component.ts b/src/app/components/group-manager/group-manager.component.ts index 38ac806..04687eb 100644 --- a/src/app/components/group-manager/group-manager.component.ts +++ b/src/app/components/group-manager/group-manager.component.ts @@ -1,8 +1,10 @@ -import { Component, OnInit, signal } from '@angular/core'; +import { Component, OnInit, signal, inject, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { StorageService } from '../../services/storage.service'; import { GroupService } from '../../services/group.service'; import { Group } from '../../models/group.model'; +import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; @Component({ selector: 'app-group-manager', @@ -276,15 +278,23 @@ import { Group } from '../../models/group.model'; `] }) export class GroupManagerComponent implements OnInit { + private readonly storageService = inject(StorageService); + private readonly groupService = inject(GroupService); + + state = createAsyncAction(); groups = signal([]); editingGroup = signal(null); editGroupName = ''; newGroupName = ''; - constructor(private groupService: GroupService) {} + constructor() { + effect(() => { + this.storageService.data(); + this.loadGroups(); + }); + } ngOnInit(): void { - this.loadGroups(); } loadGroups(): void { diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts index 06a47a3..5b6c995 100644 --- a/src/app/components/home/home.component.ts +++ b/src/app/components/home/home.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, inject, signal, effect } from '@angular/core'; import { RoundRobinService } from '../../services/round-robin.service'; import { WatchListService } from '../../services/watch-list.service'; import { StorageService } from '../../services/storage.service'; @@ -120,7 +120,10 @@ export class HomeComponent { nextMovie = signal(null); constructor() { - this.updateNextItems(); + effect(() => { + this.storageService.data(); + this.updateNextItems(); + }); } updateNextItems(): void { diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index e5f0279..c26ff5c 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -1,9 +1,10 @@ -import { Component, OnInit, signal } from '@angular/core'; +import { Component, signal, inject, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { StorageService } from '../../services/storage.service'; import { ImportExportService } from '../../services/import-export.service'; import { Settings } from '../../models/storage.model'; +import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; @Component({ selector: 'app-settings', @@ -43,15 +44,9 @@ import { Settings } from '../../models/storage.model'; -
-
- {{ errorMessage() }} -
-
- -
-
- {{ successMessage() }} +
+
+ {{ state.error() }}
@@ -155,54 +150,60 @@ import { Settings } from '../../models/storage.model'; } `] }) -export class SettingsComponent implements OnInit { - showCompleted = false; - errorMessage = signal(null); - successMessage = signal(null); +export class SettingsComponent { + private readonly storageService = inject(StorageService); + private readonly importExportService = inject(ImportExportService); - constructor( - private storageService: StorageService, - private importExportService: ImportExportService - ) {} + state = createAsyncAction(); + showCompleted = false; - ngOnInit(): void { - const settings = this.storageService.getSettings(); - this.showCompleted = settings.showCompleted; + constructor() { + effect(() => { + const data = this.storageService.data(); + if (data) { + this.showCompleted = data.settings.showCompleted; + } + }); } async updateShowCompleted(): Promise { - await this.storageService.updateSettings({ showCompleted: this.showCompleted }); - this.successMessage.set('Settings saved'); - setTimeout(() => this.successMessage.set(null), 3000); + await withAsyncAction( + async () => { + await this.storageService.updateSettings({ showCompleted: this.showCompleted }); + this.state.error.set('Settings saved'); + setTimeout(() => this.state.error.set(null), 3000); + }, + this.state, + { showError: false } + )(); } async exportData(): Promise { try { const exported = await this.importExportService.exportData(); if (exported) { - this.successMessage.set('Data exported successfully'); - setTimeout(() => this.successMessage.set(null), 3000); + this.state.error.set('Data exported successfully'); + setTimeout(() => this.state.error.set(null), 3000); } } catch (error) { - this.errorMessage.set('Failed to export data'); - setTimeout(() => this.errorMessage.set(null), 5000); + this.state.error.set('Failed to export data'); + setTimeout(() => this.state.error.set(null), 5000); } } async importData(): Promise { - this.errorMessage.set(null); - this.successMessage.set(null); + this.state.error.set(null); try { const imported = await this.importExportService.importData(); if (imported) { - this.successMessage.set('Data imported successfully'); - setTimeout(() => this.successMessage.set(null), 3000); + this.state.error.set('Data imported successfully'); + setTimeout(() => this.state.error.set(null), 3000); } } catch (error) { const message = error instanceof Error ? error.message : 'Failed to import data'; - this.errorMessage.set(`Import failed: ${message}`); - setTimeout(() => this.errorMessage.set(null), 5000); + this.state.error.set(`Import failed: ${message}`); + setTimeout(() => this.state.error.set(null), 5000); } } } From a29746745b2e96afa7e20770b23b0bd12b68e1eb Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:46:25 +0200 Subject: [PATCH 23/38] fix: use set() instead of reload() to prevent flicker Using reload() after save caused a brief loading state where data() returned undefined, creating a visual flicker. Now uses dataResource.set() to directly update the signal synchronously, preserving the current data while persisting. Effect() still fires to update derived state in components. --- src/app/components/home/home.component.ts | 8 -------- src/app/components/item-list/item-list.component.ts | 2 -- src/app/services/storage.service.ts | 2 +- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts index 5b6c995..528943b 100644 --- a/src/app/components/home/home.component.ts +++ b/src/app/components/home/home.component.ts @@ -136,8 +136,6 @@ export class HomeComponent { const series = this.nextSeries(); if (series) { await this.watchListService.markWatched(series.id); - this.storageService.dataResource.reload(); - this.updateNextItems(); } }, this.state @@ -148,8 +146,6 @@ export class HomeComponent { const series = this.nextSeries(); if (series) { await this.watchListService.markCompleted(series.id); - this.storageService.dataResource.reload(); - this.updateNextItems(); } }, this.state @@ -160,8 +156,6 @@ export class HomeComponent { const movie = this.nextMovie(); if (movie) { await this.watchListService.markWatched(movie.id); - this.storageService.dataResource.reload(); - this.updateNextItems(); } }, this.state @@ -172,8 +166,6 @@ export class HomeComponent { const movie = this.nextMovie(); if (movie) { await this.watchListService.markCompleted(movie.id); - this.storageService.dataResource.reload(); - this.updateNextItems(); } }, this.state diff --git a/src/app/components/item-list/item-list.component.ts b/src/app/components/item-list/item-list.component.ts index 7b444fc..b79f9cc 100644 --- a/src/app/components/item-list/item-list.component.ts +++ b/src/app/components/item-list/item-list.component.ts @@ -131,7 +131,6 @@ export class ItemListComponent { markWatched = withAsyncAction( async (itemId: string) => { await this.watchListService.markWatched(itemId); - await this.storageService.dataResource.reload(); }, this.state ); @@ -139,7 +138,6 @@ export class ItemListComponent { markCompleted = withAsyncAction( async (itemId: string) => { await this.watchListService.markCompleted(itemId); - await this.storageService.dataResource.reload(); }, this.state ); diff --git a/src/app/services/storage.service.ts b/src/app/services/storage.service.ts index f436e08..3a52e12 100644 --- a/src/app/services/storage.service.ts +++ b/src/app/services/storage.service.ts @@ -29,7 +29,7 @@ export class StorageService { }; this.ensureUngroupedGroup(updated); await this.adapter.save(updated); - this.dataResource.reload(); + this.dataResource.set(updated); } private ensureUngroupedGroup(data: StorageData): void { From 5a297dbfe3c16c11d3310921d155f228b7eef770 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:00:44 +0200 Subject: [PATCH 24/38] fix: remove loading indicator to prevent flicker The busy signal still disables buttons to prevent concurrent operations, but the loading message is no longer shown. This prevents the visual flicker when operations complete quickly. --- src/app/components/home/home.component.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts index 528943b..4efe9c3 100644 --- a/src/app/components/home/home.component.ts +++ b/src/app/components/home/home.component.ts @@ -14,7 +14,6 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action';

What should I watch now?

-
Loading...
{{ state.error() }}
@@ -94,13 +93,6 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; border-radius: 8px; } - .loading { - padding: 1rem; - background: light-dark(var(--light-bg-tertiary), var(--dark-bg-tertiary)); - border-radius: 4px; - margin-bottom: 1rem; - } - .error-message { padding: 1rem; background: light-dark(#f8d7da, #721c24); From 94ddbc7e227bb8bc732120e16b28476ad75c7601 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:17:13 +0200 Subject: [PATCH 25/38] fix: use effect() to expand groups reactively in ItemListComponent The old code called groupService.getAllGroups() in the constructor, which only had default data (ungrouped) at construction time. Now uses effect() that tracks this.groups() computed signal, so when storage data loads asynchronously, the effect fires and expands all groups. --- src/app/components/item-list/item-list.component.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/components/item-list/item-list.component.ts b/src/app/components/item-list/item-list.component.ts index b79f9cc..6e5fe00 100644 --- a/src/app/components/item-list/item-list.component.ts +++ b/src/app/components/item-list/item-list.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, computed, signal } from '@angular/core'; +import { Component, inject, computed, signal, effect } from '@angular/core'; import { RouterLink } from '@angular/router'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; @@ -107,8 +107,13 @@ export class ItemListComponent { }); constructor() { - this.groupService.getAllGroups().forEach(group => { - this.expandedGroups.update(set => new Set(set).add(group.id)); + effect(() => { + const currentGroups = this.groups(); + this.expandedGroups.update(set => { + const newSet = new Set(set); + currentGroups.forEach(g => newSet.add(g.id)); + return newSet; + }); }); } From f00d3c36d3c960ca1bb4f712f3f5a453971a986d Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:18:45 +0200 Subject: [PATCH 26/38] fix: include 'success' in Settings saved message The message now includes 'success' to properly trigger green styling. --- src/app/components/settings/settings.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index c26ff5c..8f1b14c 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -170,7 +170,7 @@ export class SettingsComponent { await withAsyncAction( async () => { await this.storageService.updateSettings({ showCompleted: this.showCompleted }); - this.state.error.set('Settings saved'); + this.state.error.set('Settings saved successfully'); setTimeout(() => this.state.error.set(null), 3000); }, this.state, From 7e04dce0dcfd647e52b33d8c6a6dc564e369783a Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:21:07 +0200 Subject: [PATCH 27/38] fix: wrap GroupManagerComponent async methods with withAsyncAction Added error display in template and disabled state to buttons. Methods now wrapped: createGroup, saveEdit, deleteGroup, moveUp, moveDown. This provides error handling and prevents concurrent operations. --- .../group-manager/group-manager.component.ts | 67 +++++++++++++------ 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/src/app/components/group-manager/group-manager.component.ts b/src/app/components/group-manager/group-manager.component.ts index 04687eb..cf1b719 100644 --- a/src/app/components/group-manager/group-manager.component.ts +++ b/src/app/components/group-manager/group-manager.component.ts @@ -13,6 +13,10 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action';

Group Management

+
+ {{ state.error() }} +
+

Create New Group

@@ -23,8 +27,9 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; placeholder="Group name" required class="group-input" + [disabled]="state.busy()" /> - +
@@ -40,6 +45,7 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; *ngIf="i > 0" (click)="moveUp(group.id)" class="action-btn" + [disabled]="state.busy()" title="Move up" > ↑ @@ -82,7 +88,7 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; class="group-input" /> @@ -309,9 +315,13 @@ export class GroupManagerComponent implements OnInit { if (!this.newGroupName.trim()) { return; } - await this.groupService.createGroup(this.newGroupName.trim()); - this.newGroupName = ''; - this.loadGroups(); + await withAsyncAction( + async () => { + await this.groupService.createGroup(this.newGroupName.trim()); + this.newGroupName = ''; + }, + this.state + )(); } editGroup(group: Group): void { @@ -322,12 +332,16 @@ export class GroupManagerComponent implements OnInit { async saveEdit(): Promise { const group = this.editingGroup(); if (group && this.editGroupName.trim()) { - await this.groupService.updateGroup({ - ...group, - name: this.editGroupName.trim() - }); - this.cancelEdit(); - this.loadGroups(); + await withAsyncAction( + async () => { + await this.groupService.updateGroup({ + ...group, + name: this.editGroupName.trim() + }); + this.cancelEdit(); + }, + this.state + )(); } } @@ -337,14 +351,15 @@ export class GroupManagerComponent implements OnInit { } async deleteGroup(groupId: string): Promise { - if (confirm('Are you sure you want to delete this group? Items will be moved to "Ungrouped".')) { - try { - await this.groupService.deleteGroup(groupId); - this.loadGroups(); - } catch (error) { - alert('Cannot delete the ungrouped group'); - } + if (!confirm('Are you sure you want to delete this group? Items will be moved to "Ungrouped".')) { + return; } + await withAsyncAction( + async () => { + await this.groupService.deleteGroup(groupId); + }, + this.state + )(); } async moveUp(groupId: string): Promise { @@ -353,8 +368,12 @@ export class GroupManagerComponent implements OnInit { if (index > 0) { const groupIds = sorted.map(g => g.id); [groupIds[index], groupIds[index - 1]] = [groupIds[index - 1], groupIds[index]]; - await this.groupService.reorderGroups(groupIds); - this.loadGroups(); + await withAsyncAction( + async () => { + await this.groupService.reorderGroups(groupIds); + }, + this.state + )(); } } @@ -364,8 +383,12 @@ export class GroupManagerComponent implements OnInit { if (index < sorted.length - 1) { const groupIds = sorted.map(g => g.id); [groupIds[index], groupIds[index + 1]] = [groupIds[index + 1], groupIds[index]]; - await this.groupService.reorderGroups(groupIds); - this.loadGroups(); + await withAsyncAction( + async () => { + await this.groupService.reorderGroups(groupIds); + }, + this.state + )(); } } } From b8a481832521f467a93088224e51eaecbedccc23 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:23:32 +0200 Subject: [PATCH 28/38] refactor: remove misleading providedIn: root from adapters Adapters are instantiated with new in app.config.ts, not via Angular DI. The decorator was misleading. --- src/app/services/import-export.adapter.local.ts | 4 +--- src/app/services/import-export.adapter.tauri.ts | 4 +--- src/app/services/storage.adapter.local.ts | 4 +--- src/app/services/storage.adapter.tauri.ts | 4 +--- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts index 50cdb75..96b0572 100644 --- a/src/app/services/import-export.adapter.local.ts +++ b/src/app/services/import-export.adapter.local.ts @@ -2,9 +2,7 @@ import { Injectable } from '@angular/core'; import { IImportExportAdapter } from './import-export.adapter'; import { StorageData } from '../models/storage.model'; -@Injectable({ - providedIn: 'root' -}) +@Injectable() export class LocalImportExportAdapter implements IImportExportAdapter { exportData(data: StorageData): boolean { const json = JSON.stringify(data, null, 2); diff --git a/src/app/services/import-export.adapter.tauri.ts b/src/app/services/import-export.adapter.tauri.ts index f088d6d..703a3bb 100644 --- a/src/app/services/import-export.adapter.tauri.ts +++ b/src/app/services/import-export.adapter.tauri.ts @@ -3,9 +3,7 @@ import { IImportExportAdapter } from './import-export.adapter'; import { StorageData } from '../models/storage.model'; import { save, open } from '@tauri-apps/plugin-dialog'; -@Injectable({ - providedIn: 'root' -}) +@Injectable() export class TauriImportExportAdapter implements IImportExportAdapter { async exportData(data: StorageData): Promise { const json = JSON.stringify(data, null, 2); diff --git a/src/app/services/storage.adapter.local.ts b/src/app/services/storage.adapter.local.ts index 6fe8468..be85a0b 100644 --- a/src/app/services/storage.adapter.local.ts +++ b/src/app/services/storage.adapter.local.ts @@ -6,9 +6,7 @@ import { Group } from '../models/group.model'; const STORAGE_KEY = 'watchListData'; -@Injectable({ - providedIn: 'root' -}) +@Injectable() export class LocalStorageAdapter implements IStorageAdapter { load(): StorageData { const stored = localStorage.getItem(STORAGE_KEY); diff --git a/src/app/services/storage.adapter.tauri.ts b/src/app/services/storage.adapter.tauri.ts index de9e51d..936596a 100644 --- a/src/app/services/storage.adapter.tauri.ts +++ b/src/app/services/storage.adapter.tauri.ts @@ -8,9 +8,7 @@ import { BaseDirectory, exists, mkdir, readTextFile, writeTextFile } from '@taur const STORAGE_DIR = 'watch-list'; const STORAGE_FILE = 'data.json'; -@Injectable({ - providedIn: 'root' -}) +@Injectable() export class TauriFileStorageAdapter implements IStorageAdapter { private cache: StorageData | null = null; From 2b72cf3ac467c4aaacae465367d776f0482dbf8f Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:24:30 +0200 Subject: [PATCH 29/38] refactor: remove @Injectable decorator entirely from adapters Classes are instantiated with new in app.config.ts, not via Angular DI, so no decorator needed. --- src/app/services/import-export.adapter.local.ts | 2 -- src/app/services/import-export.adapter.tauri.ts | 2 -- src/app/services/storage.adapter.local.ts | 2 -- src/app/services/storage.adapter.tauri.ts | 2 -- 4 files changed, 8 deletions(-) diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts index 96b0572..d1306b3 100644 --- a/src/app/services/import-export.adapter.local.ts +++ b/src/app/services/import-export.adapter.local.ts @@ -1,8 +1,6 @@ -import { Injectable } from '@angular/core'; import { IImportExportAdapter } from './import-export.adapter'; import { StorageData } from '../models/storage.model'; -@Injectable() export class LocalImportExportAdapter implements IImportExportAdapter { exportData(data: StorageData): boolean { const json = JSON.stringify(data, null, 2); diff --git a/src/app/services/import-export.adapter.tauri.ts b/src/app/services/import-export.adapter.tauri.ts index 703a3bb..b1f3a7d 100644 --- a/src/app/services/import-export.adapter.tauri.ts +++ b/src/app/services/import-export.adapter.tauri.ts @@ -1,9 +1,7 @@ -import { Injectable } from '@angular/core'; import { IImportExportAdapter } from './import-export.adapter'; import { StorageData } from '../models/storage.model'; import { save, open } from '@tauri-apps/plugin-dialog'; -@Injectable() export class TauriImportExportAdapter implements IImportExportAdapter { async exportData(data: StorageData): Promise { const json = JSON.stringify(data, null, 2); diff --git a/src/app/services/storage.adapter.local.ts b/src/app/services/storage.adapter.local.ts index be85a0b..bf38b8a 100644 --- a/src/app/services/storage.adapter.local.ts +++ b/src/app/services/storage.adapter.local.ts @@ -1,4 +1,3 @@ -import { Injectable } from '@angular/core'; import { IStorageAdapter } from './storage.adapter'; import { StorageData, CURRENT_SCHEMA_VERSION } from '../models/storage.model'; import { Item } from '../models/item.model'; @@ -6,7 +5,6 @@ import { Group } from '../models/group.model'; const STORAGE_KEY = 'watchListData'; -@Injectable() export class LocalStorageAdapter implements IStorageAdapter { load(): StorageData { const stored = localStorage.getItem(STORAGE_KEY); diff --git a/src/app/services/storage.adapter.tauri.ts b/src/app/services/storage.adapter.tauri.ts index 936596a..6c0b4dd 100644 --- a/src/app/services/storage.adapter.tauri.ts +++ b/src/app/services/storage.adapter.tauri.ts @@ -1,4 +1,3 @@ -import { Injectable } from '@angular/core'; import { IStorageAdapter } from './storage.adapter'; import { StorageData, CURRENT_SCHEMA_VERSION } from '../models/storage.model'; import { Item } from '../models/item.model'; @@ -8,7 +7,6 @@ import { BaseDirectory, exists, mkdir, readTextFile, writeTextFile } from '@taur const STORAGE_DIR = 'watch-list'; const STORAGE_FILE = 'data.json'; -@Injectable() export class TauriFileStorageAdapter implements IStorageAdapter { private cache: StorageData | null = null; From 45e857c59b913191575571a764cdac231e2cd7ab Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:25:29 +0200 Subject: [PATCH 30/38] fix: prevent data loss by blocking writes during loading - getData() now throws during loading instead of returning default data - saveData() blocks and throws if data is still loading - Removed getDefaultData() - was a data corruption vector This ensures no writes can overwrite real data with empty default data during the async resource loading phase. --- src/app/services/storage.service.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/app/services/storage.service.ts b/src/app/services/storage.service.ts index 3a52e12..d98c295 100644 --- a/src/app/services/storage.service.ts +++ b/src/app/services/storage.service.ts @@ -23,6 +23,9 @@ export class StorageService { readonly error = computed(() => this.dataResource.status() === 'error' ? this.dataResource.error()?.message : null); async saveData(newData: StorageData): Promise { + if (this.loading()) { + throw new Error('Cannot save while data is loading'); + } const updated: StorageData = { ...newData, lastModifiedAt: new Date().toISOString() @@ -47,9 +50,6 @@ export class StorageService { if (current) { return current; } - if (this.loading()) { - return this.getDefaultData(); - } throw new Error('Data not loaded'); } @@ -79,16 +79,4 @@ export class StorageService { settings: { ...data.settings, ...settings } }); } - - private getDefaultData(): StorageData { - return { - schemaVersion: 2, - lastModifiedAt: new Date().toISOString(), - settings: { showCompleted: false }, - groups: { - ungrouped: { id: 'ungrouped', name: 'Ungrouped', order: 0 } - }, - items: {} - }; - } } \ No newline at end of file From 4452fbab9170fb1cc3c15b0f4c295375c21fa9d9 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:47:11 +0200 Subject: [PATCH 31/38] fix: guard effects against undefined data during loading Effects now check if data exists before calling methods that depend on getData(). This prevents 'Data not loaded' errors during the initial async loading phase. --- src/app/components/group-manager/group-manager.component.ts | 6 ++++-- src/app/components/home/home.component.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/components/group-manager/group-manager.component.ts b/src/app/components/group-manager/group-manager.component.ts index cf1b719..721cb12 100644 --- a/src/app/components/group-manager/group-manager.component.ts +++ b/src/app/components/group-manager/group-manager.component.ts @@ -295,8 +295,10 @@ export class GroupManagerComponent implements OnInit { constructor() { effect(() => { - this.storageService.data(); - this.loadGroups(); + const data = this.storageService.data(); + if (data) { + this.loadGroups(); + } }); } diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts index 4efe9c3..0547286 100644 --- a/src/app/components/home/home.component.ts +++ b/src/app/components/home/home.component.ts @@ -113,8 +113,10 @@ export class HomeComponent { constructor() { effect(() => { - this.storageService.data(); - this.updateNextItems(); + const data = this.storageService.data(); + if (data) { + this.updateNextItems(); + } }); } From 1be5eac8f5373d465e52e5c04bf18c81c9b25d4c Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:48:35 +0200 Subject: [PATCH 32/38] style: add .message, .error-message, .success-message styles to group-manager Styles mirror the settings component for consistent message appearance. --- .../group-manager/group-manager.component.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/app/components/group-manager/group-manager.component.ts b/src/app/components/group-manager/group-manager.component.ts index 721cb12..c1e174f 100644 --- a/src/app/components/group-manager/group-manager.component.ts +++ b/src/app/components/group-manager/group-manager.component.ts @@ -281,6 +281,24 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; .cancel-btn:hover { background: var(--accent-secondary-hover); } + + .message { + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; + } + + .error-message { + background: light-dark(#f8d7da, #721c24); + color: light-dark(#721c24, #f8d7da); + border: 1px solid light-dark(#f5c6cb, #721c24); + } + + .success-message { + background: light-dark(#d4edda, #155724); + color: light-dark(#155724, #d4edda); + border: 1px solid light-dark(#c3e6cb, #155724); + } `] }) export class GroupManagerComponent implements OnInit { From eebc98e831d0daaba2666a352e3ee4cbd6296a19 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:49:36 +0200 Subject: [PATCH 33/38] fix: show error feedback in updateShowCompleted Now shows errors when settings save fails instead of silently swallowing them with showError: false. --- src/app/components/settings/settings.component.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 8f1b14c..ff1510b 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -173,8 +173,7 @@ export class SettingsComponent { this.state.error.set('Settings saved successfully'); setTimeout(() => this.state.error.set(null), 3000); }, - this.state, - { showError: false } + this.state )(); } From 43a4aa50da46fa1c97e460b071b7a05f9e3a8929 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:55:24 +0200 Subject: [PATCH 34/38] fix: add error handling and state rollback to components - Add withAsyncAction to ItemDetailComponent (saveChanges, markWatched, markCompleted, deleteItem) - Add effect-based data loading to guard against async loading states - Add onError callback to withAsyncAction for rollback on failure - Fix updateShowCompleted to revert checkbox on save failure and show error message --- .../components/add-item/add-item.component.ts | 99 ++++++++---- .../item-detail/item-detail.component.ts | 146 ++++++++++++------ .../components/settings/settings.component.ts | 8 +- src/app/utils/async-action.ts | 4 +- 4 files changed, 172 insertions(+), 85 deletions(-) diff --git a/src/app/components/add-item/add-item.component.ts b/src/app/components/add-item/add-item.component.ts index 90dbea2..856ec34 100644 --- a/src/app/components/add-item/add-item.component.ts +++ b/src/app/components/add-item/add-item.component.ts @@ -1,11 +1,13 @@ -import { Component, OnInit, signal } from '@angular/core'; +import { Component, OnInit, signal, inject, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { WatchListService } from '../../services/watch-list.service'; import { GroupService } from '../../services/group.service'; +import { StorageService } from '../../services/storage.service'; import { ItemType } from '../../models/item.model'; import { Group } from '../../models/group.model'; +import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; @Component({ selector: 'app-add-item', @@ -14,6 +16,10 @@ import { Group } from '../../models/group.model';

Add New Item

+
+ {{ state.error() }} +
+
@@ -97,7 +103,7 @@ import { Group } from '../../models/group.model';
- +
@@ -190,9 +196,33 @@ import { Group } from '../../models/group.model'; .cancel-btn:hover { background: var(--accent-secondary-hover); } + + .message { + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; + } + + .error-message { + background: light-dark(#f8d7da, #721c24); + color: light-dark(#721c24, #f8d7da); + border: 1px solid light-dark(#f5c6cb, #721c24); + } + + .success-message { + background: light-dark(#d4edda, #155724); + color: light-dark(#155724, #d4edda); + border: 1px solid light-dark(#c3e6cb, #155724); + } `] }) export class AddItemComponent implements OnInit { + private readonly storageService = inject(StorageService); + private readonly watchListService = inject(WatchListService); + private readonly groupService = inject(GroupService); + private readonly router = inject(Router); + + state = createAsyncAction(); groups = signal([]); title = ''; @@ -202,21 +232,21 @@ export class AddItemComponent implements OnInit { episode = 1; totalEpisodes: number | undefined; - constructor( - private watchListService: WatchListService, - private groupService: GroupService, - private router: Router - ) {} - - ngOnInit(): void { - this.groups.set(this.groupService.getAllGroups()); - // Ensure ungrouped is selected by default - if (this.groups().length > 0 && !this.groupId) { - const ungrouped = this.groups().find(g => g.id === 'ungrouped'); - this.groupId = ungrouped ? ungrouped.id : this.groups()[0].id; - } + constructor() { + effect(() => { + const data = this.storageService.data(); + if (data) { + this.groups.set(this.groupService.getAllGroups()); + if (this.groups().length > 0 && !this.groupId) { + const ungrouped = this.groups().find(g => g.id === 'ungrouped'); + this.groupId = ungrouped ? ungrouped.id : this.groups()[0].id; + } + } + }); } + ngOnInit(): void {} + onTypeChange(): void { if (this.type === 'movie') { this.season = 1; @@ -225,25 +255,28 @@ export class AddItemComponent implements OnInit { } } - async onSubmit(): Promise { - if (!this.title.trim()) { - return; - } - - await this.watchListService.addItem({ - title: this.title.trim(), - type: this.type, - groupId: this.groupId, - status: 'not-started', - progress: this.type === 'series' ? { - season: this.season, - episode: this.episode, - totalEpisodes: this.totalEpisodes - } : undefined - }); + onSubmit = withAsyncAction( + async () => { + if (!this.title.trim()) { + return; + } - this.router.navigate(['/items']); - } + await this.watchListService.addItem({ + title: this.title.trim(), + type: this.type, + groupId: this.groupId, + status: 'not-started', + progress: this.type === 'series' ? { + season: this.season, + episode: this.episode, + totalEpisodes: this.totalEpisodes + } : undefined + }); + + this.router.navigate(['/items']); + }, + this.state + ); cancel(): void { this.router.navigate(['/items']); diff --git a/src/app/components/item-detail/item-detail.component.ts b/src/app/components/item-detail/item-detail.component.ts index 041d3d3..561d8c6 100644 --- a/src/app/components/item-detail/item-detail.component.ts +++ b/src/app/components/item-detail/item-detail.component.ts @@ -1,19 +1,24 @@ -import { Component, OnInit, signal, computed } from '@angular/core'; +import { Component, OnInit, signal, computed, effect, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router, RouterLink, ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { WatchListService } from '../../services/watch-list.service'; import { GroupService } from '../../services/group.service'; +import { StorageService } from '../../services/storage.service'; import { Item, ItemType } from '../../models/item.model'; import { Group } from '../../models/group.model'; import { ProgressBarComponent } from '../progress-bar/progress-bar.component'; import { TimeAgoComponent } from '../time-ago/time-ago.component'; +import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; @Component({ selector: 'app-item-detail', imports: [CommonModule, FormsModule, RouterLink, ProgressBarComponent, TimeAgoComponent], template: `
+
+ {{ state.error() }} +

{{ item()!.title }}

@@ -329,12 +334,35 @@ import { TimeAgoComponent } from '../time-ago/time-ago.component'; .not-found h2 { margin-bottom: 1rem; } + + .message { + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; + } + + .error-message { + background: light-dark(#f8d7da, #721c24); + color: light-dark(#721c24, #f8d7da); + border: 1px solid light-dark(#f5c6cb, #721c24); + } + + .success-message { + background: light-dark(#d4edda, #155724); + color: light-dark(#155724, #d4edda); + border: 1px solid light-dark(#c3e6cb, #155724); + } `] }) export class ItemDetailComponent implements OnInit { + private readonly storageService = inject(StorageService); + private readonly watchListService = inject(WatchListService); + private readonly groupService = inject(GroupService); + item = signal(null); groups = signal([]); confirmDelete = signal(false); + state = createAsyncAction(); editTitle = ''; editType: ItemType = 'series'; @@ -345,21 +373,30 @@ export class ItemDetailComponent implements OnInit { constructor( private route: ActivatedRoute, - private router: Router, - private watchListService: WatchListService, - private groupService: GroupService - ) {} + private router: Router + ) { + effect(() => { + const data = this.storageService.data(); + if (data) { + this.groups.set(this.groupService.getAllGroups()); + } + }); + } ngOnInit(): void { - const id = this.route.snapshot.paramMap.get('id'); - if (id) { - const item = this.watchListService.getItemById(id); - if (item) { - this.item.set(item); - this.loadEditData(); + effect(() => { + const data = this.storageService.data(); + if (!data) return; + + const id = this.route.snapshot.paramMap.get('id'); + if (id) { + const item = this.watchListService.getItemById(id); + if (item) { + this.item.set(item); + this.loadEditData(); + } } - } - this.groups.set(this.groupService.getAllGroups()); + }); } loadEditData(): void { @@ -385,26 +422,29 @@ export class ItemDetailComponent implements OnInit { } } - async saveChanges(): Promise { - const currentItem = this.item(); - if (!currentItem) return; - if (!this.editTitle.trim()) return; - - const updated: Item = { - ...currentItem, - title: this.editTitle.trim(), - type: this.editType, - groupId: this.editGroupId, - progress: this.editType === 'series' ? { - season: this.editSeason, - episode: this.editEpisode, - totalEpisodes: this.editTotalEpisodes - } : undefined - }; - - await this.watchListService.updateItem(updated); - this.router.navigate(['/items']); - } + saveChanges = withAsyncAction( + async () => { + const currentItem = this.item(); + if (!currentItem) return; + if (!this.editTitle.trim()) return; + + const updated: Item = { + ...currentItem, + title: this.editTitle.trim(), + type: this.editType, + groupId: this.editGroupId, + progress: this.editType === 'series' ? { + season: this.editSeason, + episode: this.editEpisode, + totalEpisodes: this.editTotalEpisodes + } : undefined + }; + + await this.watchListService.updateItem(updated); + this.router.navigate(['/items']); + }, + this.state + ); cancelEdit(): void { this.loadEditData(); @@ -414,37 +454,43 @@ export class ItemDetailComponent implements OnInit { this.confirmDelete.set(false); } - async markWatched(): Promise { - const currentItem = this.item(); - if (currentItem) { + markWatched = withAsyncAction( + async () => { + const currentItem = this.item(); + if (!currentItem) return; await this.watchListService.markWatched(currentItem.id); const updated = this.watchListService.getItemById(currentItem.id); if (updated) { this.item.set(updated); this.loadEditData(); } - } - } - - async markCompleted(): Promise { - const currentItem = this.item(); - if (currentItem) { + }, + this.state + ); + + markCompleted = withAsyncAction( + async () => { + const currentItem = this.item(); + if (!currentItem) return; await this.watchListService.markCompleted(currentItem.id); const updated = this.watchListService.getItemById(currentItem.id); if (updated) { this.item.set(updated); this.loadEditData(); } - } - } - - async deleteItem(): Promise { - const currentItem = this.item(); - if (currentItem) { + }, + this.state + ); + + deleteItem = withAsyncAction( + async () => { + const currentItem = this.item(); + if (!currentItem) return; await this.watchListService.deleteItem(currentItem.id); this.router.navigate(['/items']); - } - } + }, + this.state + ); progressPercent = computed(() => { const currentItem = this.item(); diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index ff1510b..ed66a0a 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -167,13 +167,19 @@ export class SettingsComponent { } async updateShowCompleted(): Promise { + const previousValue = !this.showCompleted; await withAsyncAction( async () => { await this.storageService.updateSettings({ showCompleted: this.showCompleted }); this.state.error.set('Settings saved successfully'); setTimeout(() => this.state.error.set(null), 3000); }, - this.state + this.state, + { + onError: () => { + this.showCompleted = previousValue; + } + } )(); } diff --git a/src/app/utils/async-action.ts b/src/app/utils/async-action.ts index ee15915..4552816 100644 --- a/src/app/utils/async-action.ts +++ b/src/app/utils/async-action.ts @@ -7,6 +7,7 @@ export interface AsyncActionState { export interface AsyncActionOptions { showError?: boolean; + onError?: (error: unknown) => void; } export function createAsyncAction(): AsyncActionState { @@ -22,7 +23,7 @@ export function withAsyncAction) => ReturnType options: AsyncActionOptions = {} ): F { const { busy, error } = state; - const { showError = true } = options; + const { showError = true, onError } = options; return (async (...args: Parameters) => { if (busy()) return; @@ -34,6 +35,7 @@ export function withAsyncAction) => ReturnType if (showError) { error.set(err instanceof Error ? err.message : 'Operation failed'); } + onError?.(err); } finally { busy.set(false); } From cfd88ebe87e5e7de9af28a5f42a8454e03ebf540 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:38:56 +0200 Subject: [PATCH 35/38] refactor: extract duplicated code into shared utility modules Move validateStorageDataStructure, validateGroup, validateItem, validateWatchHistoryEntry, validateMigratedData, migrateDataOnly, ensureUngroupedGroup, and createDefaultData into shared/data-validation.ts and shared/data-migration.ts to eliminate duplication across import-export adapters, storage adapters, and services. --- .../services/import-export.adapter.local.ts | 50 +---- .../services/import-export.adapter.tauri.ts | 44 +---- src/app/services/import-export.service.ts | 177 +----------------- src/app/services/storage.adapter.local.ts | 93 +-------- src/app/services/storage.adapter.tauri.ts | 101 ++-------- src/app/services/storage.service.ts | 17 +- src/app/shared/data-migration.ts | 76 ++++++++ src/app/shared/data-validation.ts | 120 ++++++++++++ 8 files changed, 244 insertions(+), 434 deletions(-) create mode 100644 src/app/shared/data-migration.ts create mode 100644 src/app/shared/data-validation.ts diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts index d1306b3..afa05b5 100644 --- a/src/app/services/import-export.adapter.local.ts +++ b/src/app/services/import-export.adapter.local.ts @@ -1,5 +1,6 @@ import { IImportExportAdapter } from './import-export.adapter'; import { StorageData } from '../models/storage.model'; +import { validateStorageDataStructure } from '../shared/data-validation'; export class LocalImportExportAdapter implements IImportExportAdapter { exportData(data: StorageData): boolean { @@ -21,65 +22,32 @@ export class LocalImportExportAdapter implements IImportExportAdapter { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; - + input.addEventListener('cancel', () => resolve(null)); - + input.onchange = async () => { const file = input.files?.[0]; if (!file) { resolve(null); return; } - + try { const text = await file.text(); const parsed = JSON.parse(text); - - if (!this.validateStorageDataStructure(parsed)) { + + if (!validateStorageDataStructure(parsed)) { throw new Error('Invalid data format'); } - + resolve({ file, data: parsed as StorageData }); } catch (error) { console.error('Import error:', error); reject(error); } }; - + input.click(); }); } - - private validateStorageDataStructure(data: unknown): boolean { - if (!data || typeof data !== 'object') { - return false; - } - - const d = data as Record; - - if ( - typeof d['schemaVersion'] !== 'number' || - typeof d['lastModifiedAt'] !== 'string' || - !d['settings'] || - !d['groups'] || - !d['items'] - ) { - return false; - } - - const settings = d['settings'] as Record; - if (typeof settings['showCompleted'] !== 'boolean') { - return false; - } - - if (typeof d['groups'] !== 'object' || Array.isArray(d['groups'])) { - return false; - } - - if (typeof d['items'] !== 'object' || Array.isArray(d['items'])) { - return false; - } - - return true; - } -} \ No newline at end of file +} diff --git a/src/app/services/import-export.adapter.tauri.ts b/src/app/services/import-export.adapter.tauri.ts index b1f3a7d..3dd7010 100644 --- a/src/app/services/import-export.adapter.tauri.ts +++ b/src/app/services/import-export.adapter.tauri.ts @@ -1,11 +1,12 @@ import { IImportExportAdapter } from './import-export.adapter'; import { StorageData } from '../models/storage.model'; import { save, open } from '@tauri-apps/plugin-dialog'; +import { validateStorageDataStructure } from '../shared/data-validation'; export class TauriImportExportAdapter implements IImportExportAdapter { async exportData(data: StorageData): Promise { const json = JSON.stringify(data, null, 2); - + const filePath = await save({ defaultPath: `watch-list-export-${new Date().toISOString().split('T')[0]}.json`, filters: [{ @@ -40,51 +41,18 @@ export class TauriImportExportAdapter implements IImportExportAdapter { const { readTextFile } = await import('@tauri-apps/plugin-fs'); const content = await readTextFile(selected); const parsed = JSON.parse(content); - - if (!this.validateStorageDataStructure(parsed)) { + + if (!validateStorageDataStructure(parsed)) { throw new Error('Invalid data format'); } const fileName = selected.split(/[/\\]/).pop() || 'import.json'; const file = new File([content], fileName, { type: 'application/json' }); - + return { file, data: parsed as StorageData }; } catch (error) { console.error('Import error:', error); throw error; } } - - private validateStorageDataStructure(data: unknown): boolean { - if (!data || typeof data !== 'object') { - return false; - } - - const d = data as Record; - - if ( - typeof d['schemaVersion'] !== 'number' || - typeof d['lastModifiedAt'] !== 'string' || - !d['settings'] || - !d['groups'] || - !d['items'] - ) { - return false; - } - - const settings = d['settings'] as Record; - if (typeof settings['showCompleted'] !== 'boolean') { - return false; - } - - if (typeof d['groups'] !== 'object' || Array.isArray(d['groups'])) { - return false; - } - - if (typeof d['items'] !== 'object' || Array.isArray(d['items'])) { - return false; - } - - return true; - } -} \ No newline at end of file +} diff --git a/src/app/services/import-export.service.ts b/src/app/services/import-export.service.ts index ffe4bd1..abf67f5 100644 --- a/src/app/services/import-export.service.ts +++ b/src/app/services/import-export.service.ts @@ -1,8 +1,9 @@ import { Injectable, inject } from '@angular/core'; import { StorageService } from './storage.service'; -import { StorageData, CURRENT_SCHEMA_VERSION } from '../models/storage.model'; +import { StorageData } from '../models/storage.model'; import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './import-export.adapter'; -import { Item } from '../models/item.model'; +import { validateStorageDataStructureWithGroups, validateMigratedData } from '../shared/data-validation'; +import { migrateDataOnly, ensureUngroupedGroup } from '../shared/data-migration'; @Injectable({ providedIn: 'root' @@ -25,7 +26,7 @@ export class ImportExportService { const { data } = result; - if (!this.validateStorageDataStructure(data)) { + if (!validateStorageDataStructureWithGroups(data)) { throw new Error('Invalid data format'); } @@ -33,178 +34,14 @@ export class ImportExportService { return false; } - const migrated = this.migrateDataOnly(data); - this.ensureUngroupedGroup(migrated); + const migrated = migrateDataOnly(data); + ensureUngroupedGroup(migrated); - if (!this.validateMigratedData(migrated)) { + if (!validateMigratedData(migrated)) { throw new Error('Invalid migrated data'); } await this.storageService.saveData(migrated); return true; } - - private migrateDataOnly(data: StorageData): StorageData { - if (data.schemaVersion >= CURRENT_SCHEMA_VERSION) { - return data; - } - - let migrated = { ...data }; - - if (migrated.schemaVersion < 2) { - migrated.items = Object.fromEntries( - Object.entries(migrated.items).map(([id, item]) => { - const legacyItem = item as Item & { - lastWatchedAt?: string; - watchHistory?: unknown[]; - progress?: { season: number; episode: number; totalEpisodes?: number }; - }; - let watchHistory = legacyItem.watchHistory as any[] || []; - - let adjustedProgress = legacyItem.progress; - if (adjustedProgress && legacyItem.status !== 'completed') { - adjustedProgress = { - ...adjustedProgress, - episode: adjustedProgress.episode + 1 - }; - } - - if (watchHistory.length === 0 && - (legacyItem.status === 'in-progress' || legacyItem.lastWatchedAt !== legacyItem.createdAt)) { - const entry: any = { date: legacyItem.lastWatchedAt }; - if (legacyItem.type === 'series' && legacyItem.progress) { - entry.season = legacyItem.progress.season; - entry.episode = legacyItem.progress.episode; - } - watchHistory = [entry]; - } - - const { lastWatchedAt, ...itemWithoutLastWatched } = legacyItem; - return [id, { ...itemWithoutLastWatched, watchHistory, progress: adjustedProgress }]; - }) - ); - migrated.schemaVersion = 2; - } - - return migrated; - } - - private ensureUngroupedGroup(data: StorageData): void { - if (!data.groups['ungrouped']) { - data.groups['ungrouped'] = { - id: 'ungrouped', - name: 'Ungrouped', - order: 0 - }; - } - } - - private validateStorageDataStructure(data: unknown): boolean { - if (!data || typeof data !== 'object') { - return false; - } - - const d = data as Record; - - if ( - typeof d['schemaVersion'] !== 'number' || - typeof d['lastModifiedAt'] !== 'string' || - !d['settings'] || - !d['groups'] || - !d['items'] - ) { - return false; - } - - const settings = d['settings'] as Record; - if (typeof settings['showCompleted'] !== 'boolean') { - return false; - } - - if (typeof d['groups'] !== 'object' || Array.isArray(d['groups'])) { - return false; - } - const groups = d['groups'] as Record; - for (const group of Object.values(groups)) { - if (!this.validateGroup(group)) { - return false; - } - } - - if (typeof d['items'] !== 'object' || Array.isArray(d['items'])) { - return false; - } - - return true; - } - - private validateGroup(group: unknown): boolean { - if (!group || typeof group !== 'object') { - return false; - } - const g = group as Record; - return ( - typeof g['id'] === 'string' && - typeof g['name'] === 'string' && - typeof g['order'] === 'number' - ); - } - - private validateMigratedData(data: StorageData): boolean { - const items = data.items; - for (const item of Object.values(items)) { - if (!this.validateItem(item)) { - return false; - } - } - return true; - } - - private validateItem(item: unknown): boolean { - if (!item || typeof item !== 'object') { - return false; - } - const i = item as Record; - - if ( - typeof i['id'] !== 'string' || - typeof i['title'] !== 'string' || - typeof i['groupId'] !== 'string' || - typeof i['status'] !== 'string' || - typeof i['createdAt'] !== 'string' || - (i['type'] !== 'series' && i['type'] !== 'movie') - ) { - return false; - } - - if (!Array.isArray(i['watchHistory'])) { - return false; - } - for (const entry of i['watchHistory']) { - if (!this.validateWatchHistoryEntry(entry)) { - return false; - } - } - - if (i['type'] === 'series' && i['progress']) { - const progress = i['progress'] as Record; - if ( - typeof progress['season'] !== 'number' || - typeof progress['episode'] !== 'number' || - (progress['totalEpisodes'] !== undefined && typeof progress['totalEpisodes'] !== 'number') - ) { - return false; - } - } - - return true; - } - - private validateWatchHistoryEntry(entry: unknown): boolean { - if (!entry || typeof entry !== 'object') { - return false; - } - const e = entry as Record; - return typeof e['date'] === 'string'; - } } diff --git a/src/app/services/storage.adapter.local.ts b/src/app/services/storage.adapter.local.ts index bf38b8a..1d50430 100644 --- a/src/app/services/storage.adapter.local.ts +++ b/src/app/services/storage.adapter.local.ts @@ -1,28 +1,27 @@ import { IStorageAdapter } from './storage.adapter'; -import { StorageData, CURRENT_SCHEMA_VERSION } from '../models/storage.model'; -import { Item } from '../models/item.model'; -import { Group } from '../models/group.model'; +import { StorageData } from '../models/storage.model'; +import { migrateDataOnly, ensureUngroupedGroup, createDefaultData } from '../shared/data-migration'; const STORAGE_KEY = 'watchListData'; export class LocalStorageAdapter implements IStorageAdapter { load(): StorageData { const stored = localStorage.getItem(STORAGE_KEY); - + if (stored) { try { const parsed = JSON.parse(stored) as StorageData; - const migrated = this.migrateDataOnly(parsed); - this.ensureUngroupedGroup(migrated); + const migrated = migrateDataOnly(parsed); + ensureUngroupedGroup(migrated); localStorage.setItem(STORAGE_KEY, JSON.stringify(migrated)); return migrated; } catch (error) { console.error('Failed to parse stored data:', error); - return this.createDefaultData(); + return createDefaultData(); } } - - const defaultData = this.createDefaultData(); + + const defaultData = createDefaultData(); this.save(defaultData); return defaultData; } @@ -30,78 +29,4 @@ export class LocalStorageAdapter implements IStorageAdapter { save(data: StorageData): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } - - private migrateDataOnly(data: StorageData): StorageData { - if (data.schemaVersion >= CURRENT_SCHEMA_VERSION) { - return data; - } - - let migrated = { ...data }; - - if (migrated.schemaVersion < 2) { - migrated.items = Object.fromEntries( - Object.entries(migrated.items).map(([id, item]) => { - const legacyItem = item as Item & { - lastWatchedAt?: string; - watchHistory?: unknown[]; - progress?: { season: number; episode: number; totalEpisodes?: number }; - }; - let watchHistory = legacyItem.watchHistory as any[] || []; - - let adjustedProgress = legacyItem.progress; - if (adjustedProgress && legacyItem.status !== 'completed') { - adjustedProgress = { - ...adjustedProgress, - episode: adjustedProgress.episode + 1 - }; - } - - if (watchHistory.length === 0 && - (legacyItem.status === 'in-progress' || legacyItem.lastWatchedAt !== legacyItem.createdAt)) { - const entry: any = { date: legacyItem.lastWatchedAt }; - if (legacyItem.type === 'series' && legacyItem.progress) { - entry.season = legacyItem.progress.season; - entry.episode = legacyItem.progress.episode; - } - watchHistory = [entry]; - } - - const { lastWatchedAt, ...itemWithoutLastWatched } = legacyItem; - return [id, { ...itemWithoutLastWatched, watchHistory, progress: adjustedProgress }]; - }) - ); - migrated.schemaVersion = 2; - } - - return migrated; - } - - private createDefaultData(): StorageData { - const now = new Date().toISOString(); - return { - schemaVersion: CURRENT_SCHEMA_VERSION, - lastModifiedAt: now, - settings: { - showCompleted: false - }, - groups: { - ungrouped: { - id: 'ungrouped', - name: 'Ungrouped', - order: 0 - } - }, - items: {} - }; - } - - private ensureUngroupedGroup(data: StorageData): void { - if (!data.groups['ungrouped']) { - data.groups['ungrouped'] = { - id: 'ungrouped', - name: 'Ungrouped', - order: 0 - }; - } - } -} \ No newline at end of file +} diff --git a/src/app/services/storage.adapter.tauri.ts b/src/app/services/storage.adapter.tauri.ts index 6c0b4dd..96fbae9 100644 --- a/src/app/services/storage.adapter.tauri.ts +++ b/src/app/services/storage.adapter.tauri.ts @@ -1,8 +1,7 @@ import { IStorageAdapter } from './storage.adapter'; -import { StorageData, CURRENT_SCHEMA_VERSION } from '../models/storage.model'; -import { Item } from '../models/item.model'; -import { Group } from '../models/group.model'; +import { StorageData } from '../models/storage.model'; import { BaseDirectory, exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'; +import { migrateDataOnly, ensureUngroupedGroup, createDefaultData } from '../shared/data-migration'; const STORAGE_DIR = 'watch-list'; const STORAGE_FILE = 'data.json'; @@ -22,36 +21,36 @@ export class TauriFileStorageAdapter implements IStorageAdapter { await this.ensureDir(); const path = `${STORAGE_DIR}/${STORAGE_FILE}`; const fileExists = await exists(path, { baseDir: BaseDirectory.AppData }); - + if (!fileExists) { - const defaultData = this.createDefaultData(); + const defaultData = createDefaultData(); await this.save(defaultData); return defaultData; } const content = await readTextFile(path, { baseDir: BaseDirectory.AppData }); const parsed = JSON.parse(content) as StorageData; - - const migrated = this.migrateDataOnly(parsed); - this.ensureUngroupedGroup(migrated); - + + const migrated = migrateDataOnly(parsed); + ensureUngroupedGroup(migrated); + this.cache = migrated; await this.save(migrated); - + return migrated; } catch (error) { console.error('Failed to load file data:', error); - return this.createDefaultData(); + return createDefaultData(); } } async save(data: StorageData): Promise { try { await this.ensureDir(); - + const path = `${STORAGE_DIR}/${STORAGE_FILE}`; await writeTextFile(path, JSON.stringify(data, null, 2), { baseDir: BaseDirectory.AppData }); - + this.cache = data; } catch (error) { console.error('Failed to save file data:', error); @@ -65,78 +64,4 @@ export class TauriFileStorageAdapter implements IStorageAdapter { } throw new Error('Data not loaded. Call load() first.'); } - - private migrateDataOnly(data: StorageData): StorageData { - if (data.schemaVersion >= CURRENT_SCHEMA_VERSION) { - return data; - } - - let migrated = { ...data }; - - if (migrated.schemaVersion < 2) { - migrated.items = Object.fromEntries( - Object.entries(migrated.items).map(([id, item]) => { - const legacyItem = item as Item & { - lastWatchedAt?: string; - watchHistory?: unknown[]; - progress?: { season: number; episode: number; totalEpisodes?: number }; - }; - let watchHistory = legacyItem.watchHistory as any[] || []; - - let adjustedProgress = legacyItem.progress; - if (adjustedProgress && legacyItem.status !== 'completed') { - adjustedProgress = { - ...adjustedProgress, - episode: adjustedProgress.episode + 1 - }; - } - - if (watchHistory.length === 0 && - (legacyItem.status === 'in-progress' || legacyItem.lastWatchedAt !== legacyItem.createdAt)) { - const entry: any = { date: legacyItem.lastWatchedAt }; - if (legacyItem.type === 'series' && legacyItem.progress) { - entry.season = legacyItem.progress.season; - entry.episode = legacyItem.progress.episode; - } - watchHistory = [entry]; - } - - const { lastWatchedAt, ...itemWithoutLastWatched } = legacyItem; - return [id, { ...itemWithoutLastWatched, watchHistory, progress: adjustedProgress }]; - }) - ); - migrated.schemaVersion = 2; - } - - return migrated; - } - - private createDefaultData(): StorageData { - const now = new Date().toISOString(); - return { - schemaVersion: CURRENT_SCHEMA_VERSION, - lastModifiedAt: now, - settings: { - showCompleted: false - }, - groups: { - ungrouped: { - id: 'ungrouped', - name: 'Ungrouped', - order: 0 - } - }, - items: {} - }; - } - - private ensureUngroupedGroup(data: StorageData): void { - if (!data.groups['ungrouped']) { - data.groups['ungrouped'] = { - id: 'ungrouped', - name: 'Ungrouped', - order: 0 - }; - } - } -} \ No newline at end of file +} diff --git a/src/app/services/storage.service.ts b/src/app/services/storage.service.ts index d98c295..a854917 100644 --- a/src/app/services/storage.service.ts +++ b/src/app/services/storage.service.ts @@ -3,6 +3,7 @@ import { StorageData, Settings } from '../models/storage.model'; import { Item } from '../models/item.model'; import { Group } from '../models/group.model'; import { STORAGE_ADAPTER } from './storage.adapter'; +import { ensureUngroupedGroup } from '../shared/data-migration'; @Injectable({ providedIn: 'root' @@ -13,7 +14,7 @@ export class StorageService { readonly dataResource = resource({ loader: async () => { const loaded = await this.adapter.load(); - this.ensureUngroupedGroup(loaded); + ensureUngroupedGroup(loaded); return loaded; } }); @@ -30,21 +31,11 @@ export class StorageService { ...newData, lastModifiedAt: new Date().toISOString() }; - this.ensureUngroupedGroup(updated); + ensureUngroupedGroup(updated); await this.adapter.save(updated); this.dataResource.set(updated); } - private ensureUngroupedGroup(data: StorageData): void { - if (!data.groups['ungrouped']) { - data.groups['ungrouped'] = { - id: 'ungrouped', - name: 'Ungrouped', - order: 0 - }; - } - } - getData(): StorageData { const current = this.data(); if (current) { @@ -79,4 +70,4 @@ export class StorageService { settings: { ...data.settings, ...settings } }); } -} \ No newline at end of file +} diff --git a/src/app/shared/data-migration.ts b/src/app/shared/data-migration.ts new file mode 100644 index 0000000..ab0dc8f --- /dev/null +++ b/src/app/shared/data-migration.ts @@ -0,0 +1,76 @@ +import { StorageData, CURRENT_SCHEMA_VERSION } from '../models/storage.model'; +import { Item } from '../models/item.model'; + +export function migrateDataOnly(data: StorageData): StorageData { + if (data.schemaVersion >= CURRENT_SCHEMA_VERSION) { + return data; + } + + let migrated = { ...data }; + + if (migrated.schemaVersion < 2) { + migrated.items = Object.fromEntries( + Object.entries(migrated.items).map(([id, item]) => { + const legacyItem = item as Item & { + lastWatchedAt?: string; + watchHistory?: unknown[]; + progress?: { season: number; episode: number; totalEpisodes?: number }; + }; + let watchHistory = legacyItem.watchHistory as any[] || []; + + let adjustedProgress = legacyItem.progress; + if (adjustedProgress && legacyItem.status !== 'completed') { + adjustedProgress = { + ...adjustedProgress, + episode: adjustedProgress.episode + 1 + }; + } + + if (watchHistory.length === 0 && + (legacyItem.status === 'in-progress' || legacyItem.lastWatchedAt !== legacyItem.createdAt)) { + const entry: any = { date: legacyItem.lastWatchedAt }; + if (legacyItem.type === 'series' && legacyItem.progress) { + entry.season = legacyItem.progress.season; + entry.episode = legacyItem.progress.episode; + } + watchHistory = [entry]; + } + + const { lastWatchedAt, ...itemWithoutLastWatched } = legacyItem; + return [id, { ...itemWithoutLastWatched, watchHistory, progress: adjustedProgress }]; + }) + ); + migrated.schemaVersion = 2; + } + + return migrated; +} + +export function ensureUngroupedGroup(data: StorageData): void { + if (!data.groups['ungrouped']) { + data.groups['ungrouped'] = { + id: 'ungrouped', + name: 'Ungrouped', + order: 0 + }; + } +} + +export function createDefaultData(): StorageData { + const now = new Date().toISOString(); + return { + schemaVersion: CURRENT_SCHEMA_VERSION, + lastModifiedAt: now, + settings: { + showCompleted: false + }, + groups: { + ungrouped: { + id: 'ungrouped', + name: 'Ungrouped', + order: 0 + } + }, + items: {} + }; +} diff --git a/src/app/shared/data-validation.ts b/src/app/shared/data-validation.ts new file mode 100644 index 0000000..20812f2 --- /dev/null +++ b/src/app/shared/data-validation.ts @@ -0,0 +1,120 @@ +import { StorageData } from '../models/storage.model'; + +export function validateStorageDataStructure(data: unknown): boolean { + if (!data || typeof data !== 'object') { + return false; + } + + const d = data as Record; + + if ( + typeof d['schemaVersion'] !== 'number' || + typeof d['lastModifiedAt'] !== 'string' || + !d['settings'] || + !d['groups'] || + !d['items'] + ) { + return false; + } + + const settings = d['settings'] as Record; + if (typeof settings['showCompleted'] !== 'boolean') { + return false; + } + + if (typeof d['groups'] !== 'object' || Array.isArray(d['groups'])) { + return false; + } + + if (typeof d['items'] !== 'object' || Array.isArray(d['items'])) { + return false; + } + + return true; +} + +export function validateStorageDataStructureWithGroups(data: unknown): boolean { + if (!validateStorageDataStructure(data)) { + return false; + } + + const d = data as Record; + const groups = d['groups'] as Record; + for (const group of Object.values(groups)) { + if (!validateGroup(group)) { + return false; + } + } + + return true; +} + +export function validateGroup(group: unknown): boolean { + if (!group || typeof group !== 'object') { + return false; + } + const g = group as Record; + return ( + typeof g['id'] === 'string' && + typeof g['name'] === 'string' && + typeof g['order'] === 'number' + ); +} + +export function validateItem(item: unknown): boolean { + if (!item || typeof item !== 'object') { + return false; + } + const i = item as Record; + + if ( + typeof i['id'] !== 'string' || + typeof i['title'] !== 'string' || + typeof i['groupId'] !== 'string' || + typeof i['status'] !== 'string' || + typeof i['createdAt'] !== 'string' || + (i['type'] !== 'series' && i['type'] !== 'movie') + ) { + return false; + } + + if (!Array.isArray(i['watchHistory'])) { + return false; + } + for (const entry of i['watchHistory']) { + if (!validateWatchHistoryEntry(entry)) { + return false; + } + } + + if (i['type'] === 'series' && i['progress']) { + const progress = i['progress'] as Record; + if ( + typeof progress['season'] !== 'number' || + typeof progress['episode'] !== 'number' || + (progress['totalEpisodes'] !== undefined && typeof progress['totalEpisodes'] !== 'number') + ) { + return false; + } + } + + return true; +} + +export function validateWatchHistoryEntry(entry: unknown): boolean { + if (!entry || typeof entry !== 'object') { + return false; + } + const e = entry as Record; + return typeof e['date'] === 'string'; +} + +export function validateMigratedData(data: StorageData): boolean { + const items = data.items; + for (const item of Object.values(items)) { + if (!validateItem(item)) { + return false; + } + } + return true; +} From c7e60fc47b39d314e102beb47938ddd0f1df80b6 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:47:14 +0200 Subject: [PATCH 36/38] refactor: simplify withAsyncAction usage by removing unnecessary IIFEs Remove redundant async/await wrapper and IIFE pattern from withAsyncAction calls in: - GroupManagerComponent (createGroup, saveEdit, deleteGroup, moveUp, moveDown) - AddItemComponent (onSubmit already correct) - ItemListComponent (markWatched, markCompleted already correct) This makes the async handling more direct and readable while preserving identical functionality. --- .../group-manager/group-manager.component.ts | 136 +++++++++--------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/src/app/components/group-manager/group-manager.component.ts b/src/app/components/group-manager/group-manager.component.ts index c1e174f..1889c4b 100644 --- a/src/app/components/group-manager/group-manager.component.ts +++ b/src/app/components/group-manager/group-manager.component.ts @@ -331,85 +331,85 @@ export class GroupManagerComponent implements OnInit { return [...this.groups()].sort((a, b) => a.order - b.order); } - async createGroup(): Promise { - if (!this.newGroupName.trim()) { - return; - } - await withAsyncAction( - async () => { - await this.groupService.createGroup(this.newGroupName.trim()); - this.newGroupName = ''; - }, - this.state - )(); - } + async createGroup(): Promise { + if (!this.newGroupName.trim()) { + return; + } + await withAsyncAction( + async () => { + await this.groupService.createGroup(this.newGroupName.trim()); + this.newGroupName = ''; + }, + this.state + ); + } editGroup(group: Group): void { this.editingGroup.set(group); this.editGroupName = group.name; } - async saveEdit(): Promise { - const group = this.editingGroup(); - if (group && this.editGroupName.trim()) { - await withAsyncAction( - async () => { - await this.groupService.updateGroup({ - ...group, - name: this.editGroupName.trim() - }); - this.cancelEdit(); - }, - this.state - )(); - } - } + async saveEdit(): Promise { + const group = this.editingGroup(); + if (group && this.editGroupName.trim()) { + await withAsyncAction( + async () => { + await this.groupService.updateGroup({ + ...group, + name: this.editGroupName.trim() + }); + this.cancelEdit(); + }, + this.state + ); + } + } cancelEdit(): void { this.editingGroup.set(null); this.editGroupName = ''; } - async deleteGroup(groupId: string): Promise { - if (!confirm('Are you sure you want to delete this group? Items will be moved to "Ungrouped".')) { - return; - } - await withAsyncAction( - async () => { - await this.groupService.deleteGroup(groupId); - }, - this.state - )(); - } - - async moveUp(groupId: string): Promise { - const sorted = this.sortedGroups(); - const index = sorted.findIndex(g => g.id === groupId); - if (index > 0) { - const groupIds = sorted.map(g => g.id); - [groupIds[index], groupIds[index - 1]] = [groupIds[index - 1], groupIds[index]]; - await withAsyncAction( - async () => { - await this.groupService.reorderGroups(groupIds); - }, - this.state - )(); - } - } - - async moveDown(groupId: string): Promise { - const sorted = this.sortedGroups(); - const index = sorted.findIndex(g => g.id === groupId); - if (index < sorted.length - 1) { - const groupIds = sorted.map(g => g.id); - [groupIds[index], groupIds[index + 1]] = [groupIds[index + 1], groupIds[index]]; - await withAsyncAction( - async () => { - await this.groupService.reorderGroups(groupIds); - }, - this.state - )(); - } - } + async deleteGroup(groupId: string): Promise { + if (!confirm('Are you sure you want to delete this group? Items will be moved to "Ungrouped".')) { + return; + } + await withAsyncAction( + async () => { + await this.groupService.deleteGroup(groupId); + }, + this.state + ); + } + + async moveUp(groupId: string): Promise { + const sorted = this.sortedGroups(); + const index = sorted.findIndex(g => g.id === groupId); + if (index > 0) { + const groupIds = sorted.map(g => g.id); + [groupIds[index], groupIds[index - 1]] = [groupIds[index - 1], groupIds[index]]; + await withAsyncAction( + async () => { + await this.groupService.reorderGroups(groupIds); + }, + this.state + ); + } + } + + async moveDown(groupId: string): Promise { + const sorted = this.sortedGroups(); + const index = sorted.findIndex(g => g.id === groupId); + if (index < sorted.length - 1) { + const groupIds = sorted.map(g => g.id); + [groupIds[index], groupIds[index + 1]] = [groupIds[index + 1], groupIds[index]]; + await withAsyncAction( + async () => { + await this.groupService.reorderGroups(groupIds); + }, + this.state + ); + } + } } From 47d1be38a8189350c12618a7259d2d47ce3de658 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:10:45 +0200 Subject: [PATCH 37/38] refactor: replace error signal with typed message signal and simplify async error handling - Rename error signal to message with {text, type} shape to eliminate fragile .includes('success') string matching in templates - Unify SettingsComponent to use withAsyncAction consistently instead of manual try/catch with setTimeout - Remove redundant try/catch in Tauri save and import adapters that only logged and re-threw - Remove duplicate adapter-level validation in import adapters (the service already validates more thoroughly) --- .../components/add-item/add-item.component.ts | 4 +- .../group-manager/group-manager.component.ts | 4 +- src/app/components/home/home.component.ts | 2 +- .../item-detail/item-detail.component.ts | 4 +- .../item-list/item-list.component.ts | 2 +- .../components/settings/settings.component.ts | 68 ++++++++----------- .../services/import-export.adapter.local.ts | 18 ++--- .../services/import-export.adapter.tauri.ts | 22 ++---- src/app/services/storage.adapter.tauri.ts | 13 ++-- src/app/utils/async-action.ts | 30 +++++--- 10 files changed, 71 insertions(+), 96 deletions(-) diff --git a/src/app/components/add-item/add-item.component.ts b/src/app/components/add-item/add-item.component.ts index 856ec34..5ed358d 100644 --- a/src/app/components/add-item/add-item.component.ts +++ b/src/app/components/add-item/add-item.component.ts @@ -16,8 +16,8 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action';

Add New Item

-
- {{ state.error() }} +
+ {{ state.message()?.text }}
diff --git a/src/app/components/group-manager/group-manager.component.ts b/src/app/components/group-manager/group-manager.component.ts index 1889c4b..51ca01d 100644 --- a/src/app/components/group-manager/group-manager.component.ts +++ b/src/app/components/group-manager/group-manager.component.ts @@ -13,8 +13,8 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action';

Group Management

-
- {{ state.error() }} +
+ {{ state.message()?.text }}
diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts index 0547286..dcd9be2 100644 --- a/src/app/components/home/home.component.ts +++ b/src/app/components/home/home.component.ts @@ -14,7 +14,7 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action';

What should I watch now?

-
{{ state.error() }}
+
{{ state.message()?.text }}
diff --git a/src/app/components/item-detail/item-detail.component.ts b/src/app/components/item-detail/item-detail.component.ts index 561d8c6..9c079a2 100644 --- a/src/app/components/item-detail/item-detail.component.ts +++ b/src/app/components/item-detail/item-detail.component.ts @@ -16,8 +16,8 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; imports: [CommonModule, FormsModule, RouterLink, ProgressBarComponent, TimeAgoComponent], template: `
-
- {{ state.error() }} +
+ {{ state.message()?.text }}
diff --git a/src/app/components/item-list/item-list.component.ts b/src/app/components/item-list/item-list.component.ts index 6e5fe00..899d670 100644 --- a/src/app/components/item-list/item-list.component.ts +++ b/src/app/components/item-list/item-list.component.ts @@ -19,7 +19,7 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; Add Item
-
{{ state.error() }}
+
{{ state.message()?.text }}
-
-
- {{ state.error() }} +
+
+ {{ state.message()?.text }}
@@ -166,50 +166,42 @@ export class SettingsComponent { }); } - async updateShowCompleted(): Promise { - const previousValue = !this.showCompleted; - await withAsyncAction( - async () => { + updateShowCompleted = withAsyncAction( + async () => { + const previousValue = !this.showCompleted; + try { await this.storageService.updateSettings({ showCompleted: this.showCompleted }); - this.state.error.set('Settings saved successfully'); - setTimeout(() => this.state.error.set(null), 3000); - }, - this.state, - { - onError: () => { - this.showCompleted = previousValue; - } + this.state.message.set({ text: 'Settings saved successfully', type: 'success' }); + setTimeout(() => this.state.message.set(null), 3000); + } catch { + this.showCompleted = previousValue; + throw new Error('Failed to update settings'); } - )(); - } + }, + this.state + ); - async exportData(): Promise { - try { + exportData = withAsyncAction( + async () => { const exported = await this.importExportService.exportData(); if (exported) { - this.state.error.set('Data exported successfully'); - setTimeout(() => this.state.error.set(null), 3000); + this.state.message.set({ text: 'Data exported successfully', type: 'success' }); + setTimeout(() => this.state.message.set(null), 3000); } - } catch (error) { - this.state.error.set('Failed to export data'); - setTimeout(() => this.state.error.set(null), 5000); - } - } + }, + this.state + ); - async importData(): Promise { - this.state.error.set(null); - - try { + importData = withAsyncAction( + async () => { + this.state.message.set(null); const imported = await this.importExportService.importData(); if (imported) { - this.state.error.set('Data imported successfully'); - setTimeout(() => this.state.error.set(null), 3000); + this.state.message.set({ text: 'Data imported successfully', type: 'success' }); + setTimeout(() => this.state.message.set(null), 3000); } - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to import data'; - this.state.error.set(`Import failed: ${message}`); - setTimeout(() => this.state.error.set(null), 5000); - } - } + }, + this.state + ); } diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts index afa05b5..a1241bb 100644 --- a/src/app/services/import-export.adapter.local.ts +++ b/src/app/services/import-export.adapter.local.ts @@ -1,6 +1,5 @@ import { IImportExportAdapter } from './import-export.adapter'; import { StorageData } from '../models/storage.model'; -import { validateStorageDataStructure } from '../shared/data-validation'; export class LocalImportExportAdapter implements IImportExportAdapter { exportData(data: StorageData): boolean { @@ -18,7 +17,7 @@ export class LocalImportExportAdapter implements IImportExportAdapter { } async importData(): Promise<{ file: File; data: StorageData } | null> { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; @@ -32,19 +31,10 @@ export class LocalImportExportAdapter implements IImportExportAdapter { return; } - try { - const text = await file.text(); - const parsed = JSON.parse(text); + const text = await file.text(); + const parsed = JSON.parse(text); - if (!validateStorageDataStructure(parsed)) { - throw new Error('Invalid data format'); - } - - resolve({ file, data: parsed as StorageData }); - } catch (error) { - console.error('Import error:', error); - reject(error); - } + resolve({ file, data: parsed as StorageData }); }; input.click(); diff --git a/src/app/services/import-export.adapter.tauri.ts b/src/app/services/import-export.adapter.tauri.ts index 3dd7010..a0d34ef 100644 --- a/src/app/services/import-export.adapter.tauri.ts +++ b/src/app/services/import-export.adapter.tauri.ts @@ -1,7 +1,6 @@ import { IImportExportAdapter } from './import-export.adapter'; import { StorageData } from '../models/storage.model'; import { save, open } from '@tauri-apps/plugin-dialog'; -import { validateStorageDataStructure } from '../shared/data-validation'; export class TauriImportExportAdapter implements IImportExportAdapter { async exportData(data: StorageData): Promise { @@ -37,22 +36,13 @@ export class TauriImportExportAdapter implements IImportExportAdapter { return null; } - try { - const { readTextFile } = await import('@tauri-apps/plugin-fs'); - const content = await readTextFile(selected); - const parsed = JSON.parse(content); + const { readTextFile } = await import('@tauri-apps/plugin-fs'); + const content = await readTextFile(selected); + const parsed = JSON.parse(content); - if (!validateStorageDataStructure(parsed)) { - throw new Error('Invalid data format'); - } + const fileName = selected.split(/[/\\]/).pop() || 'import.json'; + const file = new File([content], fileName, { type: 'application/json' }); - const fileName = selected.split(/[/\\]/).pop() || 'import.json'; - const file = new File([content], fileName, { type: 'application/json' }); - - return { file, data: parsed as StorageData }; - } catch (error) { - console.error('Import error:', error); - throw error; - } + return { file, data: parsed as StorageData }; } } diff --git a/src/app/services/storage.adapter.tauri.ts b/src/app/services/storage.adapter.tauri.ts index 96fbae9..2c6d6e2 100644 --- a/src/app/services/storage.adapter.tauri.ts +++ b/src/app/services/storage.adapter.tauri.ts @@ -45,17 +45,12 @@ export class TauriFileStorageAdapter implements IStorageAdapter { } async save(data: StorageData): Promise { - try { - await this.ensureDir(); + await this.ensureDir(); - const path = `${STORAGE_DIR}/${STORAGE_FILE}`; - await writeTextFile(path, JSON.stringify(data, null, 2), { baseDir: BaseDirectory.AppData }); + const path = `${STORAGE_DIR}/${STORAGE_FILE}`; + await writeTextFile(path, JSON.stringify(data, null, 2), { baseDir: BaseDirectory.AppData }); - this.cache = data; - } catch (error) { - console.error('Failed to save file data:', error); - throw error; - } + this.cache = data; } loadSync(): StorageData { diff --git a/src/app/utils/async-action.ts b/src/app/utils/async-action.ts index 4552816..28151b9 100644 --- a/src/app/utils/async-action.ts +++ b/src/app/utils/async-action.ts @@ -1,19 +1,24 @@ import { WritableSignal, signal } from '@angular/core'; +export interface ActionMessage { + text: string; + type: 'success' | 'error'; +} + export interface AsyncActionState { busy: WritableSignal; - error: WritableSignal; + message: WritableSignal; } export interface AsyncActionOptions { - showError?: boolean; + showMessage?: boolean; onError?: (error: unknown) => void; } export function createAsyncAction(): AsyncActionState { return { busy: signal(false), - error: signal(null) + message: signal(null) }; } @@ -22,18 +27,21 @@ export function withAsyncAction) => ReturnType state: AsyncActionState, options: AsyncActionOptions = {} ): F { - const { busy, error } = state; - const { showError = true, onError } = options; + const { busy, message } = state; + const { showMessage = true, onError } = options; return (async (...args: Parameters) => { if (busy()) return; busy.set(true); - if (showError) error.set(null); + if (showMessage) message.set(null); try { await action(...args); } catch (err) { - if (showError) { - error.set(err instanceof Error ? err.message : 'Operation failed'); + if (showMessage) { + message.set({ + text: err instanceof Error ? err.message : 'Operation failed', + type: 'error' + }); } onError?.(err); } finally { @@ -42,6 +50,6 @@ export function withAsyncAction) => ReturnType }) as F; } -export function clearError(error: WritableSignal) { - error.set(null); -} \ No newline at end of file +export function clearMessage(message: WritableSignal) { + message.set(null); +} From 94ac8035bff2301e64004d4f353151c153ff4b8f Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:30:54 +0200 Subject: [PATCH 38/38] fix: move effect into injection context, guard computed against null data, and fix hanging promise on parse error - Move effect() from ngOnInit into constructor in ItemDetailComponent (Angular effect() requires an injection context) - Guard groups computed in ItemListComponent with null check to prevent 'Data not loaded' throw during initial async resource load - Add try/catch with reject() in LocalImportExportAdapter so JSON parse errors reject the promise instead of hanging indefinitely --- .../components/item-detail/item-detail.component.ts | 6 ++---- src/app/components/item-list/item-list.component.ts | 4 ++-- src/app/services/import-export.adapter.local.ts | 13 ++++++++----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/app/components/item-detail/item-detail.component.ts b/src/app/components/item-detail/item-detail.component.ts index 9c079a2..260c1b8 100644 --- a/src/app/components/item-detail/item-detail.component.ts +++ b/src/app/components/item-detail/item-detail.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, signal, computed, effect, inject } from '@angular/core'; +import { Component, signal, computed, effect, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router, RouterLink, ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; @@ -354,7 +354,7 @@ import { createAsyncAction, withAsyncAction } from '../../utils/async-action'; } `] }) -export class ItemDetailComponent implements OnInit { +export class ItemDetailComponent { private readonly storageService = inject(StorageService); private readonly watchListService = inject(WatchListService); private readonly groupService = inject(GroupService); @@ -381,9 +381,7 @@ export class ItemDetailComponent implements OnInit { this.groups.set(this.groupService.getAllGroups()); } }); - } - ngOnInit(): void { effect(() => { const data = this.storageService.data(); if (!data) return; diff --git a/src/app/components/item-list/item-list.component.ts b/src/app/components/item-list/item-list.component.ts index 899d670..e0f41d0 100644 --- a/src/app/components/item-list/item-list.component.ts +++ b/src/app/components/item-list/item-list.component.ts @@ -91,8 +91,8 @@ export class ItemListComponent { expandedGroups = signal>(new Set()); groups = computed(() => { - this.storageService.data(); - return this.groupService.getAllGroups(); + const data = this.storageService.data(); + return data ? this.groupService.getAllGroups() : []; }); allItems = computed(() => { diff --git a/src/app/services/import-export.adapter.local.ts b/src/app/services/import-export.adapter.local.ts index a1241bb..0bed5c7 100644 --- a/src/app/services/import-export.adapter.local.ts +++ b/src/app/services/import-export.adapter.local.ts @@ -17,7 +17,7 @@ export class LocalImportExportAdapter implements IImportExportAdapter { } async importData(): Promise<{ file: File; data: StorageData } | null> { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; @@ -31,10 +31,13 @@ export class LocalImportExportAdapter implements IImportExportAdapter { return; } - const text = await file.text(); - const parsed = JSON.parse(text); - - resolve({ file, data: parsed as StorageData }); + try { + const text = await file.text(); + const parsed = JSON.parse(text); + resolve({ file, data: parsed as StorageData }); + } catch (err) { + reject(err); + } }; input.click();