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 }}
+
+
@@ -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
@@ -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 }}
+
-
-
- 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
-
+
+ Import Data
+
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