
Some checks failed
FanslySync Build & Test / FanslySync Test Runner (push) Has been cancelled
575 lines
18 KiB
Svelte
575 lines
18 KiB
Svelte
<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. We’ll 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>
|