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/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.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", ] 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..5749f95 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -4,11 +4,19 @@ 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: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), + { provide: STORAGE_ADAPTER, useValue: storageAdapter }, + { provide: IMPORT_EXPORT_ADAPTER, useValue: importExportAdapter }, ...(environment.enableServiceWorker ? [ provideServiceWorker('ngsw-worker.js', { @@ -18,4 +26,4 @@ export const appConfig: ApplicationConfig = { ] : []) ] -}; +}; \ No newline at end of file diff --git a/src/app/components/add-item/add-item.component.ts b/src/app/components/add-item/add-item.component.ts index c303615..5ed358d 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.message()?.text }} +
+
@@ -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 { } } - onSubmit(): void { - if (!this.title.trim()) { - return; - } - - 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/group-manager/group-manager.component.ts b/src/app/components/group-manager/group-manager.component.ts index dda6c51..51ca01d 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', @@ -11,6 +13,10 @@ import { Group } from '../../models/group.model';

Group Management

+
+ {{ state.message()?.text }} +
+

Create New Group

@@ -21,8 +27,9 @@ import { Group } from '../../models/group.model'; placeholder="Group name" required class="group-input" + [disabled]="state.busy()" /> - +
@@ -38,6 +45,7 @@ import { Group } from '../../models/group.model'; *ngIf="i > 0" (click)="moveUp(group.id)" class="action-btn" + [disabled]="state.busy()" title="Move up" > ↑ @@ -80,7 +88,7 @@ import { Group } from '../../models/group.model'; class="group-input" /> @@ -273,18 +281,46 @@ 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 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(() => { + const data = this.storageService.data(); + if (data) { + this.loadGroups(); + } + }); + } ngOnInit(): void { - this.loadGroups(); } loadGroups(): void { @@ -295,68 +331,85 @@ export class GroupManagerComponent implements OnInit { return [...this.groups()].sort((a, b) => a.order - b.order); } - createGroup(): void { - if (!this.newGroupName.trim()) { - return; - } - this.groupService.createGroup(this.newGroupName.trim()); - this.newGroupName = ''; - this.loadGroups(); - } + 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; } - saveEdit(): void { - const group = this.editingGroup(); - if (group && this.editGroupName.trim()) { - this.groupService.updateGroup({ - ...group, - name: this.editGroupName.trim() - }); - this.cancelEdit(); - this.loadGroups(); - } - } + 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 = ''; } - deleteGroup(groupId: string): void { - if (confirm('Are you sure you want to delete this group? Items will be moved to "Ungrouped".')) { - try { - this.groupService.deleteGroup(groupId); - this.loadGroups(); - } catch (error) { - alert('Cannot delete the ungrouped group'); - } - } - } - - moveUp(groupId: string): void { - 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); - this.loadGroups(); - } - } - - moveDown(groupId: string): void { - 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); - this.loadGroups(); - } - } + 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 + ); + } + } } diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts index de1e8a3..dcd9be2 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, effect } 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,8 @@ import { NgIf } from '@angular/common';

What should I watch now?

+
{{ state.message()?.text }}
+

Next Series

@@ -20,6 +24,7 @@ import { NgIf } from '@angular/common'; [item]="nextSeries()!" (onMarkWatched)="markSeriesWatched()" (onMarkCompleted)="markSeriesCompleted()" + [disabled]="state.busy()" />
@@ -34,6 +39,7 @@ import { NgIf } from '@angular/common'; [item]="nextMovie()!" (onMarkWatched)="markMovieWatched()" (onMarkCompleted)="markMovieCompleted()" + [disabled]="state.busy()" />
@@ -86,17 +92,32 @@ import { NgIf } from '@angular/common'; background: light-dark(var(--light-bg-primary), var(--dark-bg-primary)); border-radius: 8px; } + + .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 - ) { - this.updateNextItems(); + constructor() { + effect(() => { + const data = this.storageService.data(); + if (data) { + this.updateNextItems(); + } + }); } updateNextItems(): void { @@ -104,35 +125,43 @@ export class HomeComponent { this.nextMovie.set(this.roundRobinService.getNextMovieToWatch()); } - markSeriesWatched(): void { - const series = this.nextSeries(); - if (series) { - this.watchListService.markWatched(series.id); - this.updateNextItems(); - } - } + markSeriesWatched = withAsyncAction( + async () => { + const series = this.nextSeries(); + if (series) { + await this.watchListService.markWatched(series.id); + } + }, + this.state + ); - markSeriesCompleted(): void { - const series = this.nextSeries(); - if (series) { - this.watchListService.markCompleted(series.id); - this.updateNextItems(); - } - } + markSeriesCompleted = withAsyncAction( + async () => { + const series = this.nextSeries(); + if (series) { + await this.watchListService.markCompleted(series.id); + } + }, + this.state + ); - markMovieWatched(): void { - const movie = this.nextMovie(); - if (movie) { - this.watchListService.markWatched(movie.id); - this.updateNextItems(); - } - } + markMovieWatched = withAsyncAction( + async () => { + const movie = this.nextMovie(); + if (movie) { + await this.watchListService.markWatched(movie.id); + } + }, + this.state + ); - markMovieCompleted(): void { - const movie = this.nextMovie(); - if (movie) { - this.watchListService.markCompleted(movie.id); - this.updateNextItems(); - } - } + markMovieCompleted = withAsyncAction( + async () => { + const movie = this.nextMovie(); + if (movie) { + await this.watchListService.markCompleted(movie.id); + } + }, + 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-detail/item-detail.component.ts b/src/app/components/item-detail/item-detail.component.ts index 4d36e5c..260c1b8 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, 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.message()?.text }} +

{{ 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 { +export class ItemDetailComponent { + 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,28 @@ export class ItemDetailComponent implements OnInit { constructor( private route: ActivatedRoute, - private router: Router, - private watchListService: WatchListService, - private groupService: GroupService - ) {} - - 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(); + private router: Router + ) { + effect(() => { + const data = this.storageService.data(); + if (data) { + this.groups.set(this.groupService.getAllGroups()); } - } - this.groups.set(this.groupService.getAllGroups()); + }); + + 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(); + } + } + }); } loadEditData(): void { @@ -385,26 +420,29 @@ export class ItemDetailComponent implements OnInit { } } - saveChanges(): void { - 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 - }; - - 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 +452,43 @@ export class ItemDetailComponent implements OnInit { this.confirmDelete.set(false); } - markWatched(): void { - const currentItem = this.item(); - if (currentItem) { - this.watchListService.markWatched(currentItem.id); + 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(); } - } - } - - markCompleted(): void { - const currentItem = this.item(); - if (currentItem) { - this.watchListService.markCompleted(currentItem.id); + }, + 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(); } - } - } - - deleteItem(): void { - const currentItem = this.item(); - if (currentItem) { - this.watchListService.deleteItem(currentItem.id); + }, + 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/item-list/item-list.component.ts b/src/app/components/item-list/item-list.component.ts index 4e5cc2c..e0f41d0 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, effect } 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.message()?.text }}
+
@@ -69,172 +47,80 @@ 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()(); - return this.groupService.getAllGroups(); + const data = this.storageService.data(); + return data ? 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 => { - this.expandedGroups.update(set => new Set(set).add(group.id)); + constructor() { + effect(() => { + const currentGroups = this.groups(); + this.expandedGroups.update(set => { + const newSet = new Set(set); + currentGroups.forEach(g => newSet.add(g.id)); + return newSet; + }); }); } - 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 +133,17 @@ export class ItemListComponent { return this.filteredItems().filter(item => item.groupId === groupId); } - markWatched(itemId: string): void { - this.watchListService.markWatched(itemId); - } - - markCompleted(itemId: string): void { - this.watchListService.markCompleted(itemId); - } -} - + markWatched = withAsyncAction( + async (itemId: string) => { + await this.watchListService.markWatched(itemId); + }, + this.state + ); + + markCompleted = withAsyncAction( + async (itemId: string) => { + await this.watchListService.markCompleted(itemId); + }, + this.state + ); +} \ No newline at end of file diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 23aaf1b..d9f4944 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', @@ -36,31 +37,16 @@ import { Settings } from '../../models/storage.model';

Download all your watch list data as a JSON file

- +

Replace all data with imported JSON file

-
-
- {{ errorMessage() }} -
-
- -
-
- {{ successMessage() }} +
+
+ {{ state.message()?.text }}
@@ -164,65 +150,58 @@ import { Settings } from '../../models/storage.model'; } `] }) -export class SettingsComponent implements OnInit { - showCompleted = false; - errorMessage = signal(null); - successMessage = signal(null); - - constructor( - private storageService: StorageService, - private importExportService: ImportExportService - ) {} - - ngOnInit(): void { - const settings = this.storageService.getSettings(); - this.showCompleted = settings.showCompleted; - } +export class SettingsComponent { + private readonly storageService = inject(StorageService); + private readonly importExportService = inject(ImportExportService); - updateShowCompleted(): void { - this.storageService.updateSettings({ showCompleted: this.showCompleted }); - this.successMessage.set('Settings saved'); - setTimeout(() => this.successMessage.set(null), 3000); - } + state = createAsyncAction(); + showCompleted = false; - exportData(): void { - try { - this.importExportService.exportData(); - 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); - } + constructor() { + effect(() => { + const data = this.storageService.data(); + if (data) { + this.showCompleted = data.settings.showCompleted; + } + }); } - 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; - } - - this.errorMessage.set(null); - this.successMessage.set(null); - - try { - await this.importExportService.importData(file); - 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 = ''; - } - } + updateShowCompleted = withAsyncAction( + async () => { + const previousValue = !this.showCompleted; + try { + await this.storageService.updateSettings({ showCompleted: this.showCompleted }); + 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 + ); + + exportData = withAsyncAction( + async () => { + const exported = await this.importExportService.exportData(); + if (exported) { + this.state.message.set({ text: 'Data exported successfully', type: 'success' }); + setTimeout(() => this.state.message.set(null), 3000); + } + }, + this.state + ); + + importData = withAsyncAction( + async () => { + this.state.message.set(null); + const imported = await this.importExportService.importData(); + if (imported) { + this.state.message.set({ text: 'Data imported successfully', type: 'success' }); + setTimeout(() => this.state.message.set(null), 3000); + } + }, + this.state + ); } 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..0bed5c7 --- /dev/null +++ b/src/app/services/import-export.adapter.local.ts @@ -0,0 +1,46 @@ +import { IImportExportAdapter } from './import-export.adapter'; +import { StorageData } from '../models/storage.model'; + +export class LocalImportExportAdapter implements IImportExportAdapter { + exportData(data: StorageData): boolean { + 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); + return true; + } + + async importData(): Promise<{ file: File; data: StorageData } | null> { + return new Promise((resolve, reject) => { + 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); + resolve({ file, data: parsed as StorageData }); + } catch (err) { + reject(err); + } + }; + + input.click(); + }); + } +} 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..a0d34ef --- /dev/null +++ b/src/app/services/import-export.adapter.tauri.ts @@ -0,0 +1,48 @@ +import { IImportExportAdapter } from './import-export.adapter'; +import { StorageData } from '../models/storage.model'; +import { save, open } from '@tauri-apps/plugin-dialog'; + +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 false; + } + + const { writeTextFile } = await import('@tauri-apps/plugin-fs'); + await writeTextFile(filePath, json); + return true; + } + + 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; + } + + const { readTextFile } = await import('@tauri-apps/plugin-fs'); + const content = await readTextFile(selected); + const parsed = JSON.parse(content); + + const fileName = selected.split(/[/\\]/).pop() || 'import.json'; + const file = new File([content], fileName, { type: 'application/json' }); + + return { file, data: parsed as StorageData }; + } +} diff --git a/src/app/services/import-export.adapter.ts b/src/app/services/import-export.adapter.ts new file mode 100644 index 0000000..73a838b --- /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): boolean | 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..abf67f5 100644 --- a/src/app/services/import-export.service.ts +++ b/src/app/services/import-export.service.ts @@ -1,159 +1,47 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { StorageService } from './storage.service'; import { StorageData } from '../models/storage.model'; +import { IMPORT_EXPORT_ADAPTER, IImportExportAdapter } from './import-export.adapter'; +import { validateStorageDataStructureWithGroups, validateMigratedData } from '../shared/data-validation'; +import { migrateDataOnly, ensureUngroupedGroup } from '../shared/data-migration'; @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); + return 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(); - const migrated = this.storageService.migrateDataOnly(parsed as StorageData); - this.storageService.ensureUngroupedGroup(migrated); - - if (!this.validateMigratedData(migrated)) { - throw new Error('Invalid migrated data'); - } - - this.storageService.saveData(migrated); - } catch (error) { - if (error instanceof SyntaxError) { - throw new Error('Invalid JSON file'); - } - 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; - } - 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'])) { + if (!result) { return false; } - return true; - } + const { data } = result; - private validateGroup(group: unknown): boolean { - if (!group || typeof group !== 'object') { - return false; + if (!validateStorageDataStructureWithGroups(data)) { + throw new Error('Invalid data format'); } - 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') - ) { + if (!confirm('Importing will replace all existing data. Are you sure?')) { return false; } - if (!Array.isArray(i['watchHistory'])) { - return false; - } - for (const entry of i['watchHistory']) { - if (!this.validateWatchHistoryEntry(entry)) { - return false; - } - } + const migrated = migrateDataOnly(data); + ensureUngroupedGroup(migrated); - 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; - } + if (!validateMigratedData(migrated)) { + throw new Error('Invalid migrated data'); } + await this.storageService.saveData(migrated); 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 new file mode 100644 index 0000000..1d50430 --- /dev/null +++ b/src/app/services/storage.adapter.local.ts @@ -0,0 +1,32 @@ +import { IStorageAdapter } from './storage.adapter'; +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 = migrateDataOnly(parsed); + ensureUngroupedGroup(migrated); + localStorage.setItem(STORAGE_KEY, JSON.stringify(migrated)); + return migrated; + } catch (error) { + console.error('Failed to parse stored data:', error); + return createDefaultData(); + } + } + + const defaultData = createDefaultData(); + this.save(defaultData); + return defaultData; + } + + save(data: StorageData): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } +} diff --git a/src/app/services/storage.adapter.tauri.ts b/src/app/services/storage.adapter.tauri.ts new file mode 100644 index 0000000..2c6d6e2 --- /dev/null +++ b/src/app/services/storage.adapter.tauri.ts @@ -0,0 +1,62 @@ +import { IStorageAdapter } from './storage.adapter'; +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'; + +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 = createDefaultData(); + await this.save(defaultData); + return defaultData; + } + + const content = await readTextFile(path, { baseDir: BaseDirectory.AppData }); + const parsed = JSON.parse(content) as StorageData; + + 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 createDefaultData(); + } + } + + async save(data: StorageData): Promise { + await this.ensureDir(); + + const path = `${STORAGE_DIR}/${STORAGE_FILE}`; + await writeTextFile(path, JSON.stringify(data, null, 2), { baseDir: BaseDirectory.AppData }); + + this.cache = data; + } + + loadSync(): StorageData { + if (this.cache) { + return this.cache; + } + throw new Error('Data not loaded. Call load() first.'); + } +} 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..a854917 100644 --- a/src/app/services/storage.service.ts +++ b/src/app/services/storage.service.ts @@ -1,97 +1,39 @@ -import { Injectable, signal } from '@angular/core'; -import { StorageData, Settings, CURRENT_SCHEMA_VERSION } from '../models/storage.model'; +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'; - -const STORAGE_KEY = 'watchListData'; -const DEFAULT_SCHEMA_VERSION = CURRENT_SCHEMA_VERSION; +import { STORAGE_ADAPTER } from './storage.adapter'; +import { ensureUngroupedGroup } from '../shared/data-migration'; @Injectable({ providedIn: 'root' }) export class StorageService { - private readonly data = signal(null); - - constructor() { - this.loadData(); - } + private readonly adapter = inject(STORAGE_ADAPTER); - 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(); - } + readonly dataResource = resource({ + loader: async () => { + const loaded = await this.adapter.load(); + ensureUngroupedGroup(loaded); + return loaded; } - - const defaultData = this.createDefaultData(); - this.saveData(defaultData); - return defaultData; - } + }); - migrateDataOnly(data: StorageData): StorageData { - if (data.schemaVersion >= CURRENT_SCHEMA_VERSION) { - return data; - } + 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); - 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; + async saveData(newData: StorageData): Promise { + if (this.loading()) { + throw new Error('Cannot save while data is loading'); } - - return migrated; - } - - saveData(data: StorageData): void { const updated: StorageData = { - ...data, + ...newData, lastModifiedAt: new Date().toISOString() }; - this.ensureUngroupedGroup(updated); - this.data.set(updated); - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + ensureUngroupedGroup(updated); + await this.adapter.save(updated); + this.dataResource.set(updated); } getData(): StorageData { @@ -99,11 +41,11 @@ export class StorageService { if (current) { return current; } - return this.loadData(); + throw new Error('Data not loaded'); } getDataSignal() { - return this.data.asReadonly(); + return this.data; } getItems(): Item[] { @@ -121,41 +63,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 - }; - } - } } - 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/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; +} diff --git a/src/app/utils/async-action.ts b/src/app/utils/async-action.ts new file mode 100644 index 0000000..28151b9 --- /dev/null +++ b/src/app/utils/async-action.ts @@ -0,0 +1,55 @@ +import { WritableSignal, signal } from '@angular/core'; + +export interface ActionMessage { + text: string; + type: 'success' | 'error'; +} + +export interface AsyncActionState { + busy: WritableSignal; + message: WritableSignal; +} + +export interface AsyncActionOptions { + showMessage?: boolean; + onError?: (error: unknown) => void; +} + +export function createAsyncAction(): AsyncActionState { + return { + busy: signal(false), + message: signal(null) + }; +} + +export function withAsyncAction) => ReturnType>( + action: F, + state: AsyncActionState, + options: AsyncActionOptions = {} +): F { + const { busy, message } = state; + const { showMessage = true, onError } = options; + + return (async (...args: Parameters) => { + if (busy()) return; + busy.set(true); + if (showMessage) message.set(null); + try { + await action(...args); + } catch (err) { + if (showMessage) { + message.set({ + text: err instanceof Error ? err.message : 'Operation failed', + type: 'error' + }); + } + onError?.(err); + } finally { + busy.set(false); + } + }) as F; +} + +export function clearMessage(message: WritableSignal) { + message.set(null); +} diff --git a/src/environments/environment.tauri.ts b/src/environments/environment.tauri.ts index a1c2c55..546e104 100644 --- a/src/environments/environment.tauri.ts +++ b/src/environments/environment.tauri.ts @@ -1,3 +1,9 @@ +import { TauriFileStorageAdapter } from '../app/services/storage.adapter.tauri'; +import { TauriImportExportAdapter } from '../app/services/import-export.adapter.tauri'; + export const environment = { - enableServiceWorker: false -}; + isTauri: true, + 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 a1c2c55..6095cf5 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,3 +1,18 @@ -export const environment = { - enableServiceWorker: false -}; +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; +} + +export const environment: Environment = { + isTauri: false, + enableServiceWorker: false, + storageAdapter: LocalStorageAdapter, + importExportAdapter: LocalImportExportAdapter +}; \ No newline at end of file diff --git a/src/environments/environment.web.ts b/src/environments/environment.web.ts index 43ed7cd..511d12b 100644 --- a/src/environments/environment.web.ts +++ b/src/environments/environment.web.ts @@ -1,3 +1,9 @@ +import { LocalStorageAdapter } from '../app/services/storage.adapter.local'; +import { LocalImportExportAdapter } from '../app/services/import-export.adapter.local'; + export const environment = { - enableServiceWorker: true -}; + isTauri: false, + enableServiceWorker: true, + storageAdapter: LocalStorageAdapter, + importExportAdapter: LocalImportExportAdapter +}; \ No newline at end of file