Modernize

This commit is contained in:
Georgi Gardev
2026-07-11 13:23:40 +03:00
parent 97c730520d
commit 6af05728a3
21 changed files with 1821 additions and 1633 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.git
data
.env
*.local
*.log
+25
View File
@@ -0,0 +1,25 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run build
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t vertex:ci .
+2
View File
@@ -26,3 +26,5 @@ dist-ssr
.env
data/
*.tsbuildinfo
+11 -20
View File
@@ -1,25 +1,16 @@
FROM node:20-alpine
ARG VITE_OPEN_WEATHER_KEY
ARG VITE_OPEN_WEATHER_LAT
ARG VITE_OPEN_WEATHER_LON
ARG VITE_UNSPLASH_ACCESS_KEY
ENV VITE_OPEN_WEATHER_KEY=$VITE_OPEN_WEATHER_KEY
ENV VITE_OPEN_WEATHER_LAT=$VITE_OPEN_WEATHER_LAT
ENV VITE_OPEN_WEATHER_LON=$VITE_OPEN_WEATHER_LON
ENV VITE_UNSPLASH_ACCESS_KEY=$VITE_UNSPLASH_ACCESS_KEY
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json .
RUN npm install
COPY package.json package-lock.json ./
RUN npm ci
COPY public ./public
COPY src ./src
COPY index.html .
COPY tsconfig.json .
COPY postcss.config.cjs .
COPY vite.config.ts .
COPY . .
RUN npm run build
FROM nginx:1.29-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --chmod=755 docker/40-vertex-config.sh /docker-entrypoint.d/40-vertex-config.sh
COPY --from=build /app/dist /usr/share/nginx/html
CMD ["npm", "run", "dev"]
EXPOSE 3000
+30
View File
@@ -1,3 +1,33 @@
# vertex
An extensible start page
## Development
```sh
npm install
npm run dev
```
## Deployment
```sh
docker compose up -d --build
```
The image is a static production build served by nginx. Configuration stays
editable at runtime — no rebuild needed:
- **`data/apps.yml`, `data/search-providers.yml`** — mounted via the `./data`
volume and fetched by the browser on load; edit and refresh.
- **`VITE_*` environment variables** (`.env`, see `docker-compose.yml`) — read
at container start by an entrypoint script that generates `/config.js`.
Restart the container to apply changes.
| Variable | Purpose |
| --- | --- |
| `VITE_OPEN_WEATHER_KEY` | OpenWeather API key for the weather widget |
| `VITE_OPEN_WEATHER_LAT` / `VITE_OPEN_WEATHER_LON` | Weather location |
| `VITE_UNSPLASH_ACCESS_KEY` | Unsplash API key for background images |
Note: as with any client-only SPA, these keys are visible to the browser.
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Writes the runtime config consumed by src/lib/runtime-config.ts from the
# container's VITE_* environment variables, so keys can be set or rotated at
# container start without rebuilding the image.
set -eu
CONFIG_FILE=/usr/share/nginx/html/config.js
{
echo 'window.__VERTEX_CONFIG__ = {'
env | grep '^VITE_' | while IFS='=' read -r key value; do
escaped=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
printf ' "%s": "%s",\n' "$key" "$escaped"
done
echo '};'
} > "$CONFIG_FILE"
+28
View File
@@ -0,0 +1,28 @@
server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Runtime-editable: entrypoint-generated env config and the mounted
# data/ volume must never be served from cache.
location = /config.js {
add_header Cache-Control "no-store";
}
location /data/ {
alias /app/data/;
add_header Cache-Control "no-store";
}
# Content-hashed build output is safe to cache forever.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}
+1
View File
@@ -16,6 +16,7 @@
</head>
<body>
<div id="root"></div>
<script src="/config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1614 -1567
View File
File diff suppressed because it is too large Load Diff
+27 -25
View File
@@ -11,6 +11,9 @@
],
"author": "Georgi Gardev <georgi@gar.dev>",
"license": "MIT",
"engines": {
"node": ">=22.12"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -18,34 +21,33 @@
"preview": "vite preview"
},
"dependencies": {
"@mantine/core": "^7.16.3",
"@mantine/hooks": "^7.16.3",
"@mantine/modals": "^7.16.3",
"@tabler/icons-react": "^3.30.0",
"date-fns": "^4.1.0",
"jotai": "^2.11.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.1.5",
"@mantine/core": "^7.17.8",
"@mantine/hooks": "^7.17.8",
"@tabler/icons-react": "^3.44.0",
"date-fns": "^4.4.0",
"jotai": "^2.20.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^7.18.1",
"reset.css": "^2.0.2",
"unsplash-js": "^7.0.19"
"unsplash-js": "^7.0.20",
"yaml": "^2.8.0"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@types/node": "^22.13.1",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"postcss": "^8.5.1",
"postcss-preset-mantine": "^1.17.0",
"@eslint/js": "^9.39.0",
"@types/node": "^24.0.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^9.39.0",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.0.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "~5.7.2",
"typescript-eslint": "^8.22.0",
"vite": "^5.1.0"
"typescript": "~5.9.0",
"typescript-eslint": "^8.46.0",
"vite": "^8.1.4"
}
}
+3
View File
@@ -0,0 +1,3 @@
// Placeholder for development. In production the container entrypoint
// overwrites this file with values from the VITE_* environment variables.
window.__VERTEX_CONFIG__ = {};
+3 -1
View File
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { loadYaml } from '~/lib/load-yaml';
import { CategoryDefinition } from '~/types';
async function getCategories(): Promise<CategoryDefinition[]> {
return await import('../../data/apps.yml').then((m) => m.default.categories);
const data = await loadYaml<{ categories: CategoryDefinition[] }>('/data/apps.yml');
return data?.categories ?? [];
}
export function useCategories() {
+3 -1
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { loadYaml } from '~/lib/load-yaml';
import { SearchProvider } from '~/types';
async function getSearchProviders(): Promise<SearchProvider[]> {
return await import('../../data/search-providers.yml').then((m) => m.default.providers);
const data = await loadYaml<{ providers: SearchProvider[] }>('/data/search-providers.yml');
return data?.providers ?? [];
}
export function useSearchProviders() {
+4 -3
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { getRuntimeEnv } from '~/lib/runtime-config';
import { WeatherData } from '~/types';
function fetchWeather(): Promise<WeatherData> {
const lat = import.meta.env.VITE_OPEN_WEATHER_LAT;
const lon = import.meta.env.VITE_OPEN_WEATHER_LON;
const appId = import.meta.env.VITE_OPEN_WEATHER_KEY;
const lat = getRuntimeEnv('VITE_OPEN_WEATHER_LAT');
const lon = getRuntimeEnv('VITE_OPEN_WEATHER_LON');
const appId = getRuntimeEnv('VITE_OPEN_WEATHER_KEY');
return fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${appId}&units=metric`
+2 -1
View File
@@ -1,6 +1,7 @@
import { createApi } from 'unsplash-js';
import { getRuntimeEnv } from '~/lib/runtime-config';
const UNSPLASH_ACCESS_KEY = import.meta.env.VITE_UNSPLASH_ACCESS_KEY;
const UNSPLASH_ACCESS_KEY = getRuntimeEnv('VITE_UNSPLASH_ACCESS_KEY') ?? '';
const unsplashApi = createApi({ accessKey: UNSPLASH_ACCESS_KEY, fetch });
+1 -1
View File
@@ -12,7 +12,7 @@ export function useActiveCategory() {
}
if (mode === 'temporary') {
storeTimeout(setTimeout(() => setActiveCategory(null), 3000));
storeTimeout(window.setTimeout(() => setActiveCategory(null), 3000));
}
},
[timeout]
+18
View File
@@ -0,0 +1,18 @@
import { parse } from 'yaml';
// Config YAML is fetched at runtime (served from the mounted data/ volume)
// rather than imported at build time, so edits to data/*.yml show up on
// refresh without rebuilding the image.
export async function loadYaml<T>(path: string): Promise<T | null> {
try {
const res = await fetch(path);
if (!res.ok) {
console.error(`Failed to load ${path}: ${res.status} ${res.statusText}`);
return null;
}
return parse(await res.text()) as T;
} catch (error) {
console.error(`Failed to load ${path}:`, error);
return null;
}
}
+16
View File
@@ -0,0 +1,16 @@
// Runtime configuration for static production builds.
//
// In production the container entrypoint writes /config.js, which sets
// window.__VERTEX_CONFIG__ from the container's VITE_* environment variables.
// In development that file is empty, so values fall back to import.meta.env
// (populated by Vite from .env). This keeps env vars changeable at container
// start instead of being baked into the bundle at build time.
declare global {
interface Window {
__VERTEX_CONFIG__?: Record<string, string | undefined>;
}
}
export function getRuntimeEnv(key: string): string | undefined {
return window.__VERTEX_CONFIG__?.[key] ?? import.meta.env[key];
}
+8 -8
View File
@@ -33,14 +33,6 @@ export function IndexPage() {
[searchTerm, shouldSearchLocally, filteredApps]
);
useEffect(() => {
parent.addEventListener('keydown', handleKeypress);
return () => {
parent.removeEventListener('keydown', handleKeypress);
};
});
function handleKeypress(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowRight': {
@@ -72,6 +64,14 @@ export function IndexPage() {
}
}
useEffect(() => {
parent.addEventListener('keydown', handleKeypress);
return () => {
parent.removeEventListener('keydown', handleKeypress);
};
});
return (
<Layout
isLoading={isInitialLoading}
+1 -4
View File
@@ -13,10 +13,7 @@
"baseUrl": ".",
"paths": {
"~/*": ["src/*"],
},
"types": [
"@modyfi/vite-plugin-yaml/modules"
]
}
},
"include": ["src"]
}
+1 -2
View File
@@ -1,11 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import ViteYaml from '@modyfi/vite-plugin-yaml';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), ViteYaml()],
plugins: [react()],
server: {
port: 3000,
host: '0.0.0.0',