ProfileWebNORM
README.md
normno.com — portfolio website
A production portfolio website with a liquid-glass aesthetic: animated blob backgrounds,
backdrop blur with a glass effect, and a ripple effect on clickable elements. Dark and light
themes switch automatically based on the device’s theme (prefers-color-scheme).
The navigation bar features a signature smiley face; clicking it triggers a blinking animation (Easter egg).
Stack: Astro + Svelte islands · Node 22 · SQLite (Drizzle ORM) · Caddy · Docker Compose. Designed for a low-spec VPS (512 MB RAM): everything is as static and cached as possible, a single process, the site never accesses GitHub or Telegram at the time of a user’s request.
Features
- Homepage — “About Me” section (Markdown text and links are edited in the admin panel, photos are uploaded there as well), minimalist social media buttons, the last 3 releases across all projects, a Spotify widget (current track; if nothing is playing—the last track played), the last 3 posts, and an activity heatmap with a GitHub / Claude toggle ✨.
- Projects—Hard Code / Vibe Code tabs, search and sorting (new releases, downloads, stars; the default option is set in the admin panel). Card: cover art, title, description, stars, total downloads. Project page: rendered README, releases with dates and download counts for each asset, list of open issues.
- Download menu — “Download the latest version” and a button next to each release expand to show a list of files: a platform icon based on the file extension (Windows, macOS, Linux, Android, iOS, Java, archive), size, and number of downloads. The file for the visitor’s OS is highlighted at the top and labeled “for your OS.” If there is only one file, it’s a regular link.
- Publications—Markdown posts with images from the admin panel and imported from the Telegram channel (along with photos and albums), source badge, sorted by date. Site posts are also published to the channel (rich-markdown, Bot API 10.1); edits are synchronized in both directions without duplicates; synchronized posts have a blue airplane icon and a link to the channel post. Details: docs/TELEGRAM.md.
- Project cover images—by default, GitHub’s og-image; in the admin panel, you can select
an image from the repository’s README (only the URL is stored) or upload your own
(only in this case is the file stored on the server, in
data/uploads). - RSS — a feed of posts on
/rss.xml(+ auto-detection in<head>). - Backups in Telegram—every day at 4:30 AM, the bot sends an archive of the database and uploaded
files to your private chat (
TELEGRAM_BACKUP_CHAT_ID), plus a manual backup button in the admin panel. - Two language versions—Russian on “clean” URLs, English at
/en(/en/publications/12). The language is determined byAccept-Language: if the browser isn’t set to Russian, the English version opens; the visitor’s selection is stored in a cookie, and the round button in the corner switches the language on the same page. We do not redirect bots based on language—each one receives exactly the URL it requested. - Automatic translation via API—the interface is translated manually (no quota is used), while content is translated using Google Cloud Translation (or DeepL/Azure, as desired) with a cache in SQLite. New posts are translated immediately after publication; older pages are translated lazily on the first visit. Editing a post (in the admin panel or on the Telegram channel) marks the translation as outdated based on the source’s hash and triggers a new translation; until it’s ready, the previous English text is displayed instead of the Russian version. The README, which is already in English, is identified based on the percentage of Cyrillic characters and is not translated at all. Nothing runs locally: for 2 GB of RAM, this is the only viable option. Details: docs/TRANSLATE.md.
- SEO — canonical and
hreflangon every page,sitemap.xmlwith both language versions, dynamicrobots.txt(on the admin host—completely prohibited), Open Graph and Twitter Card, JSON-LD (Person,WebSite,BlogPosting,SoftwareSourceCode, breadcrumbs), RSS in two languages, and IndexNow ping to Bing/Yandex upon publication. Checklist for migrating to a new domain: docs/SEO.md. - Data sync—an in-app cron job (set to run every 30 minutes by default) fetches GitHub
(repos, releases + download_count, stargazers, issues, README, contribution calendar,
commits involving Claude) and Telegram channel posts and writes them to SQLite. README and posts
are rendered
markdown-it+sanitize-htmlduring the sync phase—the site never makes calls to external APIs at the time of a user request. - The admin panel includes the tabs “GitHub Projects” (visibility, Hard/Vibe, covers,
default sorting, manual sync), “About Me and Links” (Markdown with preview, photos,
to Spotify), “Posts” (editor with preview and photos, sending to Telegram, import,
bulk deletion via checkboxes, bot diagnostics, manual backup), and “Translations”
(active provider, monthly character limit usage with a progress bar,
comparison of free service limits, API error analysis, translation status
for each post, and manual translation). Uploaded files are stored in
data/uploads, the same volume as the database.
Admin panel security: a separate subdomain
The admin panel is hidden from site visitors:
/admin*returns a 404 on the main domain—and in the application (middleware checksHost), and in Caddy (requests to/admin*the main domain are not proxied at all);- the admin panel can only be accessed from a subdomain within
ADMIN_HOST(for exampleadmin.normno.com) and is protected by Basic Auth (ADMIN_USER/ADMIN_PASSthere.env); - there is no link to the admin panel in the public navigation,
robots.txtandX-Robots-Tagindexing is disabled.
Local development
npm install
cp .env.example .env # заполнить минимум ADMIN_PASS; токены — по мере надобности
npm run dev
- Website: http://localhost:4321
- Admin panel: http://admin.localhost:4321/admin (in dev
ADMIN_HOST=admin.localhost; browsers will resolve it themselves*.localhostto 127.0.0.1)
The first sync with GitHub runs automatically a few seconds after startup (or by clicking the “Sync with GitHub” button in the admin panel).
Deployment to a VPS (Docker Compose)
Complete guide — docs/DEPLOY.md: setting up integrations, backups and restoration, updates, troubleshooting common issues. In short:
-
Point your DNS A records
normno.comandadmin.normno.comto the server’s IP address. -
Install Docker (
curl -fsSL https://get.docker.com | sh), open ports 80 and 443. -
Deploy:
git clone https://github.com/NORMss/ProfileWebNORM.git cd ProfileWebNORM cp .env.example .env nano .env # SITE_DOMAIN, ADMIN_DOMAIN, ADMIN_HOST, ADMIN_PASS, TZ, токены docker compose up -d --buildCaddy will automatically issue Let's Encrypt HTTPS certificates for both domains.
-
Update:
git pull && docker compose up -d --build(schema migrations are applied automatically; data is preserved).
The entire site’s status is stored in ./data: site.db (SQLite) and uploads/ (profile photos,
cover images, post images). A backup is an archive of this directory; when configured,
TELEGRAM_BACKUP_CHAT_ID the bot sends it automatically every day.
Environment variables
| Variable | Description |
|---|---|
SITE_URL |
Public website address (for links) |
SITE_DOMAIN / ADMIN_DOMAIN |
Domains for Caddy |
ALIAS_DOMAINS |
Additional domains separated by spaces: a separate certificate for each, 301 redirect to SITE_DOMAIN (DEPLOY 2.1) |
ADMIN_HOST |
Admin panel host; on other hosts /admin → 404 |
ADMIN_USER / ADMIN_PASS |
Basic Auth for the admin panel (the admin panel is disabled without a password) |
GITHUB_USERNAME |
Whose repositories to display |
GITHUB_TOKEN |
Classic PAT with scope public_repo + read:user: API limits and heatmap |
SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET |
Spotify app (the widget is hidden without it) |
SPOTIFY_REFRESH_TOKEN |
Optional: manually obtained token (connected via the admin panel takes priority) |
SPOTIFY_REDIRECT_URI |
Optional: override the redirect URI (for local development) |
TELEGRAM_BOT_TOKEN |
Bot for importing and publishing channel posts (the bot is the channel admin; see docs/TELEGRAM.md) |
TELEGRAM_CHANNEL |
@username channel (or -100…); required for publishing website posts to the channel |
TELEGRAM_BACKUP_CHAT_ID |
Personal chat_id for daily backups (empty means disabled) |
TRANSLATE_PROVIDER |
Auto-translation provider: google / deepl / azure / none (empty — the first one with a key) |
GOOGLE_TRANSLATE_API_KEY |
Google Cloud Translation API key (500,000 characters/month free) |
DEEPL_API_KEY / AZURE_TRANSLATOR_KEY |
Alternative translation services, see docs/TRANSLATE.md |
TRANSLATE_MONTHLY_LIMIT |
Your monthly character limit (0 — provider’s free limit) |
INDEXNOW_KEY |
IndexNow key: the site automatically notifies Bing and Yandex of new posts |
GOOGLE_SITE_VERIFICATION / YANDEX_VERIFICATION |
content= Meta tags for rights confirmation in Search Console and Webmaster Tools |
DB_PATH |
Path to the SQLite file |
SYNC_INTERVAL_MIN |
Sync interval, in minutes (default is 30) |
TZ |
Container time zone — the daily backup time depends on this (4:30 a.m.) |
How to connect Spotify
Everything is done via a browser (including on a phone); no terminal is required:
- Create an app at https://developer.spotify.com/dashboard.
- In its settings, add a Redirect URI—the admin panel URL:
https://admin.normno.com/admin/spotify/callback(replace with your ownADMIN_DOMAIN; the exact value is shown in the admin panel). - Enter
SPOTIFY_CLIENT_IDandSPOTIFY_CLIENT_SECRETin.env, then restart:docker compose up -d --build. -
Open the admin panel → “About Me and Links” tab → “♫ Connect Spotify”
→ confirm access. The refresh token will be saved to the database automatically,
SPOTIFY_REFRESH_TOKENyou.envyou don't need to enter it.
Spotify only accepts HTTPS addresses and loopback (127.0.0.1) — so
the redirect leads to the admin panel domain, where HTTPS is already configured via Caddy.
For local development, set SPOTIFY_REDIRECT_URI=http://127.0.0.1:4321/admin/spotify/callback
and add the same address to the Spotify app.
Manual method (if you need a token in .env)
Open this in your browser (replace with your own client_id and redirect_uri from the app),
authorize access, and copy code from the address bar—the page may
display an error, but this is normal:
https://accounts.spotify.com/authorize?client_id=CLIENT_ID&response_type=code&redirect_uri=REDIRECT_URI&scope=user-read-currently-playing%20user-read-recently-played
curl -X POST https://accounts.spotify.com/api/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-d grant_type=authorization_code -d code=CODE \
-d redirect_uri=REDIRECT_URI
refresh_token from the response → to .env as SPOTIFY_REFRESH_TOKEN
(the value from .env takes precedence over the one received via the admin panel).
Structure
src/
middleware.ts # host-gating админки + Basic Auth + CSRF + языковой
# роутинг (/en, Accept-Language, куки) + запуск cron
lib/
config.ts # доступ к env
db/ # better-sqlite3 + Drizzle, DDL, миграции, дефолты
sync/ # github.ts (репозитории, контрибуции, Claude-коммиты),
# telegram.ts (импорт постов и фото), планировщик
telegram.ts # публикация и правка постов в канале (rich-markdown)
backup.ts # снапшот БД + uploads → архив в Telegram
spotify.ts # now-playing с кешем 30 с
markdown.ts # markdown-it + sanitize-html
i18n/ # языки, разбор Accept-Language, словарь строк интерфейса
translate/ # провайдеры API, кеш переводов в SQLite, учёт лимита
seo.ts # canonical, hreflang, Open Graph, JSON-LD
indexnow.ts # пинг Bing/Яндекса при публикации
pages/
index/projects/publications # публичные страницы
rss.xml.ts # RSS-лента (на языке версии сайта)
sitemap.xml.ts # карта сайта с hreflang-альтернативами
robots.txt.ts # robots: публичный хост и админ-хост по-разному
[key].txt.ts # файл-подтверждение ключа IndexNow
api/now-playing.ts # JSON для виджета
media/** # отдача загруженных изображений
admin/** # админка, её API и OAuth Spotify
components/ # Icon, Heatmap, SpotifyWidget, admin/AdminApp
docs/DEPLOY.md # руководство по развертыванию
docs/TELEGRAM.md # бот: посты, синхронизация, бэкапы
docs/TRANSLATE.md # автоперевод: выбор API, лимиты, кеш, ошибки
docs/SEO.md # индексация, hreflang, переезд на новый домен
deploy/Caddyfile # домены: сайт, админка, алиасы с 301
deploy/local/ # свои блоки Caddy для этого сервера (не в git)
Releases
No releases yet.
Open issues
No open issues 🎉