sync/src/routes/home/+page.svelte
Sticks 22259a3e8f
Some checks failed
FanslySync Build & Test / FanslySync Test Runner (push) Has been cancelled
feat: app 2.0
2025-04-23 19:54:02 -04:00

575 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { fade, fly, slide } from 'svelte/transition';
import { info, error } from '@tauri-apps/plugin-log';
import { ask, message } from '@tauri-apps/plugin-dialog';
import { check, Update } from '@tauri-apps/plugin-updater';
import { getVersion, getTauriVersion } from '@tauri-apps/api/app';
import { invoke } from '@tauri-apps/api/core';
import { sendNotification } from '@tauri-apps/plugin-notification';
import { isEnabled, enable } from '@tauri-apps/plugin-autostart';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { platform } from '@tauri-apps/plugin-os';
import { toast } from 'svelte-french-toast';
import { awaiter } from '$lib/utils';
import type { Config, SyncData } from '$lib/types';
// --- App State ---
let loadingSync = true;
let upToDate: boolean | null = null;
let updateData: Update | null = null;
let versionData = { appVersion: 'Loading...', tauriVersion: 'Loading...' };
// --- Sync State ---
let syncState = {
show: false,
syncing: false,
success: false,
error: false,
url: '',
message: ''
};
let syncProgress = {
currentStep: '',
percentage: 0,
current_count: 0,
total_count: 0,
complete: false
};
let syncStartTime = 0;
// --- Config & AutoSync ---
let config: Config | null = null;
let syncInterval: ReturnType<typeof setInterval> | null = null;
let isAutoSyncConfigModalOpen = false;
let canSave = false;
let autoSyncConfig = { interval: 0, syncToken: '', didRunInitialValidation: false };
let autoSyncConfigState = { validatingToken: false, tokenValid: false };
onMount(async () => {
info('[FanslySync::page_init] Initializing...');
const [cfg, cfgErr] = await awaiter(invoke('get_config') as Promise<Config>);
if (cfgErr || !cfg) return await handleInitError(cfgErr);
config = cfg;
autoSyncConfig.interval = cfg.sync_interval;
autoSyncConfig.syncToken = cfg.sync_token;
const status = await check();
upToDate = status === null;
updateData = status;
versionData.appVersion = await getVersion();
versionData.tauriVersion = await getTauriVersion();
scheduleAutoSync();
loadingSync = false;
info('[FanslySync::page_init] Initialization complete.');
});
async function handleInitError(err: unknown) {
error(`[Init Error] ${err}`);
await message(`Failed to load configuration. Error: ${err}`, { kind: 'error' });
await writeText(String(err));
invoke('quit', { code: 1 });
}
function scheduleAutoSync() {
syncInterval && clearInterval(syncInterval);
syncInterval = setInterval(runAutoSync, (config?.sync_interval ?? 1) * 3600000);
}
async function runAutoSync() {
if (!config?.auto_sync_enabled) return;
info('[AutoSync] Starting...');
const nextTime = new Date(Date.now() + config.sync_interval * 3600000).toLocaleTimeString();
const data = await syncNow(true);
if (!data) return;
const [_, uploadErr] = await awaiter(
invoke('fansly_upload_auto_sync_data', { token: config.sync_token, data })
);
if (uploadErr) {
error('[AutoSync] Upload failed');
sendNotification({ title: 'Auto Sync Failed', body: `Retry at ${nextTime}` });
} else {
sendNotification({ title: 'Auto Sync Successful', body: `Next at ${nextTime}` });
}
}
async function syncNow(auto = false): Promise<SyncData | null> {
syncState = { show: true, syncing: true, success: false, error: false, url: '', message: '' };
syncStartTime = Date.now();
const thread = setInterval(updateProgress, 500);
const [data, err] = await awaiter(invoke('fansly_sync', { auto }) as Promise<SyncData>);
clearInterval(thread);
if (err || !data) {
syncState = { ...syncState, syncing: false, error: true, message: String(err) };
sendNotification({ title: 'Sync Failed', body: String(err) });
return null;
}
syncState.url = data.sync_data_url;
config!.last_sync = Date.now();
config!.last_sync_data = data;
await saveConfig();
syncState = { ...syncState, syncing: false, success: true };
!auto && sendNotification({ title: 'Sync Successful', body: 'Check app for details.' });
auto && setTimeout(() => (syncState.show = false), 5000);
return auto ? data : null;
}
async function updateProgress() {
const [p] = await awaiter<{
current_step: string;
percentage_done: number;
current_count: number;
total_count: number;
complete: boolean;
}>(invoke('fansly_get_sync_status'));
if (p) {
syncProgress.currentStep = p.current_step;
syncProgress.percentage = p.percentage_done;
syncProgress.current_count = p.current_count;
syncProgress.total_count = p.total_count;
}
}
async function saveConfig() {
const [_, err] = await awaiter(invoke('save_config', { config }));
err && error('[SaveConfig] ' + err);
}
async function doUpdate() {
if (updateData === null) {
return message(`Up to date! v${versionData.appVersion}`, { kind: 'info' });
}
const confirm = await ask(
`A new version (v${updateData?.version}) is available. Do you want to update?`,
{
title: 'Update Available',
kind: 'warning'
}
);
if (confirm) {
await updateData.downloadAndInstall();
invoke('quit', { code: 0 });
}
}
async function enableAutoSync() {
if (!(await isEnabled())) {
const confirm = await ask('Enable auto-start?');
if (!confirm) return;
await toast.promise(enable(), {
loading: 'Enabling...',
success: 'Enabled!',
error: 'Failed'
});
}
if (!config?.sync_token) return message('Set a sync token first.', { kind: 'error' });
config!.auto_sync_enabled = !config.auto_sync_enabled;
await saveConfig();
config.auto_sync_enabled ? scheduleAutoSync() : syncInterval && clearInterval(syncInterval);
toast.success(`${config.auto_sync_enabled ? 'Enabled' : 'Disabled'} Auto Sync`);
}
async function onSyncTokenEntered() {
autoSyncConfigState.validatingToken = true;
const [_, err] = await awaiter(
invoke('fansly_check_sync_token', { token: autoSyncConfig.syncToken })
);
autoSyncConfigState.validatingToken = false;
autoSyncConfigState.tokenValid = !err;
canSave = autoSyncConfigState.tokenValid;
}
async function onAutoSyncSave() {
isAutoSyncConfigModalOpen = false;
autoSyncConfig.didRunInitialValidation = false;
config!.sync_interval = autoSyncConfig.interval;
config!.sync_token = autoSyncConfig.syncToken;
await saveConfig();
toast.success('Settings saved');
}
onDestroy(() => syncInterval && clearInterval(syncInterval));
$: if (isAutoSyncConfigModalOpen && !autoSyncConfig.didRunInitialValidation) {
onSyncTokenEntered();
autoSyncConfig.didRunInitialValidation = true;
}
function useDebounce(fn: Function, delay: number) {
let timeout: ReturnType<typeof setTimeout>;
return (...args: any[]) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
}
</script>
<div class="min-h-screen bg-zinc-900 text-zinc-200 flex flex-col">
<!-- Header -->
<header class="flex items-center justify-between px-6 py-4 bg-zinc-800 shadow">
<div class="flex items-center space-x-3">
<img src="/fanslySync.png" alt="FanslySync" class="w-8 h-8" />
<div>
<h1 class="text-2xl font-semibold">FanslySync</h1>
<span class="text-sm text-zinc-400">
v{versionData.appVersion} · Tauri {versionData.tauriVersion}
</span>
</div>
</div>
<button
on:click={doUpdate}
class="text-sm font-medium px-3 py-1 rounded hover:bg-zinc-700 transition"
class:text-green-400={upToDate === true}
class:text-red-400={upToDate === false}
>
{#if upToDate === true}Up to date!{:else if upToDate === false}Update available!{/if}
</button>
</header>
<!-- Main Content -->
<main class="flex-1 overflow-auto p-6 space-y-6">
<!-- Loading / Error State -->
{#if loadingSync}
<div class="flex items-center justify-center p-10 text-zinc-400 space-x-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-8 animate-spin"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
<span>Preparing, this shouldn't take long.</span>
</div>
{:else if config === null}
<div class="flex flex-col items-center justify-center p-10 text-zinc-400 space-x-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-8 text-yellow-400"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
<span> We couldn't load your config or your configuration doesn't exist.</span>
<p class="text-sm text-zinc-400">
Please restart the app or use the button below to reconfigure.
</p>
<button
class="mt-4 px-4 py-2 bg-blue-600 rounded hover:bg-blue-500 transition text-white"
on:click={() => (window.location.href = '/setup')}
>
Reconfigure
</button>
</div>
{:else if config !== null && !loadingSync}
<!-- Sync Controls -->
<section class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Auto Sync Card -->
<div class="bg-zinc-800 p-6 rounded-lg shadow">
<div class="flex justify-between items-center mb-2">
<h2 class="text-xl font-semibold">Automatic Sync</h2>
<span
class="px-2 py-1 text-xs font-medium rounded"
class:bg-green-600={config?.auto_sync_enabled}
class:bg-red-600={!config?.auto_sync_enabled}
>
{config?.auto_sync_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<p class="text-zinc-400 mb-4">
We'll automatically sync your data {config?.sync_interval}
{config?.sync_interval === 1 ? 'hour' : 'hours'} to your configured discord server.
</p>
<div class="flex space-x-4">
<button
class="flex-1 py-2 rounded font-medium transition hover:opacity-90"
class:bg-green-600={!config?.auto_sync_enabled}
class:bg-red-600={config?.auto_sync_enabled}
on:click={enableAutoSync}
disabled={syncState.syncing}
>
{syncState.syncing
? 'Syncing…'
: config?.auto_sync_enabled
? 'Disable Auto Sync'
: 'Enable Auto Sync'}
</button>
<button
class="flex-1 py-2 bg-zinc-700 rounded font-medium transition hover:opacity-90"
on:click={() => (isAutoSyncConfigModalOpen = true)}
disabled={config?.auto_sync_enabled || syncState.syncing}
>
Edit Settings
</button>
</div>
</div>
<!-- Manual Sync Card -->
<div class="bg-zinc-800 p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-2">Manual Sync</h2>
<p class="text-zinc-400 mb-4">Trigger a sync immediately.</p>
<button
class="w-full py-2 bg-blue-600 rounded font-medium transition hover:bg-blue-500 disabled:opacity-50"
on:click={() => syncNow(false)}
disabled={syncState.syncing}
>
{syncState.syncing ? 'Syncing…' : 'Sync Now'}
</button>
</div>
</section>
{/if}
</main>
<!-- Progress Bar & Status -->
{#if syncState.show}
<div class="fixed bottom-0 left-0 right-0">
<div
class="fixed bottom-0 left-0 right-0 h-16 overflow-hidden"
transition:slide={{ duration: 500 }}
>
<!-- Background track -->
<div class="absolute inset-0 bg-zinc-800"></div>
<!-- Fill using message box background -->
<div
class={`absolute inset-y-0 left-0 transition-all duration-500 ease-in-out
${syncState.syncing ? 'bg-blue-500' : 'bg-zinc-800'}`}
style="width: {syncState.syncing
? `${syncProgress.percentage === 0 ? 2 : syncProgress.percentage}%`
: '100%'};"
></div>
<!-- Message Box Content -->
<div class="relative z-10 flex items-center h-full px-4 text-white">
{#if syncState.syncing}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6 animate-spin mr-3"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
<div>
<p class="font-medium">Syncing… {syncProgress.percentage}%</p>
<p class="text-sm">
{syncProgress.current_count} / {syncProgress.total_count}{syncProgress.currentStep}
</p>
</div>
{:else if syncState.success}
<div class="flex flex-col">
<p class="font-medium">Sync Successful!</p>
<p class="text-sm">Use Copy to grab URL.</p>
</div>
<div class="ml-auto flex space-x-2">
<button
class="px-3 py-1 bg-zinc-800 rounded"
on:click={() => writeText(syncState.url)}>Copy</button
>
<button
class="px-3 py-1 bg-zinc-800 rounded"
on:click={() => (syncState.show = false)}>Close</button
>
</div>
{:else}
<div class="flex flex-col">
<p class="font-medium">Sync Failed!</p>
<p class="text-sm">{syncState.message}</p>
</div>
<button
class="ml-auto px-3 py-1 bg-zinc-800 rounded"
on:click={() => (syncState.show = false)}>Close</button
>
{/if}
</div>
</div>
</div>
{/if}
<!-- Auto Sync Modal -->
{#if isAutoSyncConfigModalOpen}
<div
class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center"
transition:fade
>
<div
class="bg-zinc-800 p-6 rounded-lg w-full max-w-md"
in:fly={{ y: 20 }}
out:fly={{ y: 20 }}
>
<h3 class="text-xl font-semibold mb-4 flex items-center space-x-2">
<!-- Cog Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0
1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257
1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296
2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723
7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26
1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47
6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213
1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52
6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125
1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932
6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125
1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072
1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
<span>Auto Sync Settings</span>
</h3>
<div class="space-y-4">
<!-- Interval Field -->
<div>
<label for="interval" class="block text-sm mb-1">Interval (hours)</label>
<input
id="interval"
type="number"
bind:value={autoSyncConfig.interval}
min="1"
class="w-full p-2 bg-zinc-700 rounded"
/>
<p class="text-xs text-zinc-400 mt-1">
How often (in hours) the app will automatically sync.
</p>
</div>
<!-- Token Field -->
<div>
<label for="syncToken" class="block text-sm mb-1">Sync Token</label>
<div class="relative">
<input
id="syncToken"
type="text"
bind:value={autoSyncConfig.syncToken}
on:input={useDebounce(onSyncTokenEntered, 500)}
class="w-full p-2 bg-zinc-700 rounded pr-10"
/>
{#if autoSyncConfigState.validatingToken}
<!-- Spinner Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="absolute right-2 bottom-2 w-6 h-6 animate-spin text-zinc-400"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0
0h4.992m-4.993 0 3.181 3.183a8.25 8.25
0 0 0 13.803-3.7M4.031 9.865a8.25 8.25
0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
{:else if autoSyncConfigState.tokenValid}
<!-- Check Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="absolute right-2 bottom-2 w-6 h-6 text-green-400"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0
1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
{:else}
<!-- X Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="absolute right-2 bottom-2 w-6 h-6 text-red-400"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
{/if}
</div>
<p class="text-xs text-zinc-400 mt-1">
Enter your Fansly sync-token. Well validate it before saving.
</p>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button
class="px-4 py-2 bg-zinc-700 rounded hover:bg-zinc-600"
on:click={() => (isAutoSyncConfigModalOpen = false)}
>
Cancel
</button>
<button
class="px-4 py-2 bg-blue-600 rounded hover:bg-blue-500 disabled:opacity-50"
on:click={onAutoSyncSave}
disabled={!canSave}
>
Save
</button>
</div>
</div>
</div>
{/if}
</div>