This commit is contained in:
2025-12-27 02:57:25 -06:00
parent 63a6f8f7dc
commit 1d5d6aacb4
68 changed files with 6884 additions and 0 deletions

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

6
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.svelte-kit
build
dist
.DS_Store

8
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

38
frontend/README.md Normal file
View File

@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

59
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,59 @@
import js from '@eslint/js';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import sveltePlugin from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
export default [
js.configs.recommended,
{
files: ['**/*.{js,mjs,cjs,ts,svelte}'],
languageOptions: {
parser: tsParser,
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
},
globals: {
fetch: 'readonly',
console: 'readonly',
window: 'readonly',
document: 'readonly',
localStorage: 'readonly',
$state: 'readonly',
$derived: 'readonly',
$effect: 'readonly',
$props: 'readonly',
$inspect: 'readonly',
$host: 'readonly',
},
},
plugins: {
'@typescript-eslint': tsPlugin,
svelte: sveltePlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
files: ['**/*.svelte'],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: tsParser,
},
},
plugins: {
svelte: sveltePlugin,
},
rules: {
...sveltePlugin.configs.recommended.rules,
},
},
{
ignores: ['node_modules/**', '.svelte-kit/**', 'build/**', 'dist/**'],
},
];

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

39
frontend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "eslint ."
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-plugin-svelte": "^3.13.1",
"lucide-svelte": "^0.562.0",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"svelte-eslint-parser": "^1.4.1",
"svelte-i18n": "^4.0.1",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}
}

3008
frontend/pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

83
frontend/src/app.css Normal file
View File

@@ -0,0 +1,83 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
/* Custom thin scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/20 rounded-full transition-colors;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/40;
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.2) transparent;
}
}

13
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
frontend/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { Search } from 'lucide-svelte';
let { searchQuery = $bindable('') }: { searchQuery?: string } = $props();
</script>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">{$t('common.title')}</h1>
<p class="text-muted-foreground mt-1">{$t('common.subtitle')}</p>
</div>
<div class="relative w-full md:w-72">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
bind:value={searchQuery}
placeholder={$t('common.search')}
class="w-full pl-10 pr-4 py-2 rounded-lg border border-input bg-background focus:ring-2 focus:ring-ring transition-all outline-none"
/>
</div>
</div>

View File

@@ -0,0 +1,369 @@
<script lang="ts">
import { getContext } from 'svelte';
import { t } from 'svelte-i18n';
import BrandIcon from '$lib/components/icons/BrandIcon.svelte';
import {
Download,
Package,
Monitor,
Cpu,
Box,
Terminal,
Smartphone,
Shield,
Zap,
FileCheck,
FileQuestion,
FileText,
ChevronDown,
ChevronUp,
Rss,
} from 'lucide-svelte';
import type { Software } from '$lib/types';
interface Props {
software: Software;
expandedReleases: Record<string, boolean>;
onToggleReleases: (name: string) => void;
}
let { software, expandedReleases, onToggleReleases }: Props = $props();
let showReleaseNotes = $state(false);
let selectedOSs = $state<string[]>([]);
const theme = getContext<{ isDark: boolean }>('theme');
const availableOSs = $derived.by(() => {
const oss = new Set<string>();
software.releases?.forEach((release) => {
release.assets.forEach((asset) => {
if (asset.os) oss.add(asset.os.toLowerCase());
});
});
const supported = ['windows', 'linux', 'freebsd', 'openbsd', 'macos', 'android'];
return Array.from(oss)
.filter((os) => supported.includes(os))
.sort();
});
function toggleOSFilter(os: string) {
if (selectedOSs.includes(os)) {
selectedOSs = selectedOSs.filter((s) => s !== os);
} else {
selectedOSs = [...selectedOSs, os];
}
}
function formatSize(bytes: number) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function getOSIcon(os: string) {
switch (os.toLowerCase()) {
case 'macos':
return 'apple';
case 'windows':
case 'linux':
case 'android':
case 'freebsd':
case 'openbsd':
return os.toLowerCase();
default:
return null;
}
}
function getFallbackIcon(os: string) {
switch (os.toLowerCase()) {
case 'windows':
return Monitor;
case 'macos':
return Cpu;
case 'linux':
return Terminal;
case 'android':
return Smartphone;
case 'freebsd':
case 'openbsd':
return Shield;
case 'arm':
return Zap;
default:
return Box;
}
}
</script>
<div
class="flex flex-col rounded-xl border border-border bg-card hover:shadow-xl hover:border-primary/30 transition-all duration-300 overflow-hidden group"
>
<div class="p-6 flex-1">
<div class="flex items-start justify-between mb-4">
<div class="p-2 rounded-lg bg-primary/10 text-primary">
{#if software.avatar_url}
<img
src={software.avatar_url}
alt={software.name}
class="w-6 h-6 rounded-md object-cover"
/>
{:else}
<Package class="w-6 h-6" />
{/if}
</div>
<div class="flex items-center gap-2">
<a
href="/api/rss?software={software.name}"
class="p-2 rounded-lg hover:bg-accent transition-colors text-muted-foreground hover:text-orange-500"
title="RSS Feed"
target="_blank"
>
<Rss class="w-5 h-5" />
</a>
{#if !software.is_private && software.gitea_url}
<a
href="{software.gitea_url}/{software.owner}/{software.name}"
target="_blank"
rel="noopener noreferrer"
class="p-2 rounded-lg hover:bg-accent transition-colors group/gitea"
title={$t('common.viewOnGitea')}
>
<img
src={theme.isDark ? '/icons/gitea.webp' : '/icons/gitea-dark.webp'}
alt="Gitea"
class="w-5 h-5 opacity-50 group-hover/gitea:opacity-100 transition-opacity"
/>
</a>
{/if}
</div>
</div>
<div class="flex items-center gap-2 mb-2">
<h2 class="text-xl font-semibold capitalize">
{software.name.replace(/-/g, ' ')}
</h2>
{#if software.license}
<span
class="px-1.5 py-0.5 rounded border border-primary/20 bg-primary/5 text-primary/80 text-[10px] font-bold"
title={$t('common.license')}
>
{software.license}
</span>
{/if}
</div>
<p class="text-muted-foreground text-sm line-clamp-3 mb-4">
{software.description || $t('common.repoFor', { values: { name: software.name } })}
</p>
{#if software.topics && software.topics.length > 0}
<div class="flex flex-wrap gap-2 mb-4">
{#each software.topics as topic}
<span
class="px-2.5 py-1 rounded-md bg-primary/10 text-primary/80 text-xs font-semibold border border-primary/20"
>
{topic}
</span>
{/each}
</div>
{/if}
</div>
<div class="mt-auto border-t border-border bg-muted/30">
{#if software.releases && software.releases.length > 0}
{@const latest = software.releases[0]}
<div class="p-4 space-y-3">
<div
class="flex items-center justify-between text-xs font-medium uppercase tracking-wider text-muted-foreground"
>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
{$t('common.latest')}: {latest.tag_name}
</div>
{#if latest.body}
<button
onclick={() => (showReleaseNotes = !showReleaseNotes)}
class="flex items-center gap-1 px-1.5 py-0.5 rounded bg-primary/5 hover:bg-primary/10 text-[10px] transition-colors border border-primary/10"
title={$t('common.releaseNotes')}
>
<FileText class="w-3 h-3" />
<span>{$t('common.notes')}</span>
{#if showReleaseNotes}
<ChevronUp class="w-2.5 h-2.5" />
{:else}
<ChevronDown class="w-2.5 h-2.5" />
{/if}
</button>
{/if}
{#if availableOSs.length > 0}
<div class="flex items-center gap-1 ml-1 pl-2 border-l border-border/50">
{#each availableOSs as os}
{@const brand = getOSIcon(os)}
<button
onclick={() => toggleOSFilter(os)}
class="p-1 rounded-md transition-all duration-200 {selectedOSs.includes(os)
? 'bg-primary text-primary-foreground shadow-sm scale-110'
: 'hover:bg-primary/10 text-muted-foreground/60 hover:text-primary'}"
title={$t(`os.${os}`, { default: os })}
>
{#if brand}
<BrandIcon name={brand} class="w-3.5 h-3.5" />
{:else}
{@const Fallback = getFallbackIcon(os)}
<Fallback class="w-3.5 h-3.5" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
{#if software.releases.length > 1}
<button
onclick={() => onToggleReleases(software.name)}
class="text-[10px] hover:text-primary transition-colors underline decoration-dotted"
>
{expandedReleases[software.name] ? $t('common.hide') : $t('common.previousReleases')}
</button>
{/if}
</div>
{#if showReleaseNotes && latest.body}
<div
class="text-xs bg-muted/50 rounded-lg p-3 text-muted-foreground whitespace-pre-wrap border border-border max-h-[150px] overflow-y-auto scrollbar-thin"
>
{latest.body}
</div>
{/if}
{#if expandedReleases[software.name]}
<div class="space-y-4 mt-2 border-l-2 border-primary/10 pl-3">
{#each software.releases as release, i}
{@const filteredAssets = release.assets.filter(
(a) => selectedOSs.length === 0 || selectedOSs.includes(a.os.toLowerCase())
)}
{#if filteredAssets.length > 0}
<div class="space-y-2">
<div class="flex items-center justify-between text-[10px] font-bold opacity-60">
<span>{release.tag_name} {i === 0 ? `(${$t('common.latest')})` : ''}</span>
</div>
<div class="space-y-1">
{#each filteredAssets as asset}
{@const brand = getOSIcon(asset.os)}
{@const FallbackIcon = getFallbackIcon(asset.os)}
<div class="group/item flex items-center gap-2">
<a
href={asset.url}
class="flex-1 flex items-center justify-between p-1.5 rounded-md hover:bg-accent transition-colors text-[11px] border border-transparent hover:border-border min-w-0"
>
<div class="flex items-center gap-2 min-w-0">
{#if brand}
<BrandIcon
name={brand}
class="w-3.5 h-3.5 text-muted-foreground group-hover/item:text-primary shrink-0"
/>
{:else}
<FallbackIcon
class="w-3.5 h-3.5 text-muted-foreground group-hover/item:text-primary shrink-0"
/>
{/if}
<span class="truncate" title={asset.name}
>{$t(`os.${asset.os}`, { default: asset.os })}: {asset.name}</span
>
{#if asset.is_sbom}
<span
class="px-1 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 text-[8px] font-bold uppercase tracking-tight border border-blue-500/20"
>
{$t('common.sbom')}
</span>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[9px] opacity-60">{formatSize(asset.size)}</span>
<Download class="w-3 h-3 text-primary" />
</div>
</a>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{:else}
{@const filteredLatestAssets = latest.assets.filter(
(a) => selectedOSs.length === 0 || selectedOSs.includes(a.os.toLowerCase())
)}
<div
class="space-y-1 max-h-[240px] overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-primary/20 hover:scrollbar-thumb-primary/40"
>
{#if filteredLatestAssets.length > 0}
{#each filteredLatestAssets as asset}
{@const brand = getOSIcon(asset.os)}
{@const FallbackIcon = getFallbackIcon(asset.os)}
<div class="group/item flex items-center gap-2">
<a
href={asset.url}
class="flex-1 flex items-center justify-between p-2 rounded-md hover:bg-accent transition-colors text-sm border border-transparent hover:border-border min-w-0"
>
<div class="flex items-center gap-3 min-w-0">
{#if brand}
<BrandIcon
name={brand}
class="w-4 h-4 text-muted-foreground group-hover/item:text-primary shrink-0"
/>
{:else}
<FallbackIcon
class="w-4 h-4 text-muted-foreground group-hover/item:text-primary shrink-0"
/>
{/if}
<span class="font-medium truncate" title={asset.name}>
{$t(`os.${asset.os}`, { default: asset.os })}: {asset.name}
</span>
{#if asset.is_sbom}
<span
class="px-1 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 text-[9px] font-bold uppercase tracking-tight border border-blue-500/20"
>
{$t('common.sbom')}
</span>
{/if}
</div>
<div class="flex items-center gap-3 shrink-0 ml-2">
<span class="text-[10px] text-muted-foreground">{formatSize(asset.size)}</span
>
<Download
class="w-4 h-4 opacity-0 group-hover/item:opacity-100 transition-opacity text-primary"
/>
</div>
</a>
{#if asset.sha256}
<div
class="p-1.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400 shrink-0"
title="{$t('common.checksumVerified')}: {asset.sha256}"
>
<FileCheck class="w-3.5 h-3.5" />
</div>
{:else}
<div
class="p-1.5 rounded-full bg-orange-500/10 text-orange-600 dark:text-orange-400 shrink-0"
title={$t('common.checksumMissing')}
>
<FileQuestion class="w-3.5 h-3.5" />
</div>
{/if}
</div>
{/each}
{:else}
<div class="text-center py-4 text-xs text-muted-foreground opacity-60">
{$t('common.noMatchingAssets')}
</div>
{/if}
</div>
{/if}
</div>
{:else}
<div class="text-sm text-center text-muted-foreground py-6">
{$t('common.noReleases')}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
interface Props {
name: string;
class?: string;
}
let { name, class: className = 'w-5 h-5' }: Props = $props();
const paths: Record<string, string> = {
apple:
'M12.15,6.83C11.2,6.83 9.74,5.75 8.19,5.79C6.15,5.82 4.28,6.97 3.23,8.81C1.11,12.48 2.69,17.91 4.75,20.9C5.76,22.35 6.96,23.99 8.54,23.94C10.06,23.87 10.63,22.95 12.48,22.95C14.31,22.95 14.83,23.94 16.44,23.9C18.08,23.87 19.12,22.41 20.12,20.94C21.28,19.25 21.76,17.62 21.78,17.53C21.74,17.51 18.6,16.3 18.56,12.67C18.54,9.63 21.04,8.18 21.16,8.11C19.73,6.02 17.54,5.79 16.77,5.73C14.77,5.57 13.09,6.83 12.15,6.83M15.53,3.83C16.37,2.82 16.93,1.4 16.77,0C15.57,0.05 14.11,0.81 13.24,1.82C12.46,2.72 11.79,4.16 11.97,5.54C13.31,5.64 14.69,4.85 15.53,3.83Z',
windows:
'M0,3.44L9.75,2.1V11.75H0V3.44M0,12.35H9.75V22L0,20.6V12.35M10.55,2L24,0V11.75H10.55V2M10.55,12.35H24V24L10.55,22.1V12.35Z',
linux:
'M14.62,8.35C14.2,8.63 12.87,9.39 12.67,9.54C12.28,9.85 11.92,9.83 11.53,9.53C11.33,9.37 10,8.61 9.58,8.34C9.1,8.03 9.13,7.64 9.66,7.42C11.3,6.73 12.94,6.78 14.57,7.45C15.06,7.66 15.08,8.05 14.62,8.35M21.84,15.63C20.91,13.54 19.64,11.64 18,9.97C17.47,9.42 17.14,8.8 16.94,8.09C16.84,7.76 16.77,7.42 16.7,7.08C16.5,6.2 16.41,5.3 16,4.47C15.27,2.89 14,2.07 12.16,2C10.35,2.05 9,2.81 8.21,4.4C8,4.83 7.85,5.28 7.75,5.74C7.58,6.5 7.43,7.29 7.25,8.06C7.1,8.71 6.8,9.27 6.29,9.77C4.68,11.34 3.39,13.14 2.41,15.12C2.27,15.41 2.13,15.7 2.04,16C1.85,16.66 2.33,17.12 3.03,16.96C3.47,16.87 3.91,16.78 4.33,16.65C4.74,16.5 4.9,16.6 5,17C5.65,19.15 7.07,20.66 9.24,21.5C13.36,23.06 18.17,20.84 19.21,16.92C19.28,16.65 19.38,16.55 19.68,16.65C20.14,16.79 20.61,16.89 21.08,17C21.57,17.09 21.93,16.84 22,16.36C22.03,16.1 21.94,15.87 21.84,15.63',
android:
'M16.61 15.15C16.15 15.15 15.77 14.78 15.77 14.32S16.15 13.5 16.61 13.5H16.61C17.07 13.5 17.45 13.86 17.45 14.32C17.45 14.78 17.07 15.15 16.61 15.15M7.41 15.15C6.95 15.15 6.57 14.78 6.57 14.32C6.57 13.86 6.95 13.5 7.41 13.5H7.41C7.87 13.5 8.24 13.86 8.24 14.32C8.24 14.78 7.87 15.15 7.41 15.15M16.91 10.14L18.58 7.26C18.67 7.09 18.61 6.88 18.45 6.79C18.28 6.69 18.07 6.75 18 6.92L16.29 9.83C14.95 9.22 13.5 8.9 12 8.91C10.47 8.91 9 9.24 7.73 9.82L6.04 6.91C5.95 6.74 5.74 6.68 5.57 6.78C5.4 6.87 5.35 7.08 5.44 7.25L7.1 10.13C4.25 11.69 2.29 14.58 2 18H22C21.72 14.59 19.77 11.7 16.91 10.14H16.91Z',
freebsd:
'M2.69,2C3.54,1.95 6.08,3.16 6.13,3.19C4.84,4 3.74,5.09 2.91,6.38C2.09,4.81 1.34,2.91 2,2.25C2.17,2.08 2.4,2 2.69,2M20.84,2.13C21.25,2.08 21.58,2.14 21.78,2.34C22.85,3.42 19.88,8.15 19.38,8.66C18.87,9.16 17.57,8.7 16.5,7.63C15.43,6.55 14.97,5.26 15.47,4.75C15.88,4.34 19.09,2.3 20.84,2.13M12,2.56C13.29,2.56 14.53,2.82 15.66,3.28C15.17,3.6 14.81,3.85 14.69,3.97C13.7,4.96 14.14,6.83 15.72,8.41C16.7,9.38 17.84,9.97 18.78,9.97C19.46,9.97 19.92,9.68 20.16,9.44C20.33,9.27 20.6,8.88 20.91,8.41C21.42,9.59 21.69,10.88 21.69,12.25C21.69,17.61 17.36,21.97 12,21.97C6.64,21.97 2.31,17.61 2.31,12.25C2.31,6.89 6.64,2.56 12,2.56Z',
openbsd:
'M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4M12,6A6,6 0 0,0 6,12A6,6 0 0,0 12,18A6,6 0 0,0 18,12A6,6 0 0,0 12,6M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8Z',
};
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class={className}
aria-hidden="true"
>
{#if paths[name.toLowerCase()]}
<path d={paths[name.toLowerCase()]} />
{/if}
</svg>

View File

@@ -0,0 +1,13 @@
import { init, register, getLocaleFromNavigator } from 'svelte-i18n';
const defaultLocale = 'en';
register('en', () => import('./locales/en.json'));
register('de', () => import('./locales/de.json'));
register('ru', () => import('./locales/ru.json'));
register('it', () => import('./locales/it.json'));
init({
fallbackLocale: defaultLocale,
initialLocale: getLocaleFromNavigator() || defaultLocale,
});

View File

@@ -0,0 +1,45 @@
{
"common": {
"title": "Softwareverteilung",
"subtitle": "Sicherer Download von Build-Binärdateien und Containern.",
"search": "Software suchen...",
"noSoftware": "Keine Software gefunden",
"tryAdjusting": "Versuchen Sie, Ihre Suchanfrage anzupassen.",
"latest": "Aktuellste",
"releaseNotes": "Versionshinweise",
"notes": "Notizen",
"openSource": "Open-Source",
"mit": "MIT",
"previousReleases": "Vorherige Versionen",
"hide": "Ausblenden",
"noReleases": "Keine Versionen verfügbar",
"viewOnGitea": "Auf Gitea ansehen",
"checksumVerified": "SHA256 Verifiziert",
"checksumMissing": "Prüfsumme nicht verfügbar",
"rightsReserved": "Alle Rechte vorbehalten.",
"repoFor": "Software-Repository für {name}.",
"privacy": "Datenschutzerklärung",
"terms": "Nutzungsbedingungen",
"disclaimer": "Haftungsausschluss",
"downloadDoc": "Herunterladen (.txt)",
"license": "Lizenz",
"sbom": "SBOM",
"backToSoftware": "Zurück zur Software",
"docLoadError": "Dokument konnte nicht geladen werden",
"docNotFound": "Das angeforderte rechtliche Dokument konnte nicht gefunden werden.",
"errorTitle": "Software konnte nicht geladen werden",
"errorMessage": "Der Server ist möglicherweise nicht erreichbar. Bitte versuchen Sie es später erneut.",
"retry": "Wiederholen",
"noMatchingAssets": "Keine Dateien entsprechen den Filtern"
},
"os": {
"windows": "Windows",
"linux": "Linux",
"macos": "macOS",
"android": "Android",
"freebsd": "FreeBSD",
"openbsd": "OpenBSD",
"arm": "ARM/AArch64",
"unknown": "Andere"
}
}

View File

@@ -0,0 +1,45 @@
{
"common": {
"title": "Software Distribution",
"subtitle": "Securely download built binaries and containers.",
"search": "Search software...",
"noSoftware": "No software found",
"tryAdjusting": "Try adjusting your search query.",
"latest": "Latest",
"releaseNotes": "Release Notes",
"notes": "Notes",
"openSource": "Open-Source",
"mit": "MIT",
"previousReleases": "Previous Releases",
"hide": "Hide",
"noReleases": "No releases available",
"viewOnGitea": "View on Gitea",
"checksumVerified": "SHA256 Verified",
"checksumMissing": "Checksum not provided",
"rightsReserved": "All rights reserved.",
"repoFor": "Software repository for {name}.",
"privacy": "Privacy Policy",
"terms": "Terms of Service",
"disclaimer": "Legal Disclaimer",
"downloadDoc": "Download (.txt)",
"license": "License",
"sbom": "SBOM",
"backToSoftware": "Back to Software",
"docLoadError": "Failed to load document",
"docNotFound": "The requested legal document could not be found.",
"errorTitle": "Failed to load software",
"errorMessage": "The server might be down or unreachable. Please try again later.",
"retry": "Retry",
"noMatchingAssets": "No assets match selected filters"
},
"os": {
"windows": "Windows",
"linux": "Linux",
"macos": "macOS",
"android": "Android",
"freebsd": "FreeBSD",
"openbsd": "OpenBSD",
"arm": "ARM/AArch64",
"unknown": "Other"
}
}

View File

@@ -0,0 +1,45 @@
{
"common": {
"title": "Distribuzione Software",
"subtitle": "Scarica in sicurezza binari e container compilati.",
"search": "Cerca software...",
"noSoftware": "Nessun software trovato",
"tryAdjusting": "Prova a modificare la query di ricerca.",
"latest": "Ultima",
"releaseNotes": "Note di rilascio",
"notes": "Note",
"openSource": "Open-Source",
"mit": "MIT",
"previousReleases": "Versioni precedenti",
"hide": "Nascondi",
"noReleases": "Nessun rilascio disponibile",
"viewOnGitea": "Visualizza su Gitea",
"checksumVerified": "SHA256 verificato",
"checksumMissing": "Checksum non fornito",
"rightsReserved": "Tutti i diritti riservati.",
"repoFor": "Repository software per {name}.",
"privacy": "Informativa sulla Privacy",
"terms": "Termini di Servizio",
"disclaimer": "Disclaimer Legale",
"downloadDoc": "Scarica (.txt)",
"license": "Licenza",
"sbom": "SBOM",
"backToSoftware": "Torna al Software",
"docLoadError": "Caricamento documento fallito",
"docNotFound": "Il documento legale richiesto non è stato trovato.",
"errorTitle": "Caricamento software fallito",
"errorMessage": "Il server potrebbe essere inattivo o irraggiungibile. Riprova più tardi.",
"retry": "Riprova",
"noMatchingAssets": "Nessun file corrisponde ai filtri selezionati"
},
"os": {
"windows": "Windows",
"linux": "Linux",
"macos": "macOS",
"android": "Android",
"freebsd": "FreeBSD",
"openbsd": "OpenBSD",
"arm": "ARM/AArch64",
"unknown": "Altro"
}
}

View File

@@ -0,0 +1,45 @@
{
"common": {
"title": "Распространение ПО",
"subtitle": "Безопасная загрузка готовых бинарных файлов и контейнеров.",
"search": "Поиск ПО...",
"noSoftware": "Программное обеспечение не найдено",
"tryAdjusting": "Попробуйте изменить поисковый запрос.",
"latest": "Последняя",
"releaseNotes": "Примечания к выпуску",
"notes": "Заметки",
"openSource": "Open-Source",
"mit": "MIT",
"previousReleases": "Предыдущие версии",
"hide": "Скрыть",
"noReleases": "Версии недоступны",
"viewOnGitea": "Посмотреть на Gitea",
"checksumVerified": "SHA256 подтвержден",
"checksumMissing": "Контрольная сумма не предоставлена",
"rightsReserved": "Все права защищены.",
"repoFor": "Репозиторий ПО для {name}.",
"privacy": "Политика конфиденциальности",
"terms": "Условия использования",
"disclaimer": "Отказ от ответственности",
"downloadDoc": "Скачать (.txt)",
"license": "Лицензия",
"sbom": "SBOM",
"backToSoftware": "Назад к ПО",
"docLoadError": "Не удалось загрузить документ",
"docNotFound": "Запрошенный юридический документ не найден.",
"errorTitle": "Не удалось загрузить ПО",
"errorMessage": "Сервер может быть недоступен. Пожалуйста, попробуйте позже.",
"retry": "Повторить",
"noMatchingAssets": "Нет файлов, соответствующих фильтрам"
},
"os": {
"windows": "Windows",
"linux": "Linux",
"macos": "macOS",
"android": "Android",
"freebsd": "FreeBSD",
"openbsd": "OpenBSD",
"arm": "ARM/AArch64",
"unknown": "Другое"
}
}

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

26
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,26 @@
export interface Asset {
name: string;
size: number;
url: string;
os: string;
sha256?: string;
is_sbom: boolean;
}
export interface Release {
tag_name: string;
body?: string;
assets: Asset[];
}
export interface Software {
name: string;
owner: string;
description: string;
releases: Release[];
gitea_url: string;
topics: string[];
license?: string;
is_private: boolean;
avatar_url?: string;
}

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import '../app.css';
import '../lib/i18n';
import { onMount, setContext } from 'svelte';
import { Moon, Sun, Languages, Rss } from 'lucide-svelte';
import { locale, waitLocale, locales, t } from 'svelte-i18n';
import { version } from '../../package.json';
let { children } = $props();
let isDark = $state(false);
let i18nReady = $state(false);
setContext('theme', {
get isDark() {
return isDark;
},
});
onMount(async () => {
await waitLocale();
i18nReady = true;
const theme = localStorage.getItem('theme');
isDark =
theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
});
function toggleTheme() {
isDark = !isDark;
if (isDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
</script>
{#if i18nReady}
<div
class="min-h-screen flex flex-col bg-background text-foreground transition-colors duration-300"
>
<nav class="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-50">
<div
class="max-w-[1600px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"
>
<div class="flex items-center gap-3">
<img src="/logo.png" alt="Quad4 Logo" class="w-8 h-8 rounded-md" />
<a href="/" class="text-xl font-bold tracking-tight">Software Station</a>
<span class="text-muted-foreground mx-1">|</span>
<a
href="https://quad4.io"
target="_blank"
class="text-sm font-medium hover:text-primary transition-colors">Quad4</a
>
</div>
<div class="flex items-center gap-4">
<a
href="/api/rss"
target="_blank"
class="p-2 rounded-lg hover:bg-accent transition-colors text-muted-foreground hover:text-orange-500"
title="Global RSS Feed"
>
<Rss class="w-5 h-5" />
</a>
<div class="relative group flex items-center">
<Languages
class="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors cursor-pointer pointer-events-none"
/>
<select
bind:value={$locale}
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer appearance-none dark:bg-slate-900"
style="color-scheme: {isDark ? 'dark' : 'light'};"
aria-label="Select Language"
>
{#each $locales as l}
<option value={l} class="bg-card text-foreground">
{l.toUpperCase()}
</option>
{/each}
</select>
</div>
<button
onclick={toggleTheme}
class="p-2 rounded-lg hover:bg-accent transition-colors"
aria-label="Toggle theme"
>
{#if isDark}
<Sun class="w-5 h-5" />
{:else}
<Moon class="w-5 h-5" />
{/if}
</button>
</div>
</div>
</nav>
<main class="max-w-[1600px] mx-auto px-4 sm:px-6 lg:px-8 py-6">
{@render children()}
</main>
<footer class="border-t border-border mt-auto pt-6 pb-4">
<div class="max-w-[1600px] mx-auto px-4 text-center space-y-3">
<div class="flex flex-wrap justify-center gap-x-6 gap-y-2 text-sm font-medium">
<a
href="/legal/privacy"
class="text-muted-foreground hover:text-primary transition-colors"
>
{$t('common.privacy')}
</a>
<a href="/legal/terms" class="text-muted-foreground hover:text-primary transition-colors">
{$t('common.terms')}
</a>
<a
href="/legal/disclaimer"
class="text-muted-foreground hover:text-primary transition-colors"
>
{$t('common.disclaimer')}
</a>
</div>
<div class="text-sm text-muted-foreground">
&copy; {new Date().getFullYear()}
<a href="https://quad4.io" class="hover:text-primary underline decoration-dotted">Quad4</a
>.
{$t('common.rightsReserved')}
</div>
<div class="text-[10px] text-muted-foreground/60">
<a
href="https://git.quad4.io/Quad4-Software/software-station"
target="_blank"
class="hover:text-primary transition-colors"
>
{$t('common.openSource')}
</a>
<span class="mx-1">-</span>
<a
href="https://git.quad4.io/Quad4-Software/software-station"
target="_blank"
class="hover:text-primary transition-colors"
>
{$t('common.mit')}
</a>
<span class="mx-1">-</span>
v{version}
</div>
</div>
</footer>
</div>
{:else}
<div class="min-h-screen bg-background flex items-center justify-center">
<div
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{/if}

View File

@@ -0,0 +1,3 @@
export const ssr = false;
export const prerender = false;
export const trailingSlash = 'always';

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { Package, AlertCircle, RefreshCcw } from 'lucide-svelte';
import type { Software } from '$lib/types';
import SearchBar from '$lib/components/SearchBar.svelte';
import SoftwareCard from '$lib/components/SoftwareCard.svelte';
let softwareList = $state<Software[]>([]);
let searchQuery = $state('');
let loading = $state(true);
let error = $state(false);
let expandedReleases = $state<Record<string, boolean>>({});
async function fetchSoftware() {
loading = true;
error = false;
try {
const res = await fetch('/api/software');
if (!res.ok) throw new Error('Failed to fetch');
softwareList = await res.json();
} catch (e) {
console.error('Failed to fetch software list', e);
error = true;
} finally {
loading = false;
}
}
onMount(fetchSoftware);
function toggleReleases(name: string) {
expandedReleases[name] = !expandedReleases[name];
}
let filteredSoftware = $derived(
softwareList.filter(
(s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(s.description && s.description.toLowerCase().includes(searchQuery.toLowerCase()))
)
);
</script>
<div class="space-y-8">
<SearchBar bind:searchQuery />
{#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-6">
{#each Array(6) as _}
<div class="flex flex-col rounded-xl border border-border bg-card overflow-hidden">
<div class="p-6 flex-1 space-y-4">
<div class="flex items-start justify-between">
<div class="w-10 h-10 rounded-lg bg-muted animate-pulse"></div>
<div class="w-8 h-8 rounded-lg bg-muted animate-pulse"></div>
</div>
<div class="space-y-2">
<div class="h-6 w-1/2 bg-muted rounded animate-pulse"></div>
<div class="h-4 w-full bg-muted rounded animate-pulse"></div>
<div class="h-4 w-2/3 bg-muted rounded animate-pulse"></div>
</div>
<div class="flex gap-2">
<div class="h-6 w-16 bg-muted rounded-md animate-pulse"></div>
<div class="h-6 w-20 bg-muted rounded-md animate-pulse"></div>
</div>
</div>
<div class="p-4 border-t border-border bg-muted/30 space-y-3">
<div class="flex justify-between">
<div class="h-4 w-24 bg-muted rounded animate-pulse"></div>
<div class="h-4 w-16 bg-muted rounded animate-pulse"></div>
</div>
<div class="space-y-2">
<div class="h-10 w-full bg-muted rounded-md animate-pulse"></div>
<div class="h-10 w-full bg-muted rounded-md animate-pulse"></div>
</div>
</div>
</div>
{/each}
</div>
{:else if error}
<div class="text-center py-20 bg-destructive/5 rounded-2xl border border-destructive/10">
<AlertCircle class="w-16 h-16 mx-auto text-destructive opacity-50 mb-4" />
<h3 class="text-xl font-bold text-destructive">{$t('common.errorTitle')}</h3>
<p class="text-muted-foreground mt-2 max-w-md mx-auto">{$t('common.errorMessage')}</p>
<button
onclick={fetchSoftware}
class="mt-8 flex items-center gap-2 mx-auto px-6 py-2.5 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-all font-semibold shadow-lg shadow-destructive/20"
>
<RefreshCcw class="w-4 h-4" />
{$t('common.retry')}
</button>
</div>
{:else if filteredSoftware.length === 0}
<div class="text-center py-20">
<Package class="w-12 h-12 mx-auto text-muted-foreground opacity-50 mb-4" />
<h3 class="text-lg font-medium">{$t('common.noSoftware')}</h3>
<p class="text-muted-foreground">{$t('common.tryAdjusting')}</p>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-6">
{#each filteredSoftware as software}
<SoftwareCard {software} {expandedReleases} onToggleReleases={toggleReleases} />
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { FileText, Download, ArrowLeft } from 'lucide-svelte';
let docContent = $state('');
let loading = $state(true);
let error = $state(false);
const docType = $derived(page.params.doc);
onMount(async () => {
try {
const res = await fetch(`/api/legal?doc=${docType}`);
if (!res.ok) throw new Error('Failed to fetch');
docContent = await res.text();
} catch {
error = true;
} finally {
loading = false;
}
});
</script>
<div class="max-w-4xl mx-auto space-y-8 animate-in fade-in duration-500">
<div class="flex items-center justify-between">
<a
href="/"
class="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft class="w-4 h-4" />
{$t('common.backToSoftware')}
</a>
{#if !loading && !error}
<a
href="/api/legal?doc={docType}&download=true"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium"
>
<Download class="w-4 h-4" />
{$t('common.downloadDoc')}
</a>
{/if}
</div>
<div class="rounded-xl border border-border bg-card overflow-hidden">
<div class="p-6 border-b border-border bg-muted/30">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-primary/10 text-primary">
<FileText class="w-6 h-6" />
</div>
<h1 class="text-2xl font-bold tracking-tight">
{#if docType === 'privacy'}
{$t('common.privacy')}
{:else}
{$t(`common.${docType}`)}
{/if}
</h1>
</div>
</div>
<div class="p-8 prose dark:prose-invert max-w-none">
{#if loading}
<div class="space-y-4 animate-pulse">
<div class="h-4 bg-muted rounded w-3/4"></div>
<div class="h-4 bg-muted rounded w-1/2"></div>
<div class="h-4 bg-muted rounded w-5/6"></div>
<div class="h-4 bg-muted rounded w-2/3"></div>
</div>
{:else if error}
<div class="text-center py-12 space-y-4">
<div class="text-destructive text-lg font-bold">{$t('common.docLoadError')}</div>
<p class="text-muted-foreground">{$t('common.docNotFound')}</p>
</div>
{:else}
<pre
class="whitespace-pre-wrap font-sans text-foreground leading-relaxed bg-transparent border-none p-0">{docContent}</pre>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,6 @@
Contact: https://quad4.io
Expires: 2026-12-31T23:59:59.000Z
Preferred-Languages: en
Canonical: https://git.quad4.io/Quad4-Software/software-station/.well-known/security.txt
Policy: https://quad4.io/security

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
frontend/static/logo.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

24
frontend/svelte.config.js Normal file
View File

@@ -0,0 +1,24 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter({
fallback: 'index.html', // for SPA
pages: 'build',
assets: 'build',
precompress: false,
strict: true,
}),
},
};
export default config;

View File

@@ -0,0 +1,50 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
};

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

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

@@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
});