feat: static sites feature with Gitea/GitHub/GitLab support and Deno backend

Deploy static content from Git repository folders with optional server-side
API endpoints. Supports Gitea/Forgejo/Gogs, GitHub, and GitLab with provider
autodetection.

- New Sites entity with CRUD, encrypted secrets, and manual/push/tag sync triggers
- Pluggable GitProvider interface with three implementations
- Deno container mode: auto-generates router from API_{method}_{name} exports
- Static container mode: nginx serving files with optional markdown rendering
- Wizard UI with provider selector, repo picker, branch/folder tree pickers
- Deploy pipeline builds fresh image, starts container, configures NPM proxy
- Stop/Start buttons, force redeploy on manual trigger
- Periodic health checker detects crashed containers
- Proxy route existence check during auto-sync
This commit is contained in:
2026-04-11 03:35:57 +03:00
parent b0816502bf
commit 8d2c5a063b
31 changed files with 4967 additions and 5 deletions
+106
View File
@@ -604,4 +604,110 @@ export function fetchContainerStats(
);
}
// ── Static Sites ──────────────────────────────────────────────────────
import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types';
export function listStaticSites(): Promise<StaticSite[]> {
return get<StaticSite[]>('/api/sites');
}
export function getStaticSite(id: string): Promise<StaticSite> {
return get<StaticSite>(`/api/sites/${id}`);
}
export function createStaticSite(data: Partial<StaticSite>): Promise<StaticSite> {
return post<StaticSite>('/api/sites', data);
}
export function updateStaticSite(id: string, data: Partial<StaticSite>): Promise<StaticSite> {
return put<StaticSite>(`/api/sites/${id}`, data);
}
export function deleteStaticSite(id: string): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/sites/${id}`);
}
export function deployStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/deploy`);
}
export function stopStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/stop`);
}
export function startStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/start`);
}
export function listStaticSiteRepos(data: {
provider?: string;
gitea_url: string;
access_token?: string;
query?: string;
}): Promise<RepoInfo[]> {
return post<RepoInfo[]>('/api/sites/repos', data);
}
export function detectStaticSiteProvider(url: string): Promise<{ provider: GitProvider }> {
return post<{ provider: GitProvider }>('/api/sites/detect-provider', { url });
}
export function testStaticSiteConnection(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
}): Promise<{ status: string }> {
return post<{ status: string }>('/api/sites/test-connection', data);
}
export function listStaticSiteBranches(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
}): Promise<string[]> {
return post<string[]>('/api/sites/branches', data);
}
export function listStaticSiteTree(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
branch: string;
}): Promise<FolderEntry[]> {
return post<FolderEntry[]>('/api/sites/tree', data);
}
export function listStaticSiteSecrets(siteId: string): Promise<StaticSiteSecret[]> {
return get<StaticSiteSecret[]>(`/api/sites/${siteId}/secrets`);
}
export function createStaticSiteSecret(
siteId: string,
data: { key: string; value: string; encrypted?: boolean }
): Promise<StaticSiteSecret> {
return post<StaticSiteSecret>(`/api/sites/${siteId}/secrets`, data);
}
export function updateStaticSiteSecret(
siteId: string,
secretId: string,
data: { key?: string; value?: string; encrypted?: boolean }
): Promise<StaticSiteSecret> {
return put<StaticSiteSecret>(`/api/sites/${siteId}/secrets/${secretId}`, data);
}
export function deleteStaticSiteSecret(
siteId: string,
secretId: string
): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/sites/${siteId}/secrets/${secretId}`);
}
export { ApiError };
+78 -2
View File
@@ -17,7 +17,8 @@
"events": "Events",
"settings": "Settings",
"logout": "Log out",
"dns": "DNS Records"
"dns": "DNS Records",
"sites": "Sites"
},
"dashboard": {
"title": "Dashboard",
@@ -539,6 +540,77 @@
},
"lastChecked": "Last checked"
},
"sites": {
"title": "Static Sites",
"addSite": "New Site",
"newSite": "New Static Site",
"createSite": "Create Site",
"noSites": "No static sites",
"noSitesDesc": "Deploy static content from a Git repository folder.",
"searchPlaceholder": "Search sites by name, domain, or repo...",
"noMatching": "No sites match your search.",
"name": "Name",
"domain": "Domain",
"mode": "Mode",
"status": "Status",
"lastSync": "Last Sync",
"deploy": "Deploy",
"stop": "Stop",
"start": "Start",
"openSite": "Open Site",
"confirmDelete": "Delete Site",
"confirmDeleteMsg": "This will permanently delete the site and remove its container",
"siteInfo": "Site Information",
"folder": "Folder",
"syncTrigger": "Sync Trigger",
"commitSha": "Commit SHA",
"secrets": "Secrets",
"addSecret": "Add Secret",
"noSecrets": "No secrets configured. Add secrets if your site needs server-side API keys.",
"secretKey": "Key",
"secretValue": "Value",
"encryptSecret": "Encrypt value",
"saveSecret": "Add Secret",
"step1Title": "1. Repository",
"step2Title": "2. Select Branch",
"step3Title": "3. Select Folder",
"step4Title": "4. Configuration",
"step5Title": "5. Review & Create",
"fullRepoUrl": "Repository URL",
"fullRepoUrlHelp": "Paste a full URL to auto-fill the fields below (e.g., https://git.example.com/owner/repo)",
"serverUrl": "Server URL",
"repoUrl": "Git Server URL",
"repoUrlHelp": "Paste a full repo URL or enter the server base URL (Gitea, Forgejo, Gogs)",
"repoOwner": "Owner",
"repoName": "Repository",
"accessToken": "Access Token",
"accessTokenPlaceholder": "Optional — for private repos",
"accessTokenHelp": "Personal access token with repo read permissions. Leave empty for public repos.",
"noToken": "None (public repo)",
"testConnection": "Test Connection",
"connectionSuccess": "Repository is accessible",
"loadingBranches": "Loading branches...",
"selectBranch": "Select a branch",
"chooseBranch": "Choose a branch...",
"branch": "Branch",
"loadingTree": "Loading repository tree...",
"selectFolder": "Select the folder containing your site files",
"selectedFolder": "Selected folder",
"siteName": "Site Name",
"domainHelp": "Public domain for the site. Proxy will be configured automatically.",
"modeStaticDesc": "HTML, CSS, JS, images served via Nginx",
"modeDenoDesc": "Static files + server-side API from api/ folder",
"triggerManual": "Manual",
"triggerPush": "On Push",
"triggerTag": "On Tag",
"tagPattern": "Tag Pattern",
"tagPatternHelp": "Glob pattern for matching tags (e.g., v*, pages-*)",
"renderMarkdown": "Render Markdown files to HTML",
"provider": "Git Provider",
"detectedProvider": "Auto-detected",
"browseRepos": "Browse repositories",
"selectRepo": "Select a repository"
},
"common": {
"cancel": "Cancel",
"confirm": "Confirm",
@@ -556,7 +628,11 @@
"restart": "Restart",
"remove": "Remove",
"instance": "instance",
"instances": "instances"
"instances": "instances",
"next": "Next",
"yes": "Yes",
"no": "No",
"saving": "Saving..."
},
"instance": {
"stopConfirm": "This will stop the running container. The instance can be started again later.",
+78 -2
View File
@@ -17,7 +17,8 @@
"events": "События",
"settings": "Настройки",
"logout": "Выйти",
"dns": "DNS-записи"
"dns": "DNS-записи",
"sites": "Сайты"
},
"dashboard": {
"title": "Панель управления",
@@ -539,6 +540,77 @@
},
"lastChecked": "Последняя проверка"
},
"sites": {
"title": "Статические сайты",
"addSite": "Новый сайт",
"newSite": "Новый статический сайт",
"createSite": "Создать сайт",
"noSites": "Нет статических сайтов",
"noSitesDesc": "Разверните статический контент из папки Git-репозитория.",
"searchPlaceholder": "Поиск по имени, домену или репозиторию...",
"noMatching": "Нет сайтов, соответствующих поиску.",
"name": "Имя",
"domain": "Домен",
"mode": "Режим",
"status": "Статус",
"lastSync": "Последняя синхр.",
"deploy": "Развернуть",
"stop": "Остановить",
"start": "Запустить",
"openSite": "Открыть сайт",
"confirmDelete": "Удалить сайт",
"confirmDeleteMsg": "Это удалит сайт и его контейнер",
"siteInfo": "Информация о сайте",
"folder": "Папка",
"syncTrigger": "Триггер синхр.",
"commitSha": "Коммит SHA",
"secrets": "Секреты",
"addSecret": "Добавить секрет",
"noSecrets": "Секреты не настроены. Добавьте их, если сайту нужны серверные API-ключи.",
"secretKey": "Ключ",
"secretValue": "Значение",
"encryptSecret": "Шифровать значение",
"saveSecret": "Добавить секрет",
"step1Title": "1. Репозиторий",
"step2Title": "2. Выбор ветки",
"step3Title": "3. Выбор папки",
"step4Title": "4. Настройки",
"step5Title": "5. Проверка и создание",
"fullRepoUrl": "URL репозитория",
"fullRepoUrlHelp": "Вставьте полный URL для автозаполнения полей ниже (напр., https://git.example.com/owner/repo)",
"serverUrl": "URL сервера",
"repoUrl": "URL Git-сервера",
"repoUrlHelp": "Вставьте полный URL репозитория или базовый URL сервера (Gitea, Forgejo, Gogs)",
"repoOwner": "Владелец",
"repoName": "Репозиторий",
"accessToken": "Токен доступа",
"accessTokenPlaceholder": "Необязательно — для приватных репозиториев",
"accessTokenHelp": "Персональный токен с правами на чтение репозитория. Оставьте пустым для публичных.",
"noToken": "Нет (публичный репо)",
"testConnection": "Проверить соединение",
"connectionSuccess": "Репозиторий доступен",
"loadingBranches": "Загрузка веток...",
"selectBranch": "Выберите ветку",
"chooseBranch": "Выберите ветку...",
"branch": "Ветка",
"loadingTree": "Загрузка дерева репозитория...",
"selectFolder": "Выберите папку с файлами сайта",
"selectedFolder": "Выбранная папка",
"siteName": "Имя сайта",
"domainHelp": "Публичный домен сайта. Прокси будет настроен автоматически.",
"modeStaticDesc": "HTML, CSS, JS, изображения через Nginx",
"modeDenoDesc": "Статические файлы + серверный API из папки api/",
"triggerManual": "Вручную",
"triggerPush": "При пуше",
"triggerTag": "По тегу",
"tagPattern": "Паттерн тега",
"tagPatternHelp": "Glob-паттерн для тегов (напр., v*, pages-*)",
"renderMarkdown": "Рендерить Markdown-файлы в HTML",
"provider": "Git-провайдер",
"detectedProvider": "Автоопределён",
"browseRepos": "Обзор репозиториев",
"selectRepo": "Выберите репозиторий"
},
"common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
@@ -556,7 +628,11 @@
"restart": "Перезапустить",
"remove": "Удалить",
"instance": "экземпляр",
"instances": "экземпляров"
"instances": "экземпляров",
"next": "Далее",
"yes": "Да",
"no": "Нет",
"saving": "Сохранение..."
},
"instance": {
"stopConfirm": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.",
+57
View File
@@ -336,6 +336,63 @@ export interface StaleContainer {
days_stale: number;
}
/** A static site deployed from a Git repository folder. */
export interface StaticSite {
id: string;
name: string;
provider: GitProvider;
gitea_url: string;
repo_owner: string;
repo_name: string;
branch: string;
folder_path: string;
access_token: string;
domain: string;
mode: 'static' | 'deno';
render_markdown: boolean;
sync_trigger: 'push' | 'tag' | 'manual';
tag_pattern: string;
container_id: string;
proxy_route_id: string;
status: StaticSiteStatus;
last_sync_at: string;
last_commit_sha: string;
error: string;
created_at: string;
updated_at: string;
}
export type StaticSiteStatus = 'idle' | 'syncing' | 'deployed' | 'failed' | 'stopped';
export type GitProvider = '' | 'gitea' | 'github' | 'gitlab';
/** An encrypted environment variable for a static site's Deno backend. */
export interface StaticSiteSecret {
id: string;
site_id: string;
key: string;
value: string;
encrypted: boolean;
created_at: string;
updated_at: string;
}
/** A repository from the Git provider's API. */
export interface RepoInfo {
owner: string;
name: string;
full_name: string;
description: string;
private: boolean;
html_url: string;
}
/** A folder entry from the Gitea repo tree. */
export interface FolderEntry {
path: string;
is_dir: boolean;
}
/** Container CPU and memory stats from the Docker stats API. */
export interface ContainerStats {
cpu_percent: number;
+8 -1
View File
@@ -6,13 +6,14 @@
import Toast from '$lib/components/Toast.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons';
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe } from '$lib/components/icons';
import { goto } from '$app/navigation';
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
import { instanceStatusStore } from '$lib/stores/instance-status';
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
import { logout as apiLogout, getHealth } from '$lib/api';
import { publishEventLog } from '$lib/stores/event-log-bus';
import type { DockerHealth, ProxyHealth } from '$lib/types';
import { t } from '$lib/i18n';
@@ -25,6 +26,7 @@
const navItems = [
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe' },
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
@@ -96,6 +98,9 @@
},
onDeployStatus(payload) {
instanceStatusStore.notifyDeploy(payload);
},
onEventLog(payload) {
publishEventLog(payload);
}
});
@@ -175,6 +180,8 @@
<IconDashboard size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'projects'}
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'globe'}
<IconGlobe size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'deploy'}
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'proxies'}
+267
View File
@@ -0,0 +1,267 @@
<script lang="ts">
import type { StaticSite } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { IconPlus, IconSearch, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let sites = $state<StaticSite[]>([]);
let loading = $state(true);
let error = $state('');
let searchQuery = $state('');
let deploying = $state<Record<string, boolean>>({});
let confirmDelete = $state<StaticSite | null>(null);
const filteredSites = $derived(
searchQuery.trim()
? sites.filter(s => {
const q = searchQuery.toLowerCase();
return s.name.toLowerCase().includes(q)
|| s.domain.toLowerCase().includes(q)
|| s.repo_name.toLowerCase().includes(q);
})
: sites
);
async function loadSites() {
loading = true;
error = '';
try {
sites = await api.listStaticSites();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load sites';
} finally {
loading = false;
}
}
async function handleDeploy(site: StaticSite) {
deploying = { ...deploying, [site.id]: true };
try {
await api.deployStaticSite(site.id);
// Refresh after a short delay to pick up status change.
setTimeout(() => loadSites(), 2000);
} catch (e) {
error = e instanceof Error ? e.message : 'Deploy failed';
} finally {
deploying = { ...deploying, [site.id]: false };
}
}
async function handleStop(site: StaticSite) {
try {
await api.stopStaticSite(site.id);
setTimeout(() => loadSites(), 2000);
} catch (e) {
error = e instanceof Error ? e.message : 'Stop failed';
}
}
async function handleStart(site: StaticSite) {
try {
await api.startStaticSite(site.id);
setTimeout(() => loadSites(), 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Start failed';
}
}
async function handleDelete() {
if (!confirmDelete) return;
const id = confirmDelete.id;
confirmDelete = null;
try {
await api.deleteStaticSite(id);
await loadSites();
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
}
}
function statusBadge(status: string): { text: string; class: string } {
switch (status) {
case 'deployed':
return { text: 'Deployed', class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
case 'syncing':
return { text: 'Syncing', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
case 'failed':
return { text: 'Failed', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
default:
return { text: 'Idle', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
}
}
function modeBadge(mode: string): { text: string; class: string } {
if (mode === 'deno') {
return { text: 'Deno', class: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' };
}
return { text: 'Static', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
}
$effect(() => {
loadSites();
});
</script>
<svelte:head>
<title>{$t('sites.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('sites.title')}</h1>
<a
href="/sites/new"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-all duration-150 active:animate-press"
>
<IconPlus size={16} />
{$t('sites.addSite')}
</a>
</div>
{#if loading}
<SkeletonTable rows={4} cols={5} />
{:else if error}
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadSites}>
{$t('common.retry')}
</button>
</div>
{:else if sites.length === 0}
<EmptyState
title={$t('sites.noSites')}
description={$t('sites.noSitesDesc')}
actionLabel={$t('sites.addSite')}
onaction={() => { window.location.href = '/sites/new'; }}
/>
{:else}
<!-- Search -->
<div class="relative">
<IconSearch size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
<input
type="text"
bind:value={searchQuery}
placeholder={$t('sites.searchPlaceholder')}
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2.5 pl-10 pr-4 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
/>
</div>
{#if filteredSites.length === 0}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
<p class="text-sm text-[var(--text-tertiary)]">{$t('sites.noMatching')}</p>
</div>
{:else}
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.name')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.domain')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.mode')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.status')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.lastSync')}</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each filteredSites as site (site.id)}
{@const status = statusBadge(site.status)}
{@const mode = modeBadge(site.mode)}
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150">
<td class="whitespace-nowrap px-6 py-4">
<a href="/sites/{site.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
{site.name}
</a>
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{site.repo_owner}/{site.repo_name}</p>
</td>
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm">
{#if site.domain}
<a href="https://{site.domain}" target="_blank" rel="noopener noreferrer" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
{site.domain}
</a>
{:else}
<span class="text-[var(--text-tertiary)]">-</span>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {mode.class}">
{mode.text}
</span>
</td>
<td class="whitespace-nowrap px-6 py-4">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {status.class}">
{status.text}
</span>
{#if site.error}
<p class="mt-0.5 max-w-[200px] truncate text-xs text-red-500" title={site.error}>{site.error}</p>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
{#if site.last_sync_at}
{new Date(site.last_sync_at).toLocaleString()}
{:else}
-
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<button
type="button"
title={$t('sites.deploy')}
disabled={deploying[site.id]}
onclick={() => handleDeploy(site)}
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
>
<IconRefresh size={16} class={deploying[site.id] ? 'animate-spin' : ''} />
</button>
{#if site.status === 'stopped'}
<button
type="button"
title={$t('sites.start')}
onclick={() => handleStart(site)}
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-emerald-600 hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconPlay size={16} />
</button>
{:else if site.status === 'deployed'}
<button
type="button"
title={$t('sites.stop')}
onclick={() => handleStop(site)}
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconStop size={16} />
</button>
{/if}
<button
type="button"
title={$t('common.delete')}
onclick={() => { confirmDelete = site; }}
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
{#if confirmDelete}
<ConfirmDialog
open={confirmDelete !== null}
title={$t('sites.confirmDelete')}
message={`${$t('sites.confirmDeleteMsg')} "${confirmDelete.name}"?`}
confirmLabel={$t('common.delete')}
onconfirm={handleDelete}
oncancel={() => { confirmDelete = null; }}
/>
{/if}
+322
View File
@@ -0,0 +1,322 @@
<script lang="ts">
import type { StaticSite, StaticSiteSecret } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import FormField from '$lib/components/FormField.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { IconArrowLeft, IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
let site = $state<StaticSite | null>(null);
let secrets = $state<StaticSiteSecret[]>([]);
let loading = $state(true);
let error = $state('');
let deploying = $state(false);
let confirmDelete = $state(false);
// Secret form.
let showSecretForm = $state(false);
let secretKey = $state('');
let secretValue = $state('');
let secretEncrypted = $state(true);
let secretSubmitting = $state(false);
const siteId = $derived($page.params.id);
async function loadSite() {
loading = true;
error = '';
try {
site = await api.getStaticSite(siteId!);
secrets = await api.listStaticSiteSecrets(siteId!);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load site';
} finally {
loading = false;
}
}
async function handleDeploy() {
if (!site) return;
deploying = true;
try {
await api.deployStaticSite(site.id);
setTimeout(() => loadSite(), 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Deploy failed';
} finally {
deploying = false;
}
}
async function handleStop() {
if (!site) return;
try {
await api.stopStaticSite(site.id);
setTimeout(() => loadSite(), 2000);
} catch (e) {
error = e instanceof Error ? e.message : 'Stop failed';
}
}
async function handleStart() {
if (!site) return;
try {
await api.startStaticSite(site.id);
setTimeout(() => loadSite(), 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Start failed';
}
}
async function handleDelete() {
if (!site) return;
confirmDelete = false;
try {
await api.deleteStaticSite(site.id);
goto('/sites');
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
}
}
async function handleAddSecret() {
if (!site || !secretKey.trim()) return;
secretSubmitting = true;
try {
await api.createStaticSiteSecret(site.id, {
key: secretKey.trim(),
value: secretValue,
encrypted: secretEncrypted
});
secretKey = '';
secretValue = '';
secretEncrypted = true;
showSecretForm = false;
secrets = await api.listStaticSiteSecrets(site.id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add secret';
} finally {
secretSubmitting = false;
}
}
async function handleDeleteSecret(secretId: string) {
if (!site) return;
try {
await api.deleteStaticSiteSecret(site.id, secretId);
secrets = await api.listStaticSiteSecrets(site.id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete secret';
}
}
function statusBadge(status: string): { text: string; class: string } {
switch (status) {
case 'deployed': return { text: 'Deployed', class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
case 'syncing': return { text: 'Syncing', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
case 'failed': return { text: 'Failed', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
default: return { text: 'Idle', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
}
}
$effect(() => {
void siteId;
loadSite();
});
</script>
<svelte:head>
<title>{site?.name ?? $t('sites.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
{#if loading}
<div class="flex items-center gap-2 py-8">
<IconLoader size={20} class="animate-spin text-[var(--text-tertiary)]" />
<span class="text-[var(--text-tertiary)]">{$t('common.loading')}</span>
</div>
{:else if error && !site}
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
</div>
{:else if site}
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/sites" class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors">
<IconArrowLeft size={20} />
</a>
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{site.name}</h1>
<p class="text-sm text-[var(--text-tertiary)]">{site.repo_owner}/{site.repo_name} &middot; {site.branch}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
disabled={deploying}
onclick={handleDeploy}
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
>
<IconRefresh size={16} class={deploying ? 'animate-spin' : ''} />
{$t('sites.deploy')}
</button>
{#if site.status === 'stopped'}
<button
type="button"
onclick={handleStart}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-emerald-600 hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconPlay size={16} />
{$t('sites.start')}
</button>
{:else if site.status === 'deployed'}
<button
type="button"
onclick={handleStop}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconStop size={16} />
{$t('sites.stop')}
</button>
{/if}
{#if site.domain}
<a
href="https://{site.domain}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconGlobe size={16} />
{$t('sites.openSite')}
</a>
{/if}
<button
type="button"
onclick={() => { confirmDelete = true; }}
class="rounded-lg border border-[var(--color-danger-light)] px-4 py-2.5 text-sm font-medium text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors"
>
<IconTrash size={16} />
</button>
</div>
</div>
{#if error}
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-3">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
</div>
{/if}
<!-- Status & Info -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Site Info -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
<h2 class="text-base font-semibold text-[var(--text-primary)] mb-4">{$t('sites.siteInfo')}</h2>
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<span class="text-[var(--text-tertiary)]">{$t('sites.status')}</span>
<span>
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {statusBadge(site.status).class}">{statusBadge(site.status).text}</span>
</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.mode')}</span>
<span class="text-[var(--text-primary)]">{site.mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.domain')}</span>
<span class="text-[var(--text-primary)] font-mono text-xs">{site.domain || '-'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.folder')}</span>
<span class="text-[var(--text-primary)] font-mono text-xs">{site.folder_path || '/ (root)'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.syncTrigger')}</span>
<span class="text-[var(--text-primary)]">{site.sync_trigger}{site.sync_trigger === 'tag' ? ` (${site.tag_pattern})` : ''}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.lastSync')}</span>
<span class="text-[var(--text-primary)]">{site.last_sync_at ? new Date(site.last_sync_at).toLocaleString() : '-'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.commitSha')}</span>
<span class="text-[var(--text-primary)] font-mono text-xs">{site.last_commit_sha ? site.last_commit_sha.slice(0, 8) : '-'}</span>
</div>
{#if site.error}
<div class="mt-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-3">
<p class="text-xs text-red-600 dark:text-red-400">{site.error}</p>
</div>
{/if}
</div>
<!-- Secrets -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-[var(--text-primary)]">{$t('sites.secrets')}</h2>
<button
type="button"
onclick={() => { showSecretForm = !showSecretForm; }}
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconPlus size={14} />
{$t('sites.addSecret')}
</button>
</div>
{#if showSecretForm}
<div class="mb-4 space-y-3 rounded-lg bg-[var(--surface-card-hover)] p-4">
<FormField label={$t('sites.secretKey')} name="secretKey" bind:value={secretKey} placeholder="API_KEY" required />
<FormField label={$t('sites.secretValue')} name="secretValue" bind:value={secretValue} placeholder="sk-..." />
<label class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<input type="checkbox" bind:checked={secretEncrypted} class="rounded border-[var(--border-input)]" />
{$t('sites.encryptSecret')}
</label>
<button
type="button"
disabled={!secretKey.trim() || secretSubmitting}
onclick={handleAddSecret}
class="rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
>
{secretSubmitting ? $t('common.saving') : $t('sites.saveSecret')}
</button>
</div>
{/if}
{#if secrets.length === 0}
<p class="text-sm text-[var(--text-tertiary)]">{$t('sites.noSecrets')}</p>
{:else}
<div class="space-y-2">
{#each secrets as secret (secret.id)}
<div class="flex items-center justify-between rounded-lg border border-[var(--border-secondary)] px-3 py-2">
<div class="flex items-center gap-2">
{#if secret.encrypted}
<IconLock size={14} class="text-[var(--text-tertiary)]" />
{:else}
<IconUnlock size={14} class="text-[var(--text-tertiary)]" />
{/if}
<span class="font-mono text-sm text-[var(--text-primary)]">{secret.key}</span>
<span class="text-xs text-[var(--text-tertiary)]">{secret.value}</span>
</div>
<button
type="button"
onclick={() => handleDeleteSecret(secret.id)}
class="rounded p-1 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
>
<IconTrash size={14} />
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
{#if confirmDelete}
<ConfirmDialog
open={confirmDelete}
title={$t('sites.confirmDelete')}
message={`${$t('sites.confirmDeleteMsg')} "${site?.name}"?`}
confirmLabel={$t('common.delete')}
onconfirm={handleDelete}
oncancel={() => { confirmDelete = false; }}
/>
{/if}
+671
View File
@@ -0,0 +1,671 @@
<script lang="ts">
import type { FolderEntry, GitProvider } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { goto } from '$app/navigation';
import FormField from '$lib/components/FormField.svelte';
import { IconArrowLeft, IconCheck, IconLoader, IconChevronRight, IconSearch } from '$lib/components/icons';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import type { EntityPickerItem } from '$lib/types';
// Provider options.
const providerOptions: { value: GitProvider; label: string }[] = [
{ value: '', label: 'Auto-detect' },
{ value: 'gitea', label: 'Gitea / Forgejo / Gogs' },
{ value: 'github', label: 'GitHub' },
{ value: 'gitlab', label: 'GitLab' },
];
// Wizard state.
let step = $state(1);
const totalSteps = 5;
// Step 1: Repo URL.
let fullRepoUrl = $state('');
let provider = $state<GitProvider>('');
let detectedProvider = $state<GitProvider>('');
let detecting = $state(false);
let giteaUrl = $state('');
let repoOwner = $state('');
let repoName = $state('');
let accessToken = $state('');
let connectionTested = $state(false);
let connectionError = $state('');
let testing = $state(false);
// Repo picker.
let showRepoPicker = $state(false);
let repoPickerItems = $state<EntityPickerItem[]>([]);
let repoPickerLoading = $state(false);
// The effective provider (explicit selection or autodetected).
const effectiveProvider = $derived(provider || detectedProvider || 'gitea');
// Step 2: Branch picker.
let branches = $state<string[]>([]);
let selectedBranch = $state('');
let branchesLoading = $state(false);
let showBranchPicker = $state(false);
// Step 3: Folder picker.
let tree = $state<FolderEntry[]>([]);
let selectedFolder = $state('');
let treeLoading = $state(false);
let expandedDirs = $state<Set<string>>(new Set());
// Step 4: Configuration.
let siteName = $state('');
let domain = $state('');
let mode = $state<'static' | 'deno'>('static');
let renderMarkdown = $state(false);
let syncTrigger = $state<'push' | 'tag' | 'manual'>('manual');
let tagPattern = $state('');
// Step 5: Review + submit.
let submitting = $state(false);
let submitError = $state('');
// Parse repo URL into components and autodetect provider.
function parseRepoUrl(url: string) {
try {
const parsed = new URL(url.trim());
const pathParts = parsed.pathname.split('/').filter(Boolean);
if (pathParts.length >= 2) {
giteaUrl = `${parsed.protocol}//${parsed.host}`;
repoOwner = pathParts[0];
repoName = pathParts[1];
}
} catch {
// Not a valid URL yet.
}
}
async function browseRepos() {
if (!giteaUrl) return;
showRepoPicker = true;
if (repoPickerItems.length > 0) return;
repoPickerLoading = true;
try {
await autoDetectProvider();
const repos = await api.listStaticSiteRepos({
provider: effectiveProvider,
gitea_url: giteaUrl,
access_token: accessToken || undefined,
});
repoPickerItems = repos.map(r => ({
value: JSON.stringify({ owner: r.owner, name: r.name }),
label: r.full_name,
description: r.description || undefined,
icon: r.private ? 'lock' : undefined,
}));
} catch {
repoPickerItems = [];
} finally {
repoPickerLoading = false;
}
}
function selectPickedRepo(value: string) {
const parsed = JSON.parse(value) as { owner: string; name: string };
repoOwner = parsed.owner;
repoName = parsed.name;
showRepoPicker = false;
}
async function autoDetectProvider() {
if (!giteaUrl || provider) return; // skip if manually selected
detecting = true;
try {
const result = await api.detectStaticSiteProvider(giteaUrl);
detectedProvider = result.provider;
} catch {
detectedProvider = 'gitea';
} finally {
detecting = false;
}
}
async function testConnection() {
testing = true;
connectionError = '';
connectionTested = false;
try {
// Autodetect provider if not manually set.
await autoDetectProvider();
await api.testStaticSiteConnection({
provider: effectiveProvider,
gitea_url: giteaUrl,
access_token: accessToken || undefined,
repo_owner: repoOwner,
repo_name: repoName
});
connectionTested = true;
} catch (e) {
connectionError = e instanceof Error ? e.message : 'Connection failed';
} finally {
testing = false;
}
}
async function loadBranches() {
branchesLoading = true;
try {
branches = await api.listStaticSiteBranches({
provider: effectiveProvider,
gitea_url: giteaUrl,
access_token: accessToken || undefined,
repo_owner: repoOwner,
repo_name: repoName
});
if (branches.length > 0 && !selectedBranch) {
// Default to main/master if available.
selectedBranch = branches.find(b => b === 'main') ?? branches.find(b => b === 'master') ?? branches[0];
}
} catch {
branches = [];
} finally {
branchesLoading = false;
}
}
async function loadTree() {
treeLoading = true;
try {
tree = await api.listStaticSiteTree({
provider: effectiveProvider,
gitea_url: giteaUrl,
access_token: accessToken || undefined,
repo_owner: repoOwner,
repo_name: repoName,
branch: selectedBranch
});
} catch {
tree = [];
} finally {
treeLoading = false;
}
}
function goToStep(s: number) {
step = s;
if (s === 2 && branches.length === 0) loadBranches();
if (s === 3 && tree.length === 0) loadTree();
if (s === 4) {
if (!siteName) siteName = repoName;
// Autodetect Deno mode: check if selected folder has an api/ subdirectory.
const apiPrefix = selectedFolder ? selectedFolder + '/api' : 'api';
const hasApi = tree.some(e => e.is_dir && (e.path === apiPrefix || e.path.startsWith(apiPrefix + '/')));
if (hasApi) {
mode = 'deno';
}
}
}
// Tree helpers.
const folders = $derived(tree.filter(e => e.is_dir).sort((a, b) => a.path.localeCompare(b.path)));
function getTopLevelFolders(): FolderEntry[] {
return folders.filter(f => !f.path.includes('/'));
}
function getChildFolders(parentPath: string): FolderEntry[] {
return folders.filter(f => {
if (!f.path.startsWith(parentPath + '/')) return false;
const rest = f.path.slice(parentPath.length + 1);
return !rest.includes('/');
});
}
function toggleDir(path: string) {
const next = new Set(expandedDirs);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
expandedDirs = next;
}
function selectFolder(path: string) {
selectedFolder = path;
}
// Branch picker items.
const branchPickerItems = $derived<EntityPickerItem[]>(
branches.map(b => ({ value: b, label: b }))
);
async function handleSubmit() {
submitting = true;
submitError = '';
try {
const site = await api.createStaticSite({
name: siteName,
provider: effectiveProvider,
gitea_url: giteaUrl,
repo_owner: repoOwner,
repo_name: repoName,
branch: selectedBranch,
folder_path: selectedFolder,
access_token: accessToken || undefined,
domain: domain || undefined,
mode,
render_markdown: renderMarkdown,
sync_trigger: syncTrigger,
tag_pattern: syncTrigger === 'tag' ? tagPattern : undefined
});
goto(`/sites/${site.id}`);
} catch (e) {
submitError = e instanceof Error ? e.message : 'Failed to create site';
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$t('sites.newSite')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-3">
<a href="/sites" class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors">
<IconArrowLeft size={20} />
</a>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('sites.newSite')}</h1>
</div>
<!-- Progress -->
<div class="flex items-center gap-2">
{#each Array(totalSteps) as _, i}
<div class="h-1.5 flex-1 rounded-full transition-colors {i < step ? 'bg-[var(--color-brand-600)]' : 'bg-[var(--border-primary)]'}"></div>
{/each}
</div>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 animate-scale-in">
<!-- Step 1: Repository -->
{#if step === 1}
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step1Title')}</h2>
<div class="space-y-4">
<!-- Provider selector -->
<div class="space-y-2">
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.provider')}</label>
<div class="flex gap-2 flex-wrap">
{#each providerOptions as opt}
<button
type="button"
class="rounded-lg border px-3 py-2 text-sm font-medium transition-colors {provider === opt.value ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
onclick={() => { provider = opt.value; detectedProvider = ''; }}
>
{opt.label}
</button>
{/each}
</div>
{#if provider === '' && detectedProvider}
<p class="text-xs text-emerald-600 dark:text-emerald-400">
{$t('sites.detectedProvider')}: {providerOptions.find(o => o.value === detectedProvider)?.label ?? detectedProvider}
</p>
{/if}
</div>
<!-- Paste full URL for auto-fill -->
<FormField
label={$t('sites.fullRepoUrl')}
name="fullRepoUrl"
bind:value={fullRepoUrl}
placeholder="https://git.example.com/owner/repo"
helpText={$t('sites.fullRepoUrlHelp')}
oninput={(e) => {
const val = (e.target as HTMLInputElement).value;
if (val.includes('/') && val.startsWith('http')) {
parseRepoUrl(val);
autoDetectProvider();
}
}}
/>
<!-- Individual fields (auto-filled or manual) -->
<FormField label={$t('sites.serverUrl')} name="serverUrl" bind:value={giteaUrl} placeholder="https://git.example.com" required />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label={$t('sites.repoOwner')} name="repoOwner" bind:value={repoOwner} placeholder="username" required />
<div class="flex items-end gap-2">
<div class="flex-1">
<FormField label={$t('sites.repoName')} name="repoName" bind:value={repoName} placeholder="my-app" required />
</div>
<button
type="button"
onclick={browseRepos}
title={$t('sites.browseRepos')}
disabled={!giteaUrl}
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
{#if repoPickerLoading}
<IconLoader size={16} class="animate-spin" />
{:else}
<IconSearch size={16} />
{/if}
</button>
</div>
</div>
<EntityPicker
bind:open={showRepoPicker}
items={repoPickerItems}
current={repoOwner && repoName ? JSON.stringify({ owner: repoOwner, name: repoName }) : ''}
title={$t('sites.selectRepo')}
placeholder={$t('entityPicker.search')}
onselect={selectPickedRepo}
onclose={() => { showRepoPicker = false; }}
/>
<FormField
label={$t('sites.accessToken')}
name="accessToken"
type="password"
bind:value={accessToken}
placeholder={$t('sites.accessTokenPlaceholder')}
helpText={$t('sites.accessTokenHelp')}
/>
{#if connectionError}
<div class="rounded-lg bg-[var(--color-danger-light)] p-3">
<p class="text-sm text-[var(--color-danger)]">{connectionError}</p>
</div>
{/if}
{#if connectionTested}
<div class="rounded-lg bg-emerald-50 dark:bg-emerald-900/20 p-3 flex items-center gap-2">
<IconCheck size={16} class="text-emerald-600" />
<p class="text-sm text-emerald-700 dark:text-emerald-400">{$t('sites.connectionSuccess')}</p>
</div>
{/if}
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
disabled={!giteaUrl || !repoOwner || !repoName || testing}
onclick={testConnection}
>
{#if testing}
<IconLoader size={14} class="inline mr-1 animate-spin" />
{/if}
{$t('sites.testConnection')}
</button>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
disabled={!connectionTested}
onclick={() => goToStep(2)}
>
{$t('common.next')}
</button>
</div>
<!-- Step 2: Branch -->
{:else if step === 2}
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step2Title')}</h2>
{#if branchesLoading}
<div class="flex items-center gap-2 py-4">
<IconLoader size={16} class="animate-spin text-[var(--text-tertiary)]" />
<span class="text-sm text-[var(--text-tertiary)]">{$t('sites.loadingBranches')}</span>
</div>
{:else}
<div class="space-y-2">
<p class="text-sm text-[var(--text-secondary)] mb-3">{$t('sites.selectBranch')}</p>
<button
type="button"
class="w-full text-left rounded-lg border border-[var(--border-primary)] px-4 py-3 text-sm hover:bg-[var(--surface-card-hover)] transition-colors"
onclick={() => { showBranchPicker = true; }}
>
<span class="font-medium text-[var(--text-primary)]">{selectedBranch || $t('sites.chooseBranch')}</span>
</button>
<EntityPicker
bind:open={showBranchPicker}
items={branchPickerItems}
current={selectedBranch}
title={$t('sites.selectBranch')}
placeholder={$t('entityPicker.search')}
onselect={(val) => { selectedBranch = val; showBranchPicker = false; tree = []; }}
onclose={() => { showBranchPicker = false; }}
/>
</div>
{/if}
<div class="mt-6 flex justify-between">
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 1; }}>
{$t('common.back')}
</button>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
disabled={!selectedBranch}
onclick={() => goToStep(3)}
>
{$t('common.next')}
</button>
</div>
<!-- Step 3: Folder -->
{:else if step === 3}
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step3Title')}</h2>
{#if treeLoading}
<div class="flex items-center gap-2 py-4">
<IconLoader size={16} class="animate-spin text-[var(--text-tertiary)]" />
<span class="text-sm text-[var(--text-tertiary)]">{$t('sites.loadingTree')}</span>
</div>
{:else}
<p class="text-sm text-[var(--text-secondary)] mb-3">{$t('sites.selectFolder')}</p>
<!-- Root option -->
<button
type="button"
class="w-full text-left rounded-lg px-4 py-2 text-sm transition-colors mb-1 {selectedFolder === '' ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-secondary)]'}"
onclick={() => selectFolder('')}
>
/ (root)
</button>
<div class="max-h-64 overflow-y-auto rounded-lg border border-[var(--border-primary)] p-2">
{#each getTopLevelFolders() as folder (folder.path)}
{@const isSelected = selectedFolder === folder.path}
{@const isExpanded = expandedDirs.has(folder.path)}
{@const children = getChildFolders(folder.path)}
<div>
<div class="flex items-center gap-1">
{#if children.length > 0}
<button type="button" class="p-0.5 text-[var(--text-tertiary)]" onclick={() => toggleDir(folder.path)}>
<IconChevronRight size={14} class="transition-transform {isExpanded ? 'rotate-90' : ''}" />
</button>
{:else}
<span class="w-5"></span>
{/if}
<button
type="button"
class="flex-1 text-left rounded px-2 py-1.5 text-sm transition-colors {isSelected ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-primary)]'}"
onclick={() => selectFolder(folder.path)}
>
{folder.path}
</button>
</div>
{#if isExpanded}
<div class="ml-5">
{#each children as child (child.path)}
{@const childSelected = selectedFolder === child.path}
<button
type="button"
class="w-full text-left rounded px-2 py-1.5 text-sm transition-colors {childSelected ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-secondary)]'}"
onclick={() => selectFolder(child.path)}
>
{child.path.split('/').pop()}
</button>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{#if selectedFolder}
<p class="mt-2 text-xs text-[var(--text-tertiary)]">{$t('sites.selectedFolder')}: <strong>{selectedFolder || '/'}</strong></p>
{/if}
{/if}
<div class="mt-6 flex justify-between">
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 2; }}>
{$t('common.back')}
</button>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors"
onclick={() => goToStep(4)}
>
{$t('common.next')}
</button>
</div>
<!-- Step 4: Configuration -->
{:else if step === 4}
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step4Title')}</h2>
<div class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label={$t('sites.siteName')} name="siteName" bind:value={siteName} placeholder="my-site" required />
<FormField label={$t('sites.domain')} name="domain" bind:value={domain} placeholder="site.example.com" helpText={$t('sites.domainHelp')} />
</div>
<!-- Mode -->
<div class="space-y-2">
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.mode')}</label>
<div class="flex gap-3">
<button
type="button"
class="flex-1 rounded-lg border px-4 py-3 text-sm text-left transition-colors {mode === 'static' ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}"
onclick={() => { mode = 'static'; }}
>
<div class="font-medium text-[var(--text-primary)]">Static</div>
<div class="text-xs text-[var(--text-tertiary)] mt-0.5">{$t('sites.modeStaticDesc')}</div>
</button>
<button
type="button"
class="flex-1 rounded-lg border px-4 py-3 text-sm text-left transition-colors {mode === 'deno' ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}"
onclick={() => { mode = 'deno'; }}
>
<div class="font-medium text-[var(--text-primary)]">Deno</div>
<div class="text-xs text-[var(--text-tertiary)] mt-0.5">{$t('sites.modeDenoDesc')}</div>
</button>
</div>
</div>
<!-- Sync trigger -->
<div class="space-y-2">
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.syncTrigger')}</label>
<div class="flex gap-3">
{#each [
{ value: 'manual', label: $t('sites.triggerManual') },
{ value: 'push', label: $t('sites.triggerPush') },
{ value: 'tag', label: $t('sites.triggerTag') }
] as opt}
<button
type="button"
class="flex-1 rounded-lg border px-4 py-2.5 text-sm text-center font-medium transition-colors {syncTrigger === opt.value ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
onclick={() => { syncTrigger = opt.value as 'push' | 'tag' | 'manual'; }}
>
{opt.label}
</button>
{/each}
</div>
</div>
{#if syncTrigger === 'tag'}
<FormField label={$t('sites.tagPattern')} name="tagPattern" bind:value={tagPattern} placeholder="v*" helpText={$t('sites.tagPatternHelp')} />
{/if}
<!-- Options -->
<label class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<input type="checkbox" bind:checked={renderMarkdown} class="rounded border-[var(--border-input)]" />
{$t('sites.renderMarkdown')}
</label>
</div>
<div class="mt-6 flex justify-between">
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 3; }}>
{$t('common.back')}
</button>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
disabled={!siteName.trim()}
onclick={() => { step = 5; }}
>
{$t('common.next')}
</button>
</div>
<!-- Step 5: Review -->
{:else if step === 5}
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step5Title')}</h2>
<div class="space-y-3 text-sm">
<div class="grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg bg-[var(--surface-card-hover)] p-4">
<span class="text-[var(--text-tertiary)]">{$t('sites.provider')}</span>
<span class="text-[var(--text-primary)]">{providerOptions.find(o => o.value === effectiveProvider)?.label ?? effectiveProvider}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.repoUrl')}</span>
<span class="text-[var(--text-primary)] font-mono text-xs">{giteaUrl}/{repoOwner}/{repoName}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.branch')}</span>
<span class="text-[var(--text-primary)]">{selectedBranch}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.folder')}</span>
<span class="text-[var(--text-primary)]">{selectedFolder || '/ (root)'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.siteName')}</span>
<span class="text-[var(--text-primary)]">{siteName}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.domain')}</span>
<span class="text-[var(--text-primary)]">{domain || '-'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.mode')}</span>
<span class="text-[var(--text-primary)]">{mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.syncTrigger')}</span>
<span class="text-[var(--text-primary)]">{syncTrigger}{syncTrigger === 'tag' ? ` (${tagPattern})` : ''}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.renderMarkdown')}</span>
<span class="text-[var(--text-primary)]">{renderMarkdown ? $t('common.yes') : $t('common.no')}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.accessToken')}</span>
<span class="text-[var(--text-primary)]">{accessToken ? '••••••••' : $t('sites.noToken')}</span>
</div>
</div>
{#if submitError}
<div class="mt-4 rounded-lg bg-[var(--color-danger-light)] p-3">
<p class="text-sm text-[var(--color-danger)]">{submitError}</p>
</div>
{/if}
<div class="mt-6 flex justify-between">
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 4; }}>
{$t('common.back')}
</button>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
disabled={submitting}
onclick={handleSubmit}
>
{#if submitting}
<IconLoader size={14} class="inline mr-1 animate-spin" />
{/if}
{$t('sites.createSite')}
</button>
</div>
{/if}
</div>
</div>