orcas-angular 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +159 -0
- package/README.md +17 -0
- package/async/README.md +46 -0
- package/async/async.ts +16 -0
- package/async/cancellation-token.ts +90 -0
- package/dev/README.md +41 -0
- package/dev/console-hook.ts +25 -0
- package/dev/debug.service.ts.example +29 -0
- package/framework/README.md +34 -0
- package/framework/services-init.ts +25 -0
- package/index.ts +21 -0
- package/localization/README.md +73 -0
- package/localization/localization.interface.ts +18 -0
- package/localization/localization.service.ts +131 -0
- package/localization/localize.pipe.ts +30 -0
- package/log/README.md +275 -0
- package/log/echo-provider.ts +27 -0
- package/log/echo.ts +635 -0
- package/log/index.ts +6 -0
- package/log/log-systems.ts +20 -0
- package/navigation/README.md +47 -0
- package/navigation/back-on-click.directive.ts +19 -0
- package/navigation/index.ts +3 -0
- package/navigation/navigation-stack.service.ts +33 -0
- package/package.json +29 -0
- package/storage/README.md +75 -0
- package/storage/capacitor-files.service.ts +38 -0
- package/storage/file-box.service.ts +112 -0
- package/storage/files.ts +42 -0
- package/storage/key-signals.ts +49 -0
- package/storage/local-storage-files.service.ts +49 -0
- package/storage/settings-signals.service.ts +24 -0
- package/storage/settings.service.ts +24 -0
- package/storage/tauri-files.service.ts +69 -0
- package/theme/README.md +44 -0
- package/theme/theme.service.ts +33 -0
- package/ui/README.md +42 -0
- package/ui/context-menu/context-button.component.ts +55 -0
- package/ui/context-menu/context-header.component.ts +15 -0
- package/ui/context-menu/context-menu-trigger.directive.ts +26 -0
- package/ui/context-menu/context-menu.component.ts +95 -0
- package/ui/context-menu/index.ts +4 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# navigation
|
|
2
|
+
|
|
3
|
+
Angular utilities for managing navigation history and providing a reliable "go back" behaviour.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
### `navigation-stack.service.ts`
|
|
8
|
+
|
|
9
|
+
`NavigationStackService` maintains an in-memory history stack by subscribing to Angular Router's `NavigationEnd` events. This gives the application precise control over back-navigation, including a safe fallback to the home route when the history is empty.
|
|
10
|
+
|
|
11
|
+
**Methods:**
|
|
12
|
+
- **`goBack()`** — Navigates to the previous route. Falls back to `"/"` if there is no history.
|
|
13
|
+
- **`getBack(): string`** — Returns the URL of the previous route without navigating, or an empty string if unavailable.
|
|
14
|
+
|
|
15
|
+
### `back-on-click.directive.ts`
|
|
16
|
+
|
|
17
|
+
`BackOnClickDirective` (`[back-on-click]`) is a standalone directive that calls `NavigationStackService.goBack()` when the host element is clicked. It also prevents the default click behaviour so it can be safely applied to `<a>` elements.
|
|
18
|
+
|
|
19
|
+
### `index.ts`
|
|
20
|
+
|
|
21
|
+
Public barrel that re-exports both `NavigationStackService` and `BackOnClickDirective`.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
// In a standalone component:
|
|
27
|
+
import { BackOnClickDirective } from '@/lib/orcas-angular/navigation';
|
|
28
|
+
|
|
29
|
+
@Component({
|
|
30
|
+
imports: [BackOnClickDirective],
|
|
31
|
+
template: `<button back-on-click>← Back</button>`
|
|
32
|
+
})
|
|
33
|
+
export class MyComponent {}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// Programmatic back navigation:
|
|
38
|
+
import { NavigationStackService } from '@/lib/orcas-angular/navigation';
|
|
39
|
+
|
|
40
|
+
export class MyComponent {
|
|
41
|
+
private nav = inject(NavigationStackService);
|
|
42
|
+
|
|
43
|
+
onClose() {
|
|
44
|
+
this.nav.goBack();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {Directive, HostListener} from '@angular/core';
|
|
2
|
+
import {NavigationStackService} from './navigation-stack.service';
|
|
3
|
+
|
|
4
|
+
@Directive({
|
|
5
|
+
selector: '[back-on-click]',
|
|
6
|
+
standalone: true
|
|
7
|
+
})
|
|
8
|
+
export class BackOnClickDirective {
|
|
9
|
+
constructor(private navigationStack: NavigationStackService) {
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@HostListener('click', ['$event'])
|
|
13
|
+
onClick(event: Event): void {
|
|
14
|
+
if (event)
|
|
15
|
+
event.preventDefault();
|
|
16
|
+
|
|
17
|
+
this.navigationStack.goBack();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {Injectable} from '@angular/core';
|
|
2
|
+
import {Location} from '@angular/common';
|
|
3
|
+
import {Router, NavigationEnd} from '@angular/router';
|
|
4
|
+
|
|
5
|
+
@Injectable({
|
|
6
|
+
providedIn: 'root'
|
|
7
|
+
})
|
|
8
|
+
export class NavigationStackService {
|
|
9
|
+
private history: string[] = [];
|
|
10
|
+
|
|
11
|
+
constructor(private router: Router, private location: Location) {
|
|
12
|
+
this.router.events.subscribe((event: any) => {
|
|
13
|
+
if (event instanceof NavigationEnd)
|
|
14
|
+
this.history.push(event.urlAfterRedirects);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public goBack(): void {
|
|
19
|
+
this.history.pop();
|
|
20
|
+
if (this.history.length > 0)
|
|
21
|
+
this.location.back();
|
|
22
|
+
else
|
|
23
|
+
this.router.navigateByUrl("/").then();
|
|
24
|
+
this.history.pop();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public getBack(): string {
|
|
28
|
+
if (this.history.length > 1)
|
|
29
|
+
return this.history[this.history.length - 2];
|
|
30
|
+
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "orcas-angular",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A collection of reusable Angular utilities and services, with support for Tauri and Capacitor.",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"types": "index.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"angular",
|
|
9
|
+
"tauri",
|
|
10
|
+
"capacitor",
|
|
11
|
+
"storage",
|
|
12
|
+
"localization",
|
|
13
|
+
"logging"
|
|
14
|
+
],
|
|
15
|
+
"author": "Racso",
|
|
16
|
+
"license": "MPL-2.0",
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@angular/common": "^21.0.0",
|
|
19
|
+
"@angular/core": "^21.0.0",
|
|
20
|
+
"@angular/cdk": "^21.0.3",
|
|
21
|
+
"rxjs": "~7.8.0"
|
|
22
|
+
},
|
|
23
|
+
"optionalDependencies": {
|
|
24
|
+
"@tauri-apps/api": "^2.0.0",
|
|
25
|
+
"@tauri-apps/plugin-fs": "^2.0.0",
|
|
26
|
+
"@capacitor/filesystem": "^6.0.0",
|
|
27
|
+
"@capacitor/preferences": "^6.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# storage
|
|
2
|
+
|
|
3
|
+
Reactive, file-backed storage layer for application and repository settings. Built on Angular signals and an abstract file service that can be backed by different platforms (Tauri, Capacitor, or `localStorage`).
|
|
4
|
+
|
|
5
|
+
## Architecture overview
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
FilesService (abstract)
|
|
9
|
+
├── TauriFilesService — Tauri desktop file system
|
|
10
|
+
├── CapacitorFilesService — Capacitor mobile file system
|
|
11
|
+
└── LocalStorageFilesService — Browser localStorage (fallback)
|
|
12
|
+
|
|
13
|
+
FileBoxService — reads/writes a single JSON file via FilesService
|
|
14
|
+
└── SettingsSignalsService (extends KeySignals)
|
|
15
|
+
└── SettingsService — scoped to the "app-settings" key
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Files
|
|
19
|
+
|
|
20
|
+
### `files.ts`
|
|
21
|
+
|
|
22
|
+
Abstract `FilesService` class. Defines the platform-agnostic contract for file operations:
|
|
23
|
+
|
|
24
|
+
| Method | Description |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `init(...args)` | Platform-specific setup |
|
|
27
|
+
| `joinStoragePath(filePath)` | Resolves a path relative to the storage root |
|
|
28
|
+
| `hasInStorage(filePath)` | Checks existence in writable storage |
|
|
29
|
+
| `readFromStorage(filePath)` | Reads a file from writable storage |
|
|
30
|
+
| `writeToStorage(filePath, data)` | Writes a file to writable storage |
|
|
31
|
+
| `hasInProject(filePath)` | Checks existence in project assets |
|
|
32
|
+
| `readFromProject(filePath)` | Reads a file from project assets |
|
|
33
|
+
|
|
34
|
+
### `tauri-files.service.ts` / `capacitor-files.service.ts` / `local-storage-files.service.ts`
|
|
35
|
+
|
|
36
|
+
Concrete implementations of `FilesService` for Tauri, Capacitor, and browser `localStorage`.
|
|
37
|
+
|
|
38
|
+
### `file-box.service.ts`
|
|
39
|
+
|
|
40
|
+
`FileBoxService` loads and persists an entire JSON file as a flat key→value map. It exposes a reactive `$data` signal so that anything that reads from the box re-renders automatically when data changes. Saves are debounced so that multiple rapid changes are flushed in a single write.
|
|
41
|
+
|
|
42
|
+
**Init:** must be called once during bootstrap: `init(path: string)`.
|
|
43
|
+
|
|
44
|
+
### `key-signals.ts`
|
|
45
|
+
|
|
46
|
+
Abstract `KeySignals` base class. Provides a hierarchical key system where multiple string path segments are joined with a `|` separator to form a flat storage key. Subclasses implement the three abstract methods to wire `KeySignals` to an actual storage backend.
|
|
47
|
+
|
|
48
|
+
**API:**
|
|
49
|
+
|
|
50
|
+
| Method | Description |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `getNewSignal<T>(defaultValue, ...path)` | Returns a computed `Signal<T>` for the given path |
|
|
53
|
+
| `getValue<T>(defaultValue, ...path)` | Reads the value synchronously |
|
|
54
|
+
| `set(value, ...path)` | Persists a value at the given path |
|
|
55
|
+
| `clearByPrefix(...pathPrefix)` | Removes all keys that share a common prefix |
|
|
56
|
+
|
|
57
|
+
### `settings-signals.service.ts`
|
|
58
|
+
|
|
59
|
+
`SettingsSignalsService` extends `KeySignals` and connects it to `FileBoxService`. All reads and writes go through the reactive `$data` signal and the file-save pipeline.
|
|
60
|
+
|
|
61
|
+
### `settings.service.ts`
|
|
62
|
+
|
|
63
|
+
`SettingsService` wraps `SettingsSignalsService` with a fixed `"app-settings"` namespace, providing a clean API for global application settings.
|
|
64
|
+
|
|
65
|
+
### `settings.service.spec.ts` / `signal-caching.spec.ts` / `key-signals.spec.ts`
|
|
66
|
+
|
|
67
|
+
Unit tests for the storage layer.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
// Global app setting (persists across sessions)
|
|
73
|
+
const isDarkMode = settingsService.getNewSignal(false, 'theme', 'dark');
|
|
74
|
+
await settingsService.set(true, 'theme', 'dark');
|
|
75
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { FilesService } from './files';
|
|
3
|
+
|
|
4
|
+
@Injectable()
|
|
5
|
+
export class CapacitorFilesService extends FilesService {
|
|
6
|
+
static isSupported(): boolean {
|
|
7
|
+
// Capacitor is not yet implemented, so returning false
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async init() {
|
|
12
|
+
throw new Error('CapacitorFilesService not implemented');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async joinStoragePath(filePath: string): Promise<string | null> {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async hasInStorage(filePath: string): Promise<boolean> {
|
|
20
|
+
throw new Error('Method not implemented.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async readFromStorage(filePath: string): Promise<string> {
|
|
24
|
+
throw new Error('Method not implemented.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async writeToStorage(filePath: string, data: string): Promise<void> {
|
|
28
|
+
throw new Error('Method not implemented.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async hasInProject(filePath: string): Promise<boolean> {
|
|
32
|
+
throw new Error('Method not implemented.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async readFromProject(filePath: string): Promise<string> {
|
|
36
|
+
throw new Error('Method not implemented.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { computed, inject, Injectable, signal } from '@angular/core';
|
|
2
|
+
import { FilesService } from './files';
|
|
3
|
+
import { Async } from "../async/async";
|
|
4
|
+
|
|
5
|
+
enum Status {
|
|
6
|
+
NotInitialized,
|
|
7
|
+
Loading,
|
|
8
|
+
Idle,
|
|
9
|
+
Saving
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface BoxData {
|
|
13
|
+
[key: string]: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Injectable({
|
|
17
|
+
providedIn: 'root'
|
|
18
|
+
})
|
|
19
|
+
export class FileBoxService {
|
|
20
|
+
private files: FilesService = inject(FilesService);
|
|
21
|
+
private status: Status = Status.NotInitialized;
|
|
22
|
+
private path: string = '';
|
|
23
|
+
|
|
24
|
+
private saveEnqueued: boolean = false;
|
|
25
|
+
|
|
26
|
+
private $dataWritable = signal<BoxData>({});
|
|
27
|
+
public $data = computed(() => {
|
|
28
|
+
if (this.status === Status.NotInitialized)
|
|
29
|
+
console.error('Service is not initialized.');
|
|
30
|
+
else if (this.status === Status.Loading)
|
|
31
|
+
console.error('Service is loading.');
|
|
32
|
+
|
|
33
|
+
return this.$dataWritable();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async init(path: string) {
|
|
37
|
+
if (this.status !== Status.NotInitialized) {
|
|
38
|
+
console.error('Service is already initialized.');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.path = path;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
this.status = Status.Loading;
|
|
46
|
+
if (await this.files.hasInStorage(this.path)) {
|
|
47
|
+
const fileContent = await this.files.readFromStorage(this.path);
|
|
48
|
+
this.$dataWritable.set(JSON.parse(fileContent));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error('Failed to load file:', error);
|
|
53
|
+
this.$dataWritable.set({});
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
this.status = Status.Idle;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
has(key: string): boolean {
|
|
61
|
+
return key in this.$data();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
set(key: string, value: any): void {
|
|
65
|
+
this.checkType(value);
|
|
66
|
+
const newData = { ...this.$dataWritable() };
|
|
67
|
+
newData[key] = value;
|
|
68
|
+
this.$dataWritable.set(newData);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setAll(data: BoxData): void {
|
|
72
|
+
this.$dataWritable.set(data);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
remove(key: string): void {
|
|
76
|
+
const newData = { ...this.$dataWritable() };
|
|
77
|
+
delete newData[key];
|
|
78
|
+
this.$dataWritable.set(newData);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private checkType(value: any) {
|
|
82
|
+
if (value instanceof Function)
|
|
83
|
+
throw new Error('Cannot save functions.');
|
|
84
|
+
|
|
85
|
+
if (value instanceof Promise)
|
|
86
|
+
throw new Error('Cannot save promises.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async save() {
|
|
90
|
+
if (this.saveEnqueued)
|
|
91
|
+
return;
|
|
92
|
+
|
|
93
|
+
this.saveEnqueued = true;
|
|
94
|
+
|
|
95
|
+
while (this.status === Status.Saving)
|
|
96
|
+
await Async.delay(100);
|
|
97
|
+
|
|
98
|
+
this.saveEnqueued = false;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
this.status = Status.Saving;
|
|
102
|
+
const dataString = JSON.stringify(this.$data());
|
|
103
|
+
await this.files.writeToStorage(this.path, dataString);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.error('Failed to save file:', error);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
this.status = Status.Idle;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
package/storage/files.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
@Injectable()
|
|
4
|
+
export abstract class FilesService {
|
|
5
|
+
/**
|
|
6
|
+
* Initializes the service.
|
|
7
|
+
* This should handle any setup required by the specific implementation.
|
|
8
|
+
*/
|
|
9
|
+
abstract init(...args: any[]): Promise<void>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Joins a filename with the storage base directory to get a full path.
|
|
13
|
+
* Returns a string or a Promise of a string depending on implementation.
|
|
14
|
+
* In some platforms, this may return null if paths are not supported.
|
|
15
|
+
*/
|
|
16
|
+
abstract joinStoragePath(filePath: string): Promise<string | null>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Checks if a file exists in the platform-specific storage.
|
|
20
|
+
*/
|
|
21
|
+
abstract hasInStorage(filePath: string): Promise<boolean>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads a file from the platform-specific storage.
|
|
25
|
+
*/
|
|
26
|
+
abstract readFromStorage(filePath: string): Promise<string>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Writes data to a file in the platform-specific storage.
|
|
30
|
+
*/
|
|
31
|
+
abstract writeToStorage(filePath: string, data: string): Promise<void>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Checks if a file exists in the project's assets/resources.
|
|
35
|
+
*/
|
|
36
|
+
abstract hasInProject(filePath: string): Promise<boolean>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads a file from the project's assets/resources.
|
|
40
|
+
*/
|
|
41
|
+
abstract readFromProject(filePath: string): Promise<string>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { computed, Signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
export abstract class KeySignals {
|
|
4
|
+
protected abstract $data(): Record<string, any>;
|
|
5
|
+
protected abstract setRawValue(key: string, value: any): Promise<void>;
|
|
6
|
+
protected abstract setMultipleRawValues(values: Record<string, any>): Promise<void>;
|
|
7
|
+
|
|
8
|
+
protected readonly SEPARATOR = '|';
|
|
9
|
+
|
|
10
|
+
public getCanonicalKey(path: string[]): string {
|
|
11
|
+
return path.join(this.SEPARATOR);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public getNewSignal<T>(defaultValue: T, ...path: string[]): Signal<T> {
|
|
15
|
+
return computed(() => this.getValue(defaultValue, ...path));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public getValue<T>(defaultValue: T, ...path: string[]): T {
|
|
19
|
+
const key = this.getCanonicalKey(path);
|
|
20
|
+
const data = this.$data();
|
|
21
|
+
return key in data ? data[key] : defaultValue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public async set(value: any, ...path: string[]): Promise<void> {
|
|
25
|
+
const key = this.getCanonicalKey(path);
|
|
26
|
+
await this.setRawValue(key, value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Clears all keys that start with the given prefix.
|
|
31
|
+
*/
|
|
32
|
+
public async clearByPrefix(...pathPrefix: string[]): Promise<void> {
|
|
33
|
+
const prefix = this.getCanonicalKey(pathPrefix) + this.SEPARATOR;
|
|
34
|
+
const data = this.$data();
|
|
35
|
+
const updatedData = { ...data };
|
|
36
|
+
let changed = false;
|
|
37
|
+
|
|
38
|
+
for (const key in updatedData) {
|
|
39
|
+
if (key.startsWith(prefix)) {
|
|
40
|
+
delete updatedData[key];
|
|
41
|
+
changed = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (changed) {
|
|
46
|
+
await this.setMultipleRawValues(updatedData);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Injectable, inject } from '@angular/core';
|
|
2
|
+
import { HttpClient } from '@angular/common/http';
|
|
3
|
+
import { lastValueFrom } from 'rxjs';
|
|
4
|
+
import { FilesService } from './files';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class LocalStorageFilesService extends FilesService {
|
|
8
|
+
private http = inject(HttpClient);
|
|
9
|
+
|
|
10
|
+
static isSupported(): boolean {
|
|
11
|
+
return typeof window !== 'undefined' && !!window.localStorage;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async init() {
|
|
15
|
+
// LocalStorage is ready immediately
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async joinStoragePath(filePath: string): Promise<string | null> {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async hasInStorage(filePath: string): Promise<boolean> {
|
|
23
|
+
return localStorage.getItem(filePath) !== null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async readFromStorage(filePath: string): Promise<string> {
|
|
27
|
+
const data = localStorage.getItem(filePath);
|
|
28
|
+
if (data === null) throw new Error(`File not found in localStorage: ${filePath}`);
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async writeToStorage(filePath: string, data: string): Promise<void> {
|
|
33
|
+
localStorage.setItem(filePath, data);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async hasInProject(filePath: string): Promise<boolean> {
|
|
37
|
+
try {
|
|
38
|
+
// In a browser, we check if we can fetch it via HTTP
|
|
39
|
+
await lastValueFrom(this.http.head(filePath));
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async readFromProject(filePath: string): Promise<string> {
|
|
47
|
+
return await lastValueFrom(this.http.get(filePath, { responseType: 'text' }));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { inject, Injectable } from '@angular/core';
|
|
2
|
+
import { KeySignals } from './key-signals';
|
|
3
|
+
import { FileBoxService } from './file-box.service';
|
|
4
|
+
|
|
5
|
+
@Injectable({
|
|
6
|
+
providedIn: 'root'
|
|
7
|
+
})
|
|
8
|
+
export class SettingsSignalsService extends KeySignals {
|
|
9
|
+
private filebox = inject(FileBoxService);
|
|
10
|
+
|
|
11
|
+
protected override $data(): Record<string, any> {
|
|
12
|
+
return this.filebox.$data();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
protected override async setRawValue(key: string, value: any): Promise<void> {
|
|
16
|
+
this.filebox.set(key, value);
|
|
17
|
+
await this.filebox.save();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected override async setMultipleRawValues(values: Record<string, any>): Promise<void> {
|
|
21
|
+
this.filebox.setAll(values);
|
|
22
|
+
await this.filebox.save();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { inject, Injectable, Signal } from "@angular/core";
|
|
2
|
+
import { FileBoxService } from "./file-box.service";
|
|
3
|
+
import { SettingsSignalsService } from "./settings-signals.service";
|
|
4
|
+
|
|
5
|
+
@Injectable({
|
|
6
|
+
providedIn: "root"
|
|
7
|
+
})
|
|
8
|
+
export class SettingsService {
|
|
9
|
+
private fileboxService = inject(FileBoxService);
|
|
10
|
+
private sss = inject(SettingsSignalsService);
|
|
11
|
+
private readonly SETTINGS_KEY = "app-settings";
|
|
12
|
+
|
|
13
|
+
public getNewSignal<T>(defaultValue: T, ...path: string[]): Signal<T> {
|
|
14
|
+
return this.sss.getNewSignal(defaultValue, this.SETTINGS_KEY, ...path);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public async set(value: any, ...path: string[]): Promise<void> {
|
|
18
|
+
await this.sss.set(value, this.SETTINGS_KEY, ...path);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public async save(): Promise<void> {
|
|
22
|
+
await this.fileboxService.save();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { FilesService } from './files';
|
|
3
|
+
|
|
4
|
+
@Injectable()
|
|
5
|
+
export class TauriFilesService extends FilesService {
|
|
6
|
+
private static _isTauri: boolean | null = null;
|
|
7
|
+
|
|
8
|
+
static isSupported(): boolean {
|
|
9
|
+
if (this._isTauri !== null) return this._isTauri;
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
// Check for window.__TAURI_INTERNALS__ which is usually present in v2
|
|
13
|
+
this._isTauri = !!((window as any).__TAURI_INTERNALS__);
|
|
14
|
+
} catch {
|
|
15
|
+
this._isTauri = false;
|
|
16
|
+
}
|
|
17
|
+
return this._isTauri;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async init() {
|
|
21
|
+
// No special initialization needed for Tauri FS plugin beyond what is handled lazily
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async joinStoragePath(filePath: string): Promise<string | null> {
|
|
25
|
+
const { appLocalDataDir, join } = await import('@tauri-apps/api/path');
|
|
26
|
+
const dataDir = await appLocalDataDir();
|
|
27
|
+
return await join(dataDir, filePath);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async hasInStorage(filePath: string): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
const { exists } = await import('@tauri-apps/plugin-fs');
|
|
33
|
+
const { BaseDirectory } = await import('@tauri-apps/api/path');
|
|
34
|
+
return await exists(filePath, { baseDir: BaseDirectory.AppLocalData });
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('TauriFilesService.hasInStorage error:', error);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async readFromStorage(filePath: string): Promise<string> {
|
|
42
|
+
const { readTextFile } = await import('@tauri-apps/plugin-fs');
|
|
43
|
+
const { BaseDirectory } = await import('@tauri-apps/api/path');
|
|
44
|
+
return await readTextFile(filePath, { baseDir: BaseDirectory.AppLocalData });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async writeToStorage(filePath: string, data: string): Promise<void> {
|
|
48
|
+
const { writeTextFile } = await import('@tauri-apps/plugin-fs');
|
|
49
|
+
const { BaseDirectory } = await import('@tauri-apps/api/path');
|
|
50
|
+
await writeTextFile(filePath, data, { baseDir: BaseDirectory.AppLocalData });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async hasInProject(filePath: string): Promise<boolean> {
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetch(filePath, { method: 'HEAD' });
|
|
56
|
+
return response.ok;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async readFromProject(filePath: string): Promise<string> {
|
|
63
|
+
const response = await fetch(filePath);
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw new Error(`Failed to read project file: ${filePath} (${response.status})`);
|
|
66
|
+
}
|
|
67
|
+
return await response.text();
|
|
68
|
+
}
|
|
69
|
+
}
|
package/theme/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# theme
|
|
2
|
+
|
|
3
|
+
Angular service for managing the application's visual theme (light / dark mode).
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
### `theme.service.ts`
|
|
8
|
+
|
|
9
|
+
`ThemeService` reads and persists the user's theme preference via `SettingsService` and reflects it on the DOM in real time.
|
|
10
|
+
|
|
11
|
+
**Signals:**
|
|
12
|
+
- **`$theme`** — Reactive signal (`Signal<ThemeType>`) holding the current theme preference. Backed by persistent storage so the user's choice survives page reloads.
|
|
13
|
+
- **`$darkMode`** — Computed `boolean` signal. Returns `true` when the effective theme is dark. When the stored preference is `ThemeType.Unset`, it automatically falls back to the OS-level `prefers-color-scheme: dark` media query.
|
|
14
|
+
|
|
15
|
+
Whenever `$darkMode` changes, an Angular `effect` toggles the `dark` CSS class on `document.documentElement`, making the service compatible with Tailwind CSS's `darkMode: 'class'` strategy (or any similar class-based dark-mode approach).
|
|
16
|
+
|
|
17
|
+
**Method:**
|
|
18
|
+
- **`setTheme(theme: ThemeType)`** — Persists the chosen theme and updates the `$theme` signal.
|
|
19
|
+
|
|
20
|
+
**`ThemeType` enum:**
|
|
21
|
+
|
|
22
|
+
| Value | Description |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `ThemeType.Unset` | Follow the OS preference |
|
|
25
|
+
| `ThemeType.Light` | Force light mode |
|
|
26
|
+
| `ThemeType.Dark` | Force dark mode |
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { ThemeService, ThemeType } from '@/lib/orcas-angular/theme/theme.service';
|
|
32
|
+
|
|
33
|
+
export class SettingsComponent {
|
|
34
|
+
private theme = inject(ThemeService);
|
|
35
|
+
|
|
36
|
+
// Read current dark-mode state reactively
|
|
37
|
+
isDark = this.theme.$darkMode;
|
|
38
|
+
|
|
39
|
+
async toggleTheme() {
|
|
40
|
+
const next = this.theme.$darkMode() ? ThemeType.Light : ThemeType.Dark;
|
|
41
|
+
await this.theme.setTheme(next);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|