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:
@@ -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 };
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}
|
||||
@@ -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} · {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}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user