0.1.0
This commit is contained in:
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal 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
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
6
frontend/.prettierignore
Normal file
6
frontend/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
dist
|
||||
.DS_Store
|
||||
|
||||
8
frontend/.prettierrc
Normal file
8
frontend/.prettierrc
Normal 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
38
frontend/README.md
Normal 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
59
frontend/eslint.config.js
Normal 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/**'],
|
||||
},
|
||||
];
|
||||
BIN
frontend/frontend/static/icons/gitea-dark.webp
Normal file
BIN
frontend/frontend/static/icons/gitea-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
39
frontend/package.json
Normal file
39
frontend/package.json
Normal 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
3008
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
83
frontend/src/app.css
Normal file
83
frontend/src/app.css
Normal 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
13
frontend/src/app.d.ts
vendored
Normal 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
11
frontend/src/app.html
Normal 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>
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal 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 |
22
frontend/src/lib/components/SearchBar.svelte
Normal file
22
frontend/src/lib/components/SearchBar.svelte
Normal 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>
|
||||
369
frontend/src/lib/components/SoftwareCard.svelte
Normal file
369
frontend/src/lib/components/SoftwareCard.svelte
Normal 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>
|
||||
35
frontend/src/lib/components/icons/BrandIcon.svelte
Normal file
35
frontend/src/lib/components/icons/BrandIcon.svelte
Normal 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>
|
||||
13
frontend/src/lib/i18n/index.ts
Normal file
13
frontend/src/lib/i18n/index.ts
Normal 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,
|
||||
});
|
||||
45
frontend/src/lib/i18n/locales/de.json
Normal file
45
frontend/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
45
frontend/src/lib/i18n/locales/en.json
Normal file
45
frontend/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
45
frontend/src/lib/i18n/locales/it.json
Normal file
45
frontend/src/lib/i18n/locales/it.json
Normal 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"
|
||||
}
|
||||
}
|
||||
45
frontend/src/lib/i18n/locales/ru.json
Normal file
45
frontend/src/lib/i18n/locales/ru.json
Normal 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": "Другое"
|
||||
}
|
||||
}
|
||||
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal 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
26
frontend/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
162
frontend/src/routes/+layout.svelte
Normal file
162
frontend/src/routes/+layout.svelte
Normal 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">
|
||||
© {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}
|
||||
3
frontend/src/routes/+layout.ts
Normal file
3
frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
export const trailingSlash = 'always';
|
||||
106
frontend/src/routes/+page.svelte
Normal file
106
frontend/src/routes/+page.svelte
Normal 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>
|
||||
81
frontend/src/routes/legal/[doc]/+page.svelte
Normal file
81
frontend/src/routes/legal/[doc]/+page.svelte
Normal 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>
|
||||
6
frontend/static/.well-known/security.txt
Normal file
6
frontend/static/.well-known/security.txt
Normal 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
|
||||
|
||||
BIN
frontend/static/icons/gitea-dark.webp
Normal file
BIN
frontend/static/icons/gitea-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/static/icons/gitea.webp
Normal file
BIN
frontend/static/icons/gitea.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/static/logo.png
Normal file
BIN
frontend/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
3
frontend/static/robots.txt
Normal file
3
frontend/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
24
frontend/svelte.config.js
Normal file
24
frontend/svelte.config.js
Normal 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;
|
||||
50
frontend/tailwind.config.js
Normal file
50
frontend/tailwind.config.js
Normal 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
20
frontend/tsconfig.json
Normal 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
11
frontend/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user