Initial commit
This commit is contained in:
29
frontend/src/App.svelte
Normal file
29
frontend/src/App.svelte
Normal 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}
|
||||
31
frontend/src/components/AudioPlayer.svelte
Normal file
31
frontend/src/components/AudioPlayer.svelte
Normal 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>
|
||||
59
frontend/src/components/DocumentCard.svelte
Normal file
59
frontend/src/components/DocumentCard.svelte
Normal 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>
|
||||
56
frontend/src/components/ExecutableWarning.svelte
Normal file
56
frontend/src/components/ExecutableWarning.svelte
Normal 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>
|
||||
22
frontend/src/components/ImageViewer.svelte
Normal file
22
frontend/src/components/ImageViewer.svelte
Normal 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>
|
||||
160
frontend/src/components/LoadingScreen.svelte
Normal file
160
frontend/src/components/LoadingScreen.svelte
Normal 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>
|
||||
79
frontend/src/components/MarkdownRenderer.svelte
Normal file
79
frontend/src/components/MarkdownRenderer.svelte
Normal 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>
|
||||
82
frontend/src/components/MixedGallery.svelte
Normal file
82
frontend/src/components/MixedGallery.svelte
Normal 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>
|
||||
46
frontend/src/components/TextViewer.svelte
Normal file
46
frontend/src/components/TextViewer.svelte
Normal 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>
|
||||
23
frontend/src/components/VideoPlayer.svelte
Normal file
23
frontend/src/components/VideoPlayer.svelte
Normal 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
38
frontend/src/lib/api.js
Normal 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
12
frontend/src/main.js
Normal 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
|
||||
128
frontend/src/routes/Home.svelte
Normal file
128
frontend/src/routes/Home.svelte
Normal 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 <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>
|
||||
183
frontend/src/routes/ViewContent.svelte
Normal file
183
frontend/src/routes/ViewContent.svelte
Normal 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}><- 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>
|
||||
124
frontend/src/styles/global.css
Normal file
124
frontend/src/styles/global.css
Normal 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;
|
||||
}
|
||||
}
|
||||
12
frontend/src/styles/retro-theme.css
Normal file
12
frontend/src/styles/retro-theme.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user