feat: Data management
This commit is contained in:
@@ -97,7 +97,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2.2MB",
|
||||
"maximumError": "2.36MB"
|
||||
"maximumError": "2.38MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -124,6 +124,24 @@ export interface SavedThemeFileDescriptor {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ExportUserDataResult {
|
||||
cancelled: boolean;
|
||||
exported: boolean;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export interface ImportUserDataResult {
|
||||
backupPath?: string;
|
||||
cancelled: boolean;
|
||||
imported: boolean;
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
export interface EraseUserDataResult {
|
||||
erased: boolean;
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
export interface ElectronCommand {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
@@ -165,6 +183,10 @@ export interface ElectronApi {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
importUserData: () => Promise<ImportUserDataResult>;
|
||||
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||
getSavedThemesPath: () => Promise<string>;
|
||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||
readSavedTheme: (fileName: string) => Promise<string>;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SettingsPage =
|
||||
| 'notifications'
|
||||
| 'voice'
|
||||
| 'updates'
|
||||
| 'data'
|
||||
| 'debugging'
|
||||
| 'server'
|
||||
| 'members'
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<div class="space-y-6">
|
||||
<section class="rounded-lg border border-border bg-card/60 p-5">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<ng-icon
|
||||
name="lucideDatabase"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<h4 class="text-base font-semibold text-foreground">Local data</h4>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (restartRequired()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="restartApp()"
|
||||
[disabled]="busyAction() !== null"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideRefreshCw"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Restart app
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!isElectron) {
|
||||
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
||||
<p class="text-sm text-muted-foreground">Data management is only available in the packaged Electron desktop app.</p>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">Current data folder</h5>
|
||||
<p class="mt-2 break-all rounded-lg border border-border bg-secondary/20 px-3 py-2 text-sm text-muted-foreground">
|
||||
{{ dataPath() || 'Resolving data folder...' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="openDataFolder()"
|
||||
[disabled]="busyAction() !== null"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideFolderOpen"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ busyAction() === 'open' ? 'Opening...' : 'Open folder' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">Export data</h5>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Create a portable .dat archive that can be imported on another client.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="exportData()"
|
||||
[disabled]="busyAction() !== null"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ busyAction() === 'export' ? 'Exporting...' : 'Export data' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">Import all data</h5>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Restore a .dat archive. Existing local data is moved to a backup folder first.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="importData()"
|
||||
[disabled]="busyAction() !== null"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUpload"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ busyAction() === 'import' ? 'Importing...' : 'Import data' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-lg border border-destructive/30 bg-destructive/10 p-5">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">Erase user data</h5>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Remove local app data from this device and recreate an empty database.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="eraseData()"
|
||||
[disabled]="busyAction() !== null"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ busyAction() === 'erase' ? 'Erasing...' : 'Erase user data' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@if (statusMessage()) {
|
||||
<section class="rounded-lg border border-primary/30 bg-primary/10 p-4">
|
||||
<p class="break-words text-sm text-foreground">{{ statusMessage() }}</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (errorMessage()) {
|
||||
<section class="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
|
||||
<p class="break-words text-sm text-foreground">{{ errorMessage() }}</p>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideDatabase,
|
||||
lucideDownload,
|
||||
lucideFolderOpen,
|
||||
lucideRefreshCw,
|
||||
lucideTrash2,
|
||||
lucideUpload
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
|
||||
type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
||||
|
||||
@Component({
|
||||
selector: 'app-data-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideDatabase,
|
||||
lucideDownload,
|
||||
lucideFolderOpen,
|
||||
lucideRefreshCw,
|
||||
lucideTrash2,
|
||||
lucideUpload
|
||||
})
|
||||
],
|
||||
templateUrl: './data-settings.component.html'
|
||||
})
|
||||
export class DataSettingsComponent {
|
||||
private readonly electron = inject(ElectronBridgeService);
|
||||
|
||||
readonly isElectron = this.electron.isAvailable;
|
||||
readonly dataPath = signal<string | null>(null);
|
||||
readonly busyAction = signal<DataAction | null>(null);
|
||||
readonly statusMessage = signal<string | null>(null);
|
||||
readonly errorMessage = signal<string | null>(null);
|
||||
readonly restartRequired = signal(false);
|
||||
|
||||
constructor() {
|
||||
void this.loadDataPath();
|
||||
}
|
||||
|
||||
async openDataFolder(): Promise<void> {
|
||||
await this.runAction('open', async () => {
|
||||
const opened = await this.electron.requireApi().openCurrentDataFolder();
|
||||
|
||||
this.statusMessage.set(opened ? 'Opened the current data folder.' : 'Could not open the data folder.');
|
||||
});
|
||||
}
|
||||
|
||||
async exportData(): Promise<void> {
|
||||
await this.runAction('export', async () => {
|
||||
const result = await this.electron.requireApi().exportUserData();
|
||||
|
||||
if (result.cancelled) {
|
||||
this.statusMessage.set('Export cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusMessage.set(result.filePath ? `Exported data to ${result.filePath}.` : 'Exported data.');
|
||||
});
|
||||
}
|
||||
|
||||
async importData(): Promise<void> {
|
||||
if (!window.confirm('Importing data replaces the current local data. Existing data will be moved to a backup folder first. Continue?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runAction('import', async () => {
|
||||
const result = await this.electron.requireApi().importUserData();
|
||||
|
||||
if (result.cancelled) {
|
||||
this.statusMessage.set('Import cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.restartRequired.set(result.restartRequired);
|
||||
this.statusMessage.set(result.backupPath
|
||||
? `Imported data. Previous data was backed up to ${result.backupPath}.`
|
||||
: 'Imported data.');
|
||||
});
|
||||
}
|
||||
|
||||
async eraseData(): Promise<void> {
|
||||
if (!window.confirm('Erase all local MetoYou data on this device? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runAction('erase', async () => {
|
||||
const result = await this.electron.requireApi().eraseUserData();
|
||||
|
||||
this.restartRequired.set(result.restartRequired);
|
||||
this.statusMessage.set('Local data erased. Restart the app to finish resetting the session.');
|
||||
await this.loadDataPath();
|
||||
});
|
||||
}
|
||||
|
||||
async restartApp(): Promise<void> {
|
||||
await this.runAction('restart', async () => {
|
||||
await this.electron.requireApi().relaunchApp();
|
||||
});
|
||||
}
|
||||
|
||||
private async loadDataPath(): Promise<void> {
|
||||
if (!this.isElectron) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.dataPath.set(await this.electron.requireApi().getAppDataPath());
|
||||
} catch {
|
||||
this.dataPath.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
private async runAction(action: DataAction, operation: () => Promise<void>): Promise<void> {
|
||||
this.busyAction.set(action);
|
||||
this.errorMessage.set(null);
|
||||
this.statusMessage.set(null);
|
||||
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
this.errorMessage.set(error instanceof Error ? error.message : 'Data operation failed.');
|
||||
} finally {
|
||||
this.busyAction.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,9 @@
|
||||
@case ('updates') {
|
||||
Updates
|
||||
}
|
||||
@case ('data') {
|
||||
Data
|
||||
}
|
||||
@case ('debugging') {
|
||||
Debugging
|
||||
}
|
||||
@@ -273,6 +276,15 @@
|
||||
@case ('updates') {
|
||||
<app-updates-settings />
|
||||
}
|
||||
@case ('data') {
|
||||
@defer {
|
||||
<app-data-settings />
|
||||
} @loading {
|
||||
<section class="rounded-lg border border-border bg-card/60 p-5">
|
||||
<p class="text-sm text-muted-foreground">Loading data settings...</p>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@case ('debugging') {
|
||||
<app-debugging-settings />
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import { BansSettingsComponent } from './bans-settings/bans-settings.component';
|
||||
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
|
||||
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
||||
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
|
||||
import { DataSettingsComponent } from './data-settings/data-settings.component';
|
||||
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
||||
import {
|
||||
ThemeLibraryService,
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
NotificationsSettingsComponent,
|
||||
VoiceSettingsComponent,
|
||||
UpdatesSettingsComponent,
|
||||
DataSettingsComponent,
|
||||
DebuggingSettingsComponent,
|
||||
ServerSettingsComponent,
|
||||
MembersSettingsComponent,
|
||||
@@ -120,6 +122,7 @@ export class SettingsModalComponent {
|
||||
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
|
||||
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
|
||||
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
|
||||
{ id: 'data', label: 'Data', icon: 'lucideDownload' },
|
||||
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
|
||||
];
|
||||
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
|
||||
Reference in New Issue
Block a user