Initial commit

This commit is contained in:
unknown
2026-05-22 02:52:15 +02:00
commit 125321c418
55 changed files with 9231 additions and 0 deletions

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<title>cg.cx</title>
</head>
<body>
<div id="loading-screen"></div>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "cgcx-frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"vite": "^5.0.0"
},
"dependencies": {
"marked": "^12.0.0",
"dompurify": "^3.0.0"
}
}

Binary file not shown.

Binary file not shown.

29
frontend/src/App.svelte Normal file
View File

@@ -0,0 +1,29 @@
<script>
import Home from './routes/Home.svelte'
import ViewContent from './routes/ViewContent.svelte'
import LoadingScreen from './components/LoadingScreen.svelte'
let cxid = $state('')
let sc = $state('')
function readParams() {
const params = new URLSearchParams(window.location.search)
cxid = params.get('cxid') || ''
sc = params.get('sc') || ''
}
$effect(() => {
readParams()
const onPopState = () => readParams()
window.addEventListener('popstate', onPopState)
return () => window.removeEventListener('popstate', onPopState)
})
</script>
<LoadingScreen />
{#if cxid}
<ViewContent {cxid} {sc} />
{:else}
<Home />
{/if}

View File

@@ -0,0 +1,31 @@
<script>
let { src, mime } = $props()
</script>
<div class="audio-player">
<div class="label">[ Audio ]</div>
<audio controls preload="metadata" {src}></audio>
</div>
<style>
.audio-player {
max-width: 600px;
margin: 24px auto;
padding: 24px;
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.label {
font-family: 'Press Start 2P', cursive;
font-size: 0.7rem;
color: var(--retro-green);
}
audio {
width: 100%;
}
</style>

View File

@@ -0,0 +1,59 @@
<script>
import { formatSize } from '../lib/api.js'
let { file, downloadUrl } = $props()
</script>
<div class="document-card">
<div class="icon">[DOC]</div>
<div class="info">
<p class="name">{file.name}</p>
<p class="meta">{file.mime}{formatSize(file.size)}</p>
</div>
<a class="btn" href={downloadUrl} download={file.name}>Download</a>
</div>
<style>
.document-card {
max-width: 600px;
margin: 24px auto;
padding: 20px;
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.icon {
font-family: 'Press Start 2P', cursive;
font-size: 0.6rem;
color: var(--retro-green);
}
.info { flex: 1; min-width: 0; }
.name {
font-family: 'Press Start 2P', cursive;
font-size: 0.65rem;
word-break: break-all;
}
.meta {
font-size: 0.9rem;
color: #666;
margin-top: 4px;
}
.btn {
font-family: 'Press Start 2P', cursive;
font-size: 0.55rem;
padding: 10px 14px;
border: 3px solid var(--retro-green);
background: var(--retro-panel);
color: var(--retro-green);
text-decoration: none;
box-shadow: 3px 3px 0px rgba(0,0,0,0.15);
}
.btn:hover {
background: var(--retro-green);
color: #fff;
}
</style>

View File

@@ -0,0 +1,56 @@
<script>
import { formatSize } from '../lib/api.js'
let { file, downloadUrl } = $props()
</script>
<div class="warning-card">
<div class="badge">[!] DANGEROUS FILE</div>
<p class="name">{file.name}</p>
<p class="meta">{file.mime}{formatSize(file.size)}</p>
<p class="notice">
This file may be dangerous. Never execute unknown files. Only download if you trust the source.
</p>
<a class="btn danger" href={downloadUrl} download={file.name}>Download at your own risk</a>
</div>
<style>
.warning-card {
max-width: 600px;
margin: 24px auto;
padding: 24px;
background: #fff8f8;
border: 3px solid var(--retro-danger);
box-shadow: 6px 6px 0px rgba(139,26,26,0.15);
text-align: center;
}
.badge {
font-family: 'Press Start 2P', cursive;
font-size: 0.6rem;
color: var(--retro-danger);
margin-bottom: 12px;
}
.name {
font-family: 'Press Start 2P', cursive;
font-size: 0.7rem;
word-break: break-all;
}
.meta {
font-size: 0.9rem;
color: #666;
margin: 8px 0;
}
.notice {
font-size: 1rem;
color: #555;
margin: 16px 0;
}
.btn.danger {
border-color: var(--retro-danger);
color: var(--retro-danger);
}
.btn.danger:hover {
background: var(--retro-danger);
color: #fff;
}
</style>

View File

@@ -0,0 +1,22 @@
<script>
let { src, name } = $props()
</script>
<div class="image-viewer">
<img {src} alt={name} decoding="async" loading="eager" />
</div>
<style>
.image-viewer {
display: flex;
justify-content: center;
padding: 16px;
}
img {
max-width: 100%;
max-height: 85vh;
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
image-rendering: auto;
}
</style>

View File

@@ -0,0 +1,160 @@
<script>
let visible = $state(true)
let progress = $state(0)
let fading = $state(false)
let on = $state(false)
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
$effect(() => {
if (reducedMotion) {
on = true
progress = 100
setTimeout(() => {
fading = true
setTimeout(() => { visible = false }, 400)
}, 300)
return
}
requestAnimationFrame(() => { on = true })
const duration = 1600
const start = performance.now()
function tick(now) {
const elapsed = now - start
progress = Math.min(100, (elapsed / duration) * 100)
if (progress < 100) {
requestAnimationFrame(tick)
} else {
setTimeout(() => {
fading = true
setTimeout(() => { visible = false }, 700)
}, 300)
}
}
requestAnimationFrame(tick)
})
</script>
{#if visible}
<div class="loading-screen" class:fading class:on>
<div class="scanlines"></div>
<div class="curvature"></div>
<div class="content">
<div class="logo">CG.CX</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {progress}%"></div>
</div>
</div>
</div>
{/if}
<style>
.loading-screen {
position: fixed;
inset: 0;
z-index: 9999;
background: var(--retro-bg);
display: flex;
align-items: center;
justify-content: center;
transform: scaleY(0.005) scaleX(0.8);
opacity: 0;
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.3s ease;
overflow: hidden;
}
.loading-screen.on {
transform: scaleY(1) scaleX(1);
opacity: 1;
}
.loading-screen.fading {
opacity: 0;
pointer-events: none;
transition: opacity 0.7s ease;
}
.scanlines {
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.04) 2px,
rgba(0,0,0,0.04) 4px
);
animation: scanline-flicker 0.1s infinite;
}
@keyframes scanline-flicker {
0%, 100% { opacity: 0.95; }
50% { opacity: 1; }
}
.curvature {
position: absolute;
inset: 0;
pointer-events: none;
box-shadow: inset 0 0 80px rgba(0,0,0,0.12);
border-radius: 15% / 5%;
}
.content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.logo {
font-family: 'Press Start 2P', cursive;
font-size: clamp(1.4rem, 5vw, 2.4rem);
color: var(--retro-green);
text-shadow: 2px 2px 0px rgba(0,0,0,0.1);
animation: glitch-reveal 0.6s ease-out 0.3s both;
}
@keyframes glitch-reveal {
0% { clip-path: inset(0 100% 0 0); transform: translateX(-6px); }
25% { clip-path: inset(0 70% 0 0); transform: translateX(5px); }
50% { clip-path: inset(0 30% 0 0); transform: translateX(-3px); }
75% { clip-path: inset(0 10% 0 0); transform: translateX(2px); }
100% { clip-path: inset(0 0 0 0); transform: translateX(0); }
}
.progress-bar {
width: min(300px, 80vw);
height: 10px;
background: #ddd;
border: 2px solid var(--retro-border);
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--retro-green), var(--retro-green-light));
transition: width 0.05s linear;
}
@media (prefers-reduced-motion: reduce) {
.loading-screen {
transform: none;
opacity: 1;
transition: opacity 0.3s ease;
}
.scanlines {
display: none;
}
.logo {
animation: none;
}
.progress-fill {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,79 @@
<script>
import { marked } from 'marked'
import DOMPurify from 'dompurify'
let { src } = $props()
let html = $state('')
let loading = $state(true)
$effect(() => {
fetch(src)
.then(r => r.text())
.then(text => {
try {
const raw = marked.parse(text, { async: false })
html = DOMPurify.sanitize(raw)
} catch (e) {
html = '<p class="error">Failed to render markdown.</p>'
}
loading = false
})
.catch(() => {
html = '<p class="error">Failed to load markdown.</p>'
loading = false
})
})
</script>
<div class="markdown-renderer">
{#if loading}
<p>Loading markdown...</p>
{:else}
{@html html}
{/if}
</div>
<style>
.markdown-renderer {
max-width: 800px;
margin: 24px auto;
padding: 24px;
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
font-size: 1.05rem;
line-height: 1.6;
}
.markdown-renderer :global(h1), .markdown-renderer :global(h2), .markdown-renderer :global(h3) {
font-family: 'Press Start 2P', cursive;
color: var(--retro-green);
margin: 1.2em 0 0.6em;
font-size: 0.9rem;
}
.markdown-renderer :global(pre) {
background: #1a1a1a;
color: #e0e0e0;
padding: 12px;
overflow-x: auto;
border: 2px solid var(--retro-border);
font-family: 'VT323', monospace;
font-size: 1.1rem;
}
.markdown-renderer :global(code) {
font-family: 'VT323', monospace;
background: #eee;
padding: 2px 6px;
}
.markdown-renderer :global(pre code) {
background: transparent;
padding: 0;
}
.markdown-renderer :global(p) { margin: 0.8em 0; }
.markdown-renderer :global(ul), .markdown-renderer :global(ol) { margin: 0.8em 0 0.8em 1.5em; }
.markdown-renderer :global(blockquote) {
border-left: 4px solid var(--retro-green);
padding-left: 12px;
color: #444;
margin: 0.8em 0;
}
</style>

View File

@@ -0,0 +1,82 @@
<script>
import { fileUrl } from '../lib/api.js'
import ImageViewer from './ImageViewer.svelte'
import VideoPlayer from './VideoPlayer.svelte'
import AudioPlayer from './AudioPlayer.svelte'
import MarkdownRenderer from './MarkdownRenderer.svelte'
import TextViewer from './TextViewer.svelte'
import DocumentCard from './DocumentCard.svelte'
import ExecutableWarning from './ExecutableWarning.svelte'
let { files, cxid, password = '' } = $props()
function getViewer(file) {
const flags = file.render_flags || 0
if (flags & 1) return 'image'
if (flags & 2) return 'video'
if (flags & 4) return 'audio'
if (flags & 8) return 'markdown'
if (flags & 16) return 'text'
if (flags & 64 || flags & 128) return 'dangerous'
return 'document'
}
</script>
<div class="gallery">
{#each files as file, i (file.idx)}
{@const viewer = getViewer(file)}
<div class="item">
<div class="item-header">
<span class="item-index">#{i + 1}</span>
<span class="item-name">{file.name}</span>
</div>
{#if viewer === 'image'}
<ImageViewer src={fileUrl(cxid, file.idx, false, password)} name={file.name} />
{:else if viewer === 'video'}
<VideoPlayer src={fileUrl(cxid, file.idx, false, password)} mime={file.mime} />
{:else if viewer === 'audio'}
<AudioPlayer src={fileUrl(cxid, file.idx, false, password)} mime={file.mime} />
{:else if viewer === 'markdown'}
<MarkdownRenderer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'text'}
<TextViewer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'dangerous'}
<ExecutableWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{:else}
<DocumentCard {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{/if}
</div>
{/each}
</div>
<style>
.gallery {
max-width: 1000px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.item {
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
}
.item-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: #f0f0f0;
border-bottom: 2px solid var(--retro-border);
}
.item-index {
font-family: 'Press Start 2P', cursive;
font-size: 0.5rem;
color: var(--retro-green);
}
.item-name {
font-size: 1rem;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,46 @@
<script>
let { src } = $props()
let text = $state('')
let loading = $state(true)
$effect(() => {
fetch(src)
.then(r => r.text())
.then(t => {
text = t
loading = false
})
.catch(() => {
text = 'Failed to load text content.'
loading = false
})
})
</script>
<div class="text-viewer">
{#if loading}
<p>Loading text...</p>
{:else}
<pre>{text}</pre>
{/if}
</div>
<style>
.text-viewer {
max-width: 900px;
margin: 24px auto;
padding: 24px;
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
}
pre {
white-space: pre-wrap;
word-break: break-word;
font-family: 'VT323', monospace;
font-size: 1.1rem;
line-height: 1.5;
max-height: 80vh;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,23 @@
<script>
let { src, mime } = $props()
</script>
<div class="video-player">
<!-- svelte-ignore a11y_media_has_caption -->
<video controls preload="metadata" {src}></video>
</div>
<style>
.video-player {
display: flex;
justify-content: center;
padding: 16px;
}
video {
max-width: 100%;
max-height: 80vh;
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
background: #000;
}
</style>

38
frontend/src/lib/api.js Normal file
View File

@@ -0,0 +1,38 @@
const API_BASE = "http://127.0.0.1:8090";
export async function fetchMetadata(cxid) {
const res = await fetch(
`${API_BASE}/api/content/${encodeURIComponent(cxid)}`,
);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function verifyPassword(cxid, password) {
const res = await fetch(
`${API_BASE}/api/content/${encodeURIComponent(cxid)}/verify-password`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
credentials: "same-origin",
},
);
return res.ok;
}
export function fileUrl(cxid, fileIdx, download = false, password = "") {
let url = `${API_BASE}/api/content/${encodeURIComponent(cxid)}/file/${fileIdx}`;
if (download) url += "?download=1";
if (password)
url += (download ? "&" : "?") + `sc=${encodeURIComponent(password)}`;
return url;
}
export function formatSize(bytes) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.min(Math.floor(Math.log2(bytes) / 10), units.length - 1);
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}

12
frontend/src/main.js Normal file
View File

@@ -0,0 +1,12 @@
import { mount } from 'svelte'
import App from './App.svelte'
import './styles/global.css'
const loadingScreen = document.getElementById('loading-screen')
if (loadingScreen) loadingScreen.remove()
const app = mount(App, {
target: document.getElementById('app'),
})
export default app

View File

@@ -0,0 +1,128 @@
<script>
import { fetchMetadata, verifyPassword } from '../lib/api.js'
let cxidInput = $state('')
let passwordInput = $state('')
let needsPassword = $state(false)
let loading = $state(false)
let error = $state('')
async function submit() {
error = ''
if (!cxidInput.trim()) return
loading = true
try {
const meta = await fetchMetadata(cxidInput.trim())
if (meta.has_password && !passwordInput) {
needsPassword = true
loading = false
return
}
if (meta.has_password) {
const ok = await verifyPassword(cxidInput.trim(), passwordInput)
if (!ok) {
error = 'Incorrect password.'
loading = false
return
}
}
const url = new URL(window.location.href)
url.searchParams.set('cxid', cxidInput.trim())
if (passwordInput) url.searchParams.set('sc', passwordInput)
history.pushState({}, '', url.toString())
window.dispatchEvent(new PopStateEvent('popstate'))
} catch (e) {
error = e.message || 'Content not found.'
loading = false
}
}
function onKeydown(e) {
if (e.key === 'Enter') submit()
}
</script>
<main class="home">
<div class="hero">
<h1 class="retro-heading">CG.CX</h1>
<p class="tagline">Secure content sharing</p>
</div>
<div class="panel">
<label for="cxid">Content ID</label>
<input id="cxid" type="text" bind:value={cxidInput} placeholder="Enter content ID..." onkeydown={onKeydown} />
{#if needsPassword}
<label for="pw">Password</label>
<input id="pw" type="password" bind:value={passwordInput} placeholder="Enter password..." onkeydown={onKeydown} />
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
<button onclick={submit} disabled={loading}>
{loading ? 'Loading...' : '[ Unlock ]'}
</button>
</div>
<footer>
<p>Developed by <a href="https://t.me/forgecadrape" target="_blank" rel="noopener">@forgecadrape</a></p>
<p>portfolio <a href="https://kittens.rip/" target="_blank" rel="noopener">kittens.rip</a></p>
<p class="footer-small">Created with &lt;3 in Europe<br/>@2026 <a href="https://cg.cx/">cg.cx</a></p>
</footer>
</main>
<style>
.home {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 32px;
}
.hero {
text-align: center;
}
.tagline {
font-size: 1.2rem;
color: var(--retro-green-light);
margin-top: 8px;
letter-spacing: 2px;
}
.panel {
width: min(400px, 100%);
background: var(--retro-panel);
border: 3px solid var(--retro-border);
padding: 24px;
box-shadow: 6px 6px 0px var(--retro-shadow);
display: flex;
flex-direction: column;
gap: 12px;
}
.panel label {
font-family: 'Press Start 2P', cursive;
font-size: 0.55rem;
color: var(--retro-green);
text-transform: uppercase;
}
.error {
color: var(--retro-danger);
font-size: 1rem;
}
footer {
text-align: center;
font-size: 0.95rem;
color: #555;
}
footer p {
margin: 4px 0;
}
.footer-small {
font-size: 0.85rem;
margin-top: 12px;
color: #777;
}
</style>

View File

@@ -0,0 +1,183 @@
<script>
import { fetchMetadata, verifyPassword, fileUrl } from '../lib/api.js'
import ImageViewer from '../components/ImageViewer.svelte'
import VideoPlayer from '../components/VideoPlayer.svelte'
import AudioPlayer from '../components/AudioPlayer.svelte'
import MarkdownRenderer from '../components/MarkdownRenderer.svelte'
import TextViewer from '../components/TextViewer.svelte'
import DocumentCard from '../components/DocumentCard.svelte'
import ExecutableWarning from '../components/ExecutableWarning.svelte'
import MixedGallery from '../components/MixedGallery.svelte'
let { cxid, sc } = $props()
let phase = $state('loading_meta') // loading_meta | password_required | loading_content | rendering | error
let metadata = $state(null)
let password = $state('')
let error = $state('')
$effect(() => {
password = sc || ''
})
$effect(() => {
load()
})
async function load() {
phase = 'loading_meta'
error = ''
try {
const meta = await fetchMetadata(cxid)
metadata = meta
if (meta.has_password && !password) {
phase = 'password_required'
return
}
if (meta.has_password) {
const ok = await verifyPassword(cxid, password)
if (!ok) {
phase = 'password_required'
error = 'Incorrect password.'
return
}
}
phase = 'rendering'
} catch (e) {
phase = 'error'
error = e.message || 'Failed to load content.'
}
}
async function submitPassword() {
error = ''
if (!password) return
const ok = await verifyPassword(cxid, password)
if (ok) {
phase = 'rendering'
} else {
error = 'Incorrect password.'
}
}
function goHome() {
history.pushState({}, '', '/')
window.dispatchEvent(new PopStateEvent('popstate'))
}
function getViewerFor(file) {
const flags = file.render_flags || 0
if (flags & 1) return 'image'
if (flags & 2) return 'video'
if (flags & 4) return 'audio'
if (flags & 8) return 'markdown'
if (flags & 16) return 'text'
if (flags & 64) return 'executable'
if (flags & 128) return 'dangerous'
return 'document'
}
</script>
<main class="view">
{#if phase === 'loading_meta'}
<div class="center">
<p class="retro-heading">Loading...</p>
</div>
{:else if phase === 'password_required'}
<div class="center">
<div class="panel">
<p class="retro-heading">[ Protected ]</p>
<p>This content requires a password.</p>
<input type="password" bind:value={password} placeholder="Password..." onkeydown={(e) => e.key === 'Enter' && submitPassword()} />
{#if error}<p class="error">{error}</p>{/if}
<button onclick={submitPassword}>Unlock</button>
<button class="secondary" onclick={goHome}>Back</button>
</div>
</div>
{:else if phase === 'error'}
<div class="center">
<p class="error">{error}</p>
<button onclick={goHome}>Home</button>
</div>
{:else if phase === 'rendering'}
<div class="content-header">
<button class="small" onclick={goHome}>&lt;- Home</button>
<span class="meta">{metadata.files.length} file{metadata.files.length !== 1 ? 's' : ''}</span>
{#if metadata.max_views}
<span class="meta">Views: {metadata.current_views}/{metadata.max_views}</span>
{/if}
</div>
{#if metadata.files.length === 1}
{@const file = metadata.files[0]}
{@const viewer = getViewerFor(file)}
{#if viewer === 'image'}
<ImageViewer src={fileUrl(cxid, file.idx, false, password)} name={file.name} />
{:else if viewer === 'video'}
<VideoPlayer src={fileUrl(cxid, file.idx, false, password)} mime={file.mime} />
{:else if viewer === 'audio'}
<AudioPlayer src={fileUrl(cxid, file.idx, false, password)} mime={file.mime} />
{:else if viewer === 'markdown'}
<MarkdownRenderer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'text'}
<TextViewer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'executable' || viewer === 'dangerous'}
<ExecutableWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{:else}
<DocumentCard {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{/if}
{:else}
<MixedGallery files={metadata.files} {cxid} {password} />
{/if}
{/if}
</main>
<style>
.view {
min-height: 100vh;
padding: 16px;
}
.center {
min-height: 60vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.panel {
width: min(400px, 100%);
background: var(--retro-panel);
border: 3px solid var(--retro-border);
padding: 24px;
box-shadow: 6px 6px 0px var(--retro-shadow);
display: flex;
flex-direction: column;
gap: 12px;
}
.error { color: var(--retro-danger); }
.content-header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid var(--retro-border);
}
.meta {
font-size: 0.9rem;
color: #555;
background: #eee;
padding: 4px 8px;
border: 1px solid var(--retro-border);
}
button.small {
font-size: 0.5rem;
padding: 8px 10px;
}
button.secondary {
border-color: #999;
color: #555;
}
</style>

View File

@@ -0,0 +1,124 @@
@import './retro-theme.css';
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/press-start-2p-latin.woff2') format('woff2');
}
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/vt323-latin.woff2') format('woff2');
}
html {
font-size: 18px;
}
body {
font-family: 'VT323', monospace;
background: var(--retro-bg);
color: var(--retro-fg);
line-height: 1.4;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: var(--retro-green);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button, .btn {
font-family: 'Press Start 2P', cursive;
font-size: 0.65rem;
padding: 12px 16px;
border: 3px solid var(--retro-green);
background: var(--retro-panel);
color: var(--retro-green);
cursor: pointer;
text-transform: uppercase;
box-shadow: 3px 3px 0px rgba(0,0,0,0.15);
}
button:disabled, .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input[type="text"],
input[type="password"] {
font-family: 'VT323', monospace;
font-size: 1.1rem;
padding: 10px 12px;
border: 2px solid var(--retro-border);
background: #fff;
color: var(--retro-fg);
outline: none;
width: 100%;
box-sizing: border-box;
}
input:focus {
border-color: var(--retro-green);
}
.retro-heading {
font-family: 'Press Start 2P', cursive;
font-size: clamp(0.9rem, 3vw, 1.4rem);
line-height: 1.6;
color: var(--retro-green);
background: linear-gradient(135deg, var(--retro-green), var(--retro-green-light), var(--retro-green));
background-size: 200% 200%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@media (prefers-reduced-motion: no-preference) {
button, .btn {
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
button:hover, .btn:hover {
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0px rgba(0,0,0,0.2);
}
button:active, .btn:active {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0px rgba(0,0,0,0.15);
}
.retro-heading {
animation: gradient-shift 3s ease infinite;
}
}
@media (max-width: 768px) {
html { font-size: 17px; }
}
@media (max-width: 480px) {
html { font-size: 16px; }
button, .btn { font-size: 0.55rem; padding: 10px 12px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,12 @@
:root {
--retro-bg: #fafafa;
--retro-fg: #111111;
--retro-green: #1a4a1a;
--retro-green-light: #2e8b2e;
--retro-accent: #0f380f;
--retro-border: #cccccc;
--retro-panel: #ffffff;
--retro-shadow: rgba(0, 0, 0, 0.12);
--retro-danger: #8b1a1a;
--retro-warning: #8b5a1a;
}

10
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
})