Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 6s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 5s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Failing after 6s
494 lines
17 KiB
Plaintext
494 lines
17 KiB
Plaintext
---
|
|
import '../styles/global.css';
|
|
import Header from '../components/Header.astro';
|
|
import Footer from '../components/Footer.astro';
|
|
import SubscriptionPopup from '../components/SubscriptionPopup.astro';
|
|
import BackToTop from '../components/interactive/BackToTop.svelte';
|
|
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
|
import { getI18n, LOCALE_COOKIE_NAME, SUPPORTED_LOCALES } from '../lib/i18n';
|
|
import type { SiteSettings } from '../lib/types';
|
|
|
|
interface Props {
|
|
title?: string;
|
|
description?: string;
|
|
siteSettings?: SiteSettings;
|
|
canonical?: string;
|
|
noindex?: boolean;
|
|
ogImage?: string;
|
|
ogType?: string;
|
|
twitterCard?: 'summary' | 'summary_large_image';
|
|
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
|
|
}
|
|
|
|
const props = Astro.props;
|
|
const { locale, messages } = getI18n(Astro);
|
|
|
|
let siteSettings = props.siteSettings ?? DEFAULT_SITE_SETTINGS;
|
|
|
|
if (!props.siteSettings) {
|
|
try {
|
|
siteSettings = await api.getSiteSettings();
|
|
} catch (error) {
|
|
console.error('Failed to load site settings:', error);
|
|
}
|
|
}
|
|
|
|
const title = props.title || siteSettings.siteTitle;
|
|
const description = props.description || siteSettings.siteDescription;
|
|
const siteUrl = siteSettings.siteUrl.replace(/\/$/, '');
|
|
const defaultCanonical = `${siteUrl}${Astro.url.pathname}`;
|
|
const canonical = props.canonical
|
|
? /^https?:\/\//i.test(props.canonical)
|
|
? props.canonical
|
|
: `${siteUrl}${props.canonical.startsWith('/') ? props.canonical : `/${props.canonical}`}`
|
|
: defaultCanonical;
|
|
const resolvedOgImage = props.ogImage || siteSettings.seo.defaultOgImage;
|
|
const ogImage = resolvedOgImage
|
|
? /^https?:\/\//i.test(resolvedOgImage)
|
|
? resolvedOgImage
|
|
: `${siteUrl}${resolvedOgImage.startsWith('/') ? resolvedOgImage : `/${resolvedOgImage}`}`
|
|
: undefined;
|
|
const ogType = props.ogType || 'website';
|
|
const twitterCard = props.twitterCard || (ogImage ? 'summary_large_image' : 'summary');
|
|
const defaultJsonLdObjects = [
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'WebSite',
|
|
name: siteSettings.siteName,
|
|
alternateName: siteSettings.siteShortName,
|
|
url: siteUrl,
|
|
description,
|
|
inLanguage: locale,
|
|
potentialAction: {
|
|
'@type': 'SearchAction',
|
|
target: `${siteUrl}/search?q={search_term_string}`,
|
|
'query-input': 'required name=search_term_string',
|
|
},
|
|
},
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Person',
|
|
name: siteSettings.ownerName,
|
|
url: siteUrl,
|
|
image: siteSettings.ownerAvatarUrl || undefined,
|
|
jobTitle: siteSettings.ownerTitle,
|
|
description: siteSettings.ownerBio || description,
|
|
sameAs: [
|
|
siteSettings.social.github,
|
|
siteSettings.social.twitter,
|
|
].filter(Boolean),
|
|
},
|
|
].filter((item) => item.name || item.url);
|
|
const mergedJsonLdObjects = [
|
|
...defaultJsonLdObjects,
|
|
...(props.jsonLd === undefined
|
|
? []
|
|
: Array.isArray(props.jsonLd)
|
|
? props.jsonLd
|
|
: [props.jsonLd]),
|
|
];
|
|
const jsonLd = mergedJsonLdObjects.length ? JSON.stringify(mergedJsonLdObjects) : undefined;
|
|
const i18nPayload = JSON.stringify({ locale, messages });
|
|
---
|
|
|
|
<!DOCTYPE html>
|
|
<html lang={locale} data-locale={locale}>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<meta name="description" content={description} />
|
|
<meta name="robots" content={props.noindex ? 'noindex, nofollow' : 'index, follow'} />
|
|
<link rel="canonical" href={canonical} />
|
|
<meta property="og:site_name" content={siteSettings.siteName} />
|
|
<meta property="og:title" content={title} />
|
|
<meta property="og:description" content={description} />
|
|
<meta property="og:type" content={ogType} />
|
|
<meta property="og:url" content={canonical} />
|
|
<meta name="twitter:card" content={twitterCard} />
|
|
<meta name="twitter:title" content={title} />
|
|
<meta name="twitter:description" content={description} />
|
|
{siteSettings.seo.defaultTwitterHandle && (
|
|
<meta name="twitter:site" content={siteSettings.seo.defaultTwitterHandle} />
|
|
)}
|
|
{siteSettings.seo.defaultTwitterHandle && (
|
|
<meta name="twitter:creator" content={siteSettings.seo.defaultTwitterHandle} />
|
|
)}
|
|
{ogImage && <meta property="og:image" content={ogImage} />}
|
|
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
<title>{title}</title>
|
|
{jsonLd && <script type="application/ld+json" set:html={jsonLd}></script>}
|
|
|
|
<style is:inline>
|
|
:root {
|
|
--primary: #2563eb;
|
|
--primary-rgb: 37 99 235;
|
|
--primary-light: rgba(37 99 235 / 0.14);
|
|
--primary-dark: #1d4ed8;
|
|
--secondary: #f97316;
|
|
--secondary-rgb: 249 115 22;
|
|
--secondary-light: rgba(249 115 22 / 0.14);
|
|
--bg: #eef3f8;
|
|
--bg-rgb: 238 243 248;
|
|
--bg-secondary: #e2e8f0;
|
|
--bg-tertiary: #cbd5e1;
|
|
--terminal-bg: #f8fbff;
|
|
--text: #0f172a;
|
|
--text-rgb: 15 23 42;
|
|
--text-secondary: #475569;
|
|
--text-tertiary: #5f6f86;
|
|
--terminal-text: #0f172a;
|
|
--title-color: #0f172a;
|
|
--button-text: #0f172a;
|
|
--border-color: #d6e0ea;
|
|
--border-color-rgb: 214 224 234;
|
|
--terminal-border: #d6e0ea;
|
|
--tag-bg: #edf3f8;
|
|
--tag-text: #0f172a;
|
|
--header-bg: rgba(244 248 252 / 0.92);
|
|
--code-bg: #eef3f8;
|
|
--success: #10b981;
|
|
--success-rgb: 16 185 129;
|
|
--success-light: #d1fae5;
|
|
--success-dark: #065f46;
|
|
--warning: #f59e0b;
|
|
--warning-rgb: 245 158 11;
|
|
--warning-light: #fef3c7;
|
|
--warning-dark: #92400e;
|
|
--danger: #ef4444;
|
|
--danger-rgb: 239 68 68;
|
|
--danger-light: #fee2e2;
|
|
--danger-dark: #991b1b;
|
|
--gray-light: #f3f4f6;
|
|
--gray-dark: #374151;
|
|
--btn-close: #ff5f56;
|
|
--btn-minimize: #ffbd2e;
|
|
--btn-expand: #27c93f;
|
|
}
|
|
|
|
html.dark {
|
|
--primary: #00ff9d;
|
|
--primary-rgb: 0 255 157;
|
|
--primary-light: #00ff9d33;
|
|
--primary-dark: #00b8ff;
|
|
--secondary: #00b8ff;
|
|
--secondary-rgb: 0 184 255;
|
|
--secondary-light: #00b8ff33;
|
|
--bg: #0a0e17;
|
|
--bg-rgb: 10 14 23;
|
|
--bg-secondary: #161b22;
|
|
--bg-tertiary: #21262d;
|
|
--terminal-bg: #0d1117;
|
|
--text: #e6e6e6;
|
|
--text-rgb: 230 230 230;
|
|
--text-secondary: #d1d5db;
|
|
--text-tertiary: #9ca3af;
|
|
--terminal-text: #e6e6e6;
|
|
--title-color: #ffffff;
|
|
--button-text: #e6e6e6;
|
|
--border-color: rgba(255 255 255 / 0.1);
|
|
--border-color-rgb: 255 255 255;
|
|
--terminal-border: rgba(255 255 255 / 0.1);
|
|
--tag-bg: #161b22;
|
|
--tag-text: #e6e6e6;
|
|
--header-bg: rgba(22 27 34 / 0.9);
|
|
--code-bg: #161b22;
|
|
--success-light: #064e3b;
|
|
--success-dark: #d1fae5;
|
|
--warning-light: #78350f;
|
|
--warning-dark: #fef3c7;
|
|
--danger-light: #7f1d1d;
|
|
--danger-dark: #fee2e2;
|
|
--gray-light: #1f2937;
|
|
--gray-dark: #e5e7eb;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not(.light):not(.dark) {
|
|
--primary: #00ff9d;
|
|
--primary-rgb: 0 255 157;
|
|
--primary-light: #00ff9d33;
|
|
--primary-dark: #00b8ff;
|
|
--secondary: #00b8ff;
|
|
--secondary-rgb: 0 184 255;
|
|
--secondary-light: #00b8ff33;
|
|
--bg: #0a0e17;
|
|
--bg-rgb: 10 14 23;
|
|
--bg-secondary: #161b22;
|
|
--bg-tertiary: #21262d;
|
|
--terminal-bg: #0d1117;
|
|
--text: #e6e6e6;
|
|
--text-rgb: 230 230 230;
|
|
--text-secondary: #d1d5db;
|
|
--text-tertiary: #9ca3af;
|
|
--terminal-text: #e6e6e6;
|
|
--title-color: #ffffff;
|
|
--button-text: #e6e6e6;
|
|
--border-color: rgba(255 255 255 / 0.1);
|
|
--border-color-rgb: 255 255 255;
|
|
--terminal-border: rgba(255 255 255 / 0.1);
|
|
--tag-bg: #161b22;
|
|
--tag-text: #e6e6e6;
|
|
--header-bg: rgba(22 27 34 / 0.9);
|
|
--code-bg: #161b22;
|
|
--success-light: #064e3b;
|
|
--success-dark: #d1fae5;
|
|
--warning-light: #78350f;
|
|
--warning-dark: #fef3c7;
|
|
--danger-light: #7f1d1d;
|
|
--danger-dark: #fee2e2;
|
|
--gray-light: #1f2937;
|
|
--gray-dark: #e5e7eb;
|
|
}
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
</style>
|
|
|
|
<script is:inline define:vars={{ i18nPayload, locale, localeCookieName: LOCALE_COOKIE_NAME, supportedLocales: SUPPORTED_LOCALES }}>
|
|
window.__TERMI_I18N__ = JSON.parse(i18nPayload);
|
|
window.__termiTranslate = function(key, params = {}) {
|
|
const payload = window.__TERMI_I18N__ || { messages: {} };
|
|
const template = key.split('.').reduce((current, segment) => {
|
|
if (!current || typeof current !== 'object') {
|
|
return undefined;
|
|
}
|
|
return current[segment];
|
|
}, payload.messages);
|
|
|
|
if (typeof template !== 'string') {
|
|
return key;
|
|
}
|
|
|
|
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? ''));
|
|
};
|
|
|
|
(function() {
|
|
document.documentElement.lang = locale;
|
|
document.documentElement.dataset.locale = locale;
|
|
localStorage.setItem('locale', locale);
|
|
document.cookie = `${localeCookieName}=${encodeURIComponent(locale)};path=/;max-age=31536000;samesite=lax`;
|
|
})();
|
|
</script>
|
|
|
|
<script is:inline>
|
|
(function() {
|
|
function renderPrompt(el, nextCommand, typingMode) {
|
|
const id = el.getAttribute('data-id');
|
|
const cmdEl = document.getElementById('cmd-' + id);
|
|
const cursorEl = document.getElementById('cursor-' + id);
|
|
|
|
if (!cmdEl || !cursorEl) return;
|
|
|
|
const command = String(nextCommand || '');
|
|
const typing = typingMode === true || typingMode === 'true';
|
|
const renderSeq = String((Number(el.getAttribute('data-render-seq') || '0') || 0) + 1);
|
|
|
|
el.setAttribute('data-command', command);
|
|
el.setAttribute('data-render-seq', renderSeq);
|
|
cmdEl.textContent = '';
|
|
cursorEl.style.animation = 'none';
|
|
cursorEl.style.opacity = '1';
|
|
|
|
if (!typing) {
|
|
cmdEl.textContent = command;
|
|
cursorEl.style.animation = 'blink 1s infinite';
|
|
return;
|
|
}
|
|
|
|
let index = 0;
|
|
|
|
function typeChar() {
|
|
if (el.getAttribute('data-render-seq') !== renderSeq) {
|
|
return;
|
|
}
|
|
|
|
if (index < command.length) {
|
|
cmdEl.textContent += command.charAt(index);
|
|
index += 1;
|
|
setTimeout(typeChar, 42 + Math.random() * 22);
|
|
} else {
|
|
cursorEl.style.animation = 'blink 1s infinite';
|
|
}
|
|
}
|
|
|
|
setTimeout(typeChar, 120);
|
|
}
|
|
|
|
if (!window.__termiCommandPrompt) {
|
|
window.__termiCommandPrompt = {
|
|
set(promptId, command, options = {}) {
|
|
if (!promptId) return;
|
|
const el = document.querySelector(`[data-prompt-id="${promptId}"]`);
|
|
if (!el) return;
|
|
renderPrompt(el, command, options.typing ?? true);
|
|
}
|
|
};
|
|
}
|
|
|
|
function mountPrompts() {
|
|
const prompts = document.querySelectorAll('[data-command]:not([data-command-mounted])');
|
|
|
|
prompts.forEach(function(el) {
|
|
el.setAttribute('data-command-mounted', 'true');
|
|
renderPrompt(el, el.getAttribute('data-command') || '', el.getAttribute('data-typing') === 'true');
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', mountPrompts, { once: true });
|
|
} else {
|
|
mountPrompts();
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
<script is:inline>
|
|
(function() {
|
|
const STORAGE_KEY = 'theme';
|
|
const VALID_THEMES = new Set(['light', 'dark', 'system']);
|
|
const root = document.documentElement;
|
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
|
|
function readThemeMode() {
|
|
try {
|
|
const savedMode = localStorage.getItem(STORAGE_KEY);
|
|
return VALID_THEMES.has(savedMode) ? savedMode : 'system';
|
|
} catch {
|
|
return 'system';
|
|
}
|
|
}
|
|
|
|
function resolveTheme(mode) {
|
|
return mode === 'dark' || (mode === 'system' && mediaQuery.matches) ? 'dark' : 'light';
|
|
}
|
|
|
|
function applyTheme(mode, options = {}) {
|
|
const { persist = false, notify = true } = options;
|
|
const safeMode = VALID_THEMES.has(mode) ? mode : 'system';
|
|
const resolvedTheme = resolveTheme(safeMode);
|
|
|
|
root.dataset.themeMode = safeMode;
|
|
root.dataset.themeResolved = resolvedTheme;
|
|
root.classList.toggle('dark', resolvedTheme === 'dark');
|
|
root.classList.toggle('light', resolvedTheme === 'light');
|
|
|
|
if (persist) {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, safeMode);
|
|
} catch {
|
|
// Ignore storage write failures and keep the UI responsive.
|
|
}
|
|
}
|
|
|
|
if (notify) {
|
|
window.dispatchEvent(
|
|
new CustomEvent('termi:theme-change', {
|
|
detail: { mode: safeMode, resolved: resolvedTheme },
|
|
})
|
|
);
|
|
}
|
|
|
|
return { mode: safeMode, resolved: resolvedTheme };
|
|
}
|
|
|
|
function syncThemeFromStorage(notify = false) {
|
|
return applyTheme(readThemeMode(), { notify });
|
|
}
|
|
|
|
window.__termiTheme = {
|
|
getMode: readThemeMode,
|
|
resolveTheme,
|
|
applyTheme(mode) {
|
|
return applyTheme(mode, { persist: true, notify: true });
|
|
},
|
|
syncTheme() {
|
|
return syncThemeFromStorage(true);
|
|
},
|
|
};
|
|
|
|
syncThemeFromStorage(false);
|
|
|
|
if (!window.__termiThemeMediaBound) {
|
|
const handleSystemThemeChange = () => {
|
|
if (readThemeMode() === 'system') {
|
|
syncThemeFromStorage(true);
|
|
}
|
|
};
|
|
|
|
if (typeof mediaQuery.addEventListener === 'function') {
|
|
mediaQuery.addEventListener('change', handleSystemThemeChange);
|
|
} else {
|
|
mediaQuery.onchange = handleSystemThemeChange;
|
|
}
|
|
|
|
window.__termiThemeMediaBound = true;
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
<link
|
|
rel="stylesheet"
|
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
|
media="print"
|
|
onload="this.media='all'"
|
|
/>
|
|
<noscript>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
|
</noscript>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link
|
|
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
|
|
rel="stylesheet"
|
|
media="print"
|
|
onload="this.media='all'"
|
|
>
|
|
<noscript>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
</noscript>
|
|
</head>
|
|
<body class="min-h-screen bg-[var(--bg)] text-[var(--text)] font-sans antialiased">
|
|
<div class="relative min-h-screen flex flex-col">
|
|
<div class="fixed inset-0 -z-10 bg-[var(--bg)]"></div>
|
|
<div
|
|
class="fixed inset-0 -z-10 opacity-70"
|
|
style="background:
|
|
radial-gradient(circle at top left, rgba(var(--primary-rgb), 0.06), transparent 30%),
|
|
radial-gradient(circle at top right, rgba(var(--secondary-rgb), 0.05), transparent 26%),
|
|
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 34%, transparent), transparent 48%);"
|
|
></div>
|
|
<div
|
|
class="fixed inset-0 -z-10 opacity-30"
|
|
style="background-image:
|
|
linear-gradient(rgba(var(--primary-rgb), 0.035) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(var(--primary-rgb), 0.03) 1px, transparent 1px);
|
|
background-size: 100% 18px, 18px 100%;"
|
|
></div>
|
|
|
|
<Header siteSettings={siteSettings} />
|
|
|
|
<main class="flex-1 w-full">
|
|
<slot />
|
|
</main>
|
|
|
|
<Footer siteSettings={siteSettings} />
|
|
<SubscriptionPopup siteSettings={siteSettings} requestUrl={Astro.url} />
|
|
<BackToTop client:load />
|
|
</div>
|
|
</body>
|
|
</html>
|
|
|
|
<style>
|
|
:global(body) {
|
|
font-family: 'IBM Plex Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
}
|
|
|
|
:global(code, pre) {
|
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
}
|
|
</style>
|