Author SHA1 Message Date
Tomas DvorakandDevin 112cf84362 chore: release v0.7.1, auto-release on merge
Release workflow now also fires on workflow_run when CI succeeds on
master; verify job skips unless package.json version lacks a matching
tag, so merges only release on a version bump. Tag pushes unchanged.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:12:24 +02:00
Tomáš Dvořák 61ede5ee99 Merge pull request #13 from Dvorinka/fix/selfhosted-email-optional
CI / Backend Tests (push) Canceled after 0s
CI / Frontend Tests (push) Canceled after 0s
CI / Build All Services (push) Canceled after 0s
CI / Docker Build (auth) (push) Canceled after 0s
CI / Docker Build (backend) (push) Canceled after 0s
CI / Docker Build (frontend) (push) Canceled after 0s
CI / Migration Up/Down (push) Canceled after 0s
CI / Compose E2E Smoke (push) Canceled after 0s
CI / Lint (push) Canceled after 0s
CI / Publish edge images (auth) (push) Canceled after 0s
CI / Publish edge images (backend) (push) Canceled after 0s
CI / Publish edge images (frontend) (push) Canceled after 0s
fix: self-hosted auth without mail transport, password-manager-friendly login
2026-09-22 19:50:30 +02:00
Tomas DvorakandDevin 2fbde04563 fix: don't require email infra on self-hosted, improve login form detection
- sendTransactionalEmail no-ops (with a warning) when no mail transport is
  configured instead of throwing — sign-up 500'd on SMTP-less instances
- sendVerificationEmail catches transport failures so a broken SMTP can't
  block account creation; verification stays optional (requireEmailVerification
  was never set)
- login form gains id/name attributes and label for/id pairing so password
  managers (Proton Pass et al.) detect the fields; email input uses
  autocomplete=username

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 19:46:14 +02:00
Tomáš Dvořák 9c118d41c2 Merge pull request #12 from Dvorinka/feat/landing-page
feat: add static landing page
2026-09-22 19:38:16 +02:00
Tomas DvorakandDevin c0c5e1cfcb chore: gitignore vercel link metadata in landing/
Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 18:19:40 +02:00
Tomas DvorakandDevin 28b10eee2b feat: add static landing page
Self-contained index.html under landing/ — no build step, deploys as a
plain static site (Vercel output dir = landing/). Uses the app's design
tokens (zinc dark, vermilion accent, Geist/Geist Mono vendored as woff2),
real screenshots from docs/, and the README feature/architecture facts.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 18:14:28 +02:00
Tomáš Dvořák 23868ef7c9 Merge pull request #11 from Dvorinka/fix/installer-strip-build-blocks
CI / Backend Tests (push) Canceled after 0s
CI / Frontend Tests (push) Canceled after 0s
CI / Build All Services (push) Canceled after 0s
CI / Docker Build (auth) (push) Canceled after 0s
CI / Docker Build (backend) (push) Canceled after 0s
CI / Docker Build (frontend) (push) Canceled after 0s
CI / Migration Up/Down (push) Canceled after 0s
CI / Compose E2E Smoke (push) Canceled after 0s
CI / Lint (push) Canceled after 0s
CI / Publish edge images (auth) (push) Canceled after 0s
CI / Publish edge images (backend) (push) Canceled after 0s
CI / Publish edge images (frontend) (push) Canceled after 0s
fix: strip build blocks in installer prebuilt path
2026-09-21 22:31:36 +02:00
Tomas DvorakandDevin 10c667dffd fix: strip build blocks in installer prebuilt path
The installer's GHCR path ships a compose file that still declares
build: contexts pointing at apps/*/Dockerfile, which it never
downloads. Any later `compose build` or `up --build` fails with
"lstat apps: no such file or directory". Delete the build blocks
after a successful prebuilt pull so the shipped file matches what
is actually runnable.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 22:25:32 +02:00
23 changed files with 754 additions and 20 deletions

No files matched your search

+54 -8
View File
@@ -1,25 +1,54 @@
name: Release name: Release
# Triggered by semver tags: git tag v0.3.0 && git push --tags # Triggered two ways:
# Tag must match the root package.json version. # 1. Semver tags: git tag v0.3.0 && git push --tags (must match package.json).
# 2. workflow_run: after the CI workflow succeeds on master, the version in
# root package.json is released automatically if no matching tag exists.
on: on:
push: push:
tags: ["v*.*.*"] tags: ["v*.*.*"]
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [master]
permissions: permissions:
contents: write contents: write
packages: write packages: write
env:
VERSION: ${{ github.ref_name }}
jobs: jobs:
verify: verify:
name: Verify tag name: Verify version
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.check.outputs.version }}
needed: ${{ steps.check.outputs.needed }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Resolve version + decide whether to release
id: check
run: |
if [ "${{ github.event_name }}" = "workflow_run" ]; then
if [ "${{ github.event.workflow_run.conclusion }}" != "success" ]; then
echo "CI did not succeed — skipping release."
echo "needed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
VERSION="v$(node -p "require('./package.json').version")"
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" > /dev/null 2>&1; then
echo "$VERSION already released — nothing to do."
echo "needed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
else
VERSION="${{ github.ref_name }}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "needed=true" >> "$GITHUB_OUTPUT"
- name: Check tag matches package versions - name: Check tag matches package versions
if: steps.check.outputs.needed == 'true'
env:
VERSION: ${{ steps.check.outputs.version }}
run: | run: |
PKG=$(node -p "require('./package.json').version") PKG=$(node -p "require('./package.json').version")
DESKTOP_PKG=$(node -p "require('./apps/desktop/package.json').version") DESKTOP_PKG=$(node -p "require('./apps/desktop/package.json').version")
@@ -32,6 +61,7 @@ jobs:
images: images:
name: Build + push ${{ matrix.app }} name: Build + push ${{ matrix.app }}
needs: verify needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
@@ -57,7 +87,7 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ env.VERSION }} ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:latest ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:latest
cache-from: type=gha,scope=${{ matrix.app }} cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }} cache-to: type=gha,mode=max,scope=${{ matrix.app }}
@@ -65,6 +95,7 @@ jobs:
cli-binaries: cli-binaries:
name: Compile ${{ matrix.name }} name: Compile ${{ matrix.name }}
needs: verify needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
@@ -94,6 +125,7 @@ jobs:
desktop: desktop:
name: Desktop bundle (${{ matrix.name }}) name: Desktop bundle (${{ matrix.name }})
needs: verify needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
@@ -130,6 +162,7 @@ jobs:
npm-packages: npm-packages:
name: Pack npm tarballs name: Pack npm tarballs
needs: verify needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -151,8 +184,11 @@ jobs:
release: release:
name: GitHub Release name: GitHub Release
needs: [images, cli-binaries, desktop, npm-packages] needs: [verify, images, cli-binaries, desktop, npm-packages]
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
VERSION: ${{ needs.verify.outputs.version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# build-push-action uploads "*.dockerbuild" metadata artifacts that # build-push-action uploads "*.dockerbuild" metadata artifacts that
@@ -171,6 +207,16 @@ jobs:
with: with:
name: npm-tarballs name: npm-tarballs
path: dist-release path: dist-release
# workflow_run runs leave the release untagged — create it so the
# GitHub Release points at a real tag. GITHUB_TOKEN pushes do not
# re-trigger workflows, so this cannot recurse.
- name: Tag release
if: github.event_name == 'workflow_run'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "$VERSION"
git push origin "$VERSION"
- name: Extract changelog section - name: Extract changelog section
run: | run: |
awk -v ver="${VERSION#v}" ' awk -v ver="${VERSION#v}" '
+16
View File
@@ -2,6 +2,22 @@
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
## [0.7.1] - 2026-09-23
### Fixed
- **Self-hosted sign-up no longer requires mail infrastructure** — `sendTransactionalEmail` warned-and-returned when no transport was configured instead of throwing, so SMTP-less instances 500'd on sign-up. `sendVerificationEmail` now also catches transport failures; a broken SMTP cannot block account creation (verification stays optional).
- **Password managers detect the login form** — the email/password fields gained `id`/`name` attributes and `label for`/`id` pairing, and the email input uses `autocomplete="username"` (Proton Pass et al.).
- **Installer prebuilt path strips build blocks** — `install.sh` previously kept `*-build` compose blocks when pulling prebuilt GHCR images.
### Added
- **Static landing page** (`landing/`) — self-contained marketing site with bundled Geist fonts and product screenshots; deployed separately from the app artifacts.
### Infrastructure
- **Automatic releases on merge** — merges to `master` whose root `package.json` version has no matching `v*` tag now trigger the full release pipeline (GHCR images, CLI binaries, desktop bundles, npm tarballs, GitHub Release). Tag pushes still work as before.
## [0.7.0] - 2026-09-21 ## [0.7.0] - 2026-09-21
### Changed ### Changed
+6
View File
@@ -94,12 +94,18 @@ export const auth = betterAuth({
sendOnSignUp: true, sendOnSignUp: true,
autoSignInAfterVerification: true, autoSignInAfterVerification: true,
async sendVerificationEmail({ user, url }) { async sendVerificationEmail({ user, url }) {
// Verification is optional — a misconfigured transport must not fail
// sign-up or sign-in.
try {
await sendTransactionalEmail({ await sendTransactionalEmail({
to: user.email, to: user.email,
subject: "Verify your Primora email", subject: "Verify your Primora email",
text: `Verify your Primora email: ${url}`, text: `Verify your Primora email: ${url}`,
html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`, html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`,
}); });
} catch (error) {
console.warn(JSON.stringify({ level: "warn", msg: "verification_email_failed", error }));
}
}, },
}, },
// Enabled unconditionally — better-auth only turns this on when NODE_ENV is // Enabled unconditionally — better-auth only turns this on when NODE_ENV is
+6 -1
View File
@@ -51,7 +51,12 @@ export async function sendTransactionalEmail(input: {
} }
if (!("smtp" in cfg) || !cfg.smtp) { if (!("smtp" in cfg) || !cfg.smtp) {
throw new Error("No mail transport configured for auth service."); // Self-hosted instances may run without mail — auth still works, the
// email simply never goes out.
console.warn(
JSON.stringify({ level: "warn", msg: "mail_skipped_no_transport", to: input.to, subject: input.subject }),
);
return;
} }
await nodemailer.createTransport(cfg.smtp).sendMail({ await nodemailer.createTransport(cfg.smtp).sendMail({
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@primora/desktop", "name": "@primora/desktop",
"version": "0.7.0", "version": "0.7.1",
"private": true, "private": true,
"description": "Primora desktop shell — thin Tauri client for a self-hosted deployment", "description": "Primora desktop shell — thin Tauri client for a self-hosted deployment",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Primora", "productName": "Primora",
"version": "0.7.0", "version": "0.7.1",
"identifier": "dev.tdvorak.primora", "identifier": "dev.tdvorak.primora",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
+1 -1
View File
@@ -736,7 +736,7 @@ export default function App() {
try { try {
if (mode() === "sign-up") { if (mode() === "sign-up") {
await authClient.signUp.email({ email: email(), password: password(), name: name() }); await authClient.signUp.email({ email: email(), password: password(), name: name() });
setAuthMessage("Account created. Check Mailpit (or your SMTP provider) for the verification email."); setAuthMessage("Account created — signing you in.");
} else { } else {
await authClient.signIn.email({ email: email(), password: password() }); await authClient.signIn.email({ email: email(), password: password() });
} }
+3 -2
View File
@@ -13,16 +13,17 @@ const sizeClasses = {
}; };
export function Input(props: InputProps) { export function Input(props: InputProps) {
const [local, rest] = splitProps(props, ["label", "error", "size", "class"]); const [local, rest] = splitProps(props, ["label", "error", "size", "class", "id"]);
const size = () => local.size ?? "md"; const size = () => local.size ?? "md";
return ( return (
<div class="w-full"> <div class="w-full">
<Show when={local.label}> <Show when={local.label}>
<label class="label">{local.label}</label> <label class="label" for={local.id}>{local.label}</label>
</Show> </Show>
<input <input
id={local.id}
class={`input ${sizeClasses[size()]} ${local.error ? "border-error focus:border-error focus:ring-error/20" : ""} ${local.class ?? ""}`} class={`input ${sizeClasses[size()]} ${local.error ? "border-error focus:border-error focus:ring-error/20" : ""} ${local.class ?? ""}`}
{...rest} {...rest}
/> />
+7 -1
View File
@@ -124,6 +124,8 @@ export function LoginPage(props: LoginPageProps) {
<Show when={!isSignIn()}> <Show when={!isSignIn()}>
<Input <Input
label="Name" label="Name"
id="name"
name="name"
type="text" type="text"
placeholder="Ada Lovelace" placeholder="Ada Lovelace"
value={props.name} value={props.name}
@@ -134,15 +136,19 @@ export function LoginPage(props: LoginPageProps) {
</Show> </Show>
<Input <Input
label="Email" label="Email"
id="email"
name="email"
type="email" type="email"
placeholder="[email protected]" placeholder="[email protected]"
value={props.email} value={props.email}
onInput={(e) => props.onEmailChange(e.currentTarget.value)} onInput={(e) => props.onEmailChange(e.currentTarget.value)}
autocomplete="email" autocomplete="username"
required required
/> />
<Input <Input
label="Password" label="Password"
id="password"
name="password"
type="password" type="password"
placeholder="••••••••" placeholder="••••••••"
value={props.password} value={props.password}
+7 -4
View File
@@ -20,6 +20,10 @@ NGINX_PORT="${NGINX_PORT:-80}"
info() { echo "==> $*"; } info() { echo "==> $*"; }
die() { echo "ERROR: $*" >&2; exit 1; } die() { echo "ERROR: $*" >&2; exit 1; }
# '|' delimiter for sed: base64 output can contain '/', '+', '='.
SED=(sed -i)
[[ "$OSTYPE" == darwin* ]] && SED=(sed -i '')
command -v docker >/dev/null 2>&1 || die "docker is not installed" command -v docker >/dev/null 2>&1 || die "docker is not installed"
docker compose version >/dev/null 2>&1 || die "docker compose plugin is not installed" docker compose version >/dev/null 2>&1 || die "docker compose plugin is not installed"
command -v curl >/dev/null 2>&1 || die "curl is not installed" command -v curl >/dev/null 2>&1 || die "curl is not installed"
@@ -42,10 +46,6 @@ if [ ! -f .env ]; then
ENCRYPTION_KEY=$(openssl rand -hex 32) ENCRYPTION_KEY=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 16) DB_PASSWORD=$(openssl rand -hex 16)
# '|' delimiter: base64 output can contain '/', '+', '='.
SED=(sed -i)
[[ "$OSTYPE" == darwin* ]] && SED=(sed -i '')
"${SED[@]}" "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$DB_PASSWORD|" .env "${SED[@]}" "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$DB_PASSWORD|" .env
"${SED[@]}" "s|postgres://primora:primora@|postgres://primora:$DB_PASSWORD@|" .env "${SED[@]}" "s|postgres://primora:primora@|postgres://primora:$DB_PASSWORD@|" .env
"${SED[@]}" "s|^JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET|" .env "${SED[@]}" "s|^JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET|" .env
@@ -68,6 +68,9 @@ if docker pull "ghcr.io/$GHCR_OWNER/primora-backend:latest" \
&& docker pull "ghcr.io/$GHCR_OWNER/primora-auth:latest" \ && docker pull "ghcr.io/$GHCR_OWNER/primora-auth:latest" \
&& docker pull "ghcr.io/$GHCR_OWNER/primora-frontend:latest"; then && docker pull "ghcr.io/$GHCR_OWNER/primora-frontend:latest"; then
info "Starting prebuilt images" info "Starting prebuilt images"
# The source tree isn't downloaded on this path — strip build: blocks so a
# later `compose build`/`up --build` can't fail on missing apps/*/Dockerfile.
"${SED[@]}" '/^[[:space:]]*build:/,/^[[:space:]]*dockerfile:/d' docker-compose.yml
docker compose up -d --no-build docker compose up -d --no-build
else else
# GHCR packages are private until first published release — build from source. # GHCR packages are private until first published release — build from source.
+2
View File
@@ -0,0 +1,2 @@
.vercel
.env*
Binary file not shown.
Binary file not shown.
+641
View File
@@ -0,0 +1,641 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Primora — Self-hosted backend platform</title>
<meta name="description" content="Primora is a self-hosted backend platform: auth, Postgres-backed APIs, S3-style object storage, JSON collections, scoped API keys, and a full audit log on your own hardware.">
<meta property="og:title" content="Primora — Self-hosted backend platform">
<meta property="og:description" content="Auth, Postgres-backed APIs, object storage, and audit logs on your own hardware. No hosted tier.">
<meta property="og:type" content="website">
<meta property="og:image" content="screenshots/overview.png">
<link rel="icon" type="image/svg+xml" href="primora.svg">
<style>
@font-face {
font-family: "Geist";
src: url("fonts/geist-sans.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: "Geist Mono";
src: url("fonts/geist-mono.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
:root {
--bg: #0a0a0b;
--bg-panel: #0f0f11;
--surface: #141416;
--surface-2: #1b1b1f;
--border: #222227;
--border-strong: #303036;
--text-1: #f4f4f5;
--text-2: #a2a2ab;
--text-3: #63636d;
--accent: #f4693a;
--accent-hover: #ff7c4e;
--accent-fg: #1c0d05;
--accent-muted: rgba(244, 105, 58, 0.12);
--accent-border: rgba(244, 105, 58, 0.35);
--success: #4cc38a;
--font-sans: "Geist", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, "SF Mono", monospace;
--radius: 8px;
--radius-lg: 12px;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: var(--font-sans);
font-size: 0.9375rem;
line-height: 1.6;
letter-spacing: -0.006em;
background: var(--bg);
color: var(--text-1);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
::selection { background: var(--accent-border); color: var(--text-1); }
a { color: inherit; text-decoration: none; }
code, pre, .mono { font-family: var(--font-mono); }
.wrap { max-width: 72rem; margin: 0 auto; padding: 0 1.5rem; }
/* ------------------------------ nav ------------------------------ */
.nav {
position: fixed; inset: 0 0 auto 0; z-index: 50;
border-bottom: 1px solid var(--border);
background: rgba(10, 10, 11, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.nav-inner {
display: flex; align-items: center; justify-content: space-between;
height: 3.5rem;
}
.nav-brand { display: flex; align-items: center; gap: 0.625rem; font-weight: 600; letter-spacing: -0.02em; }
.nav-brand img { width: 1.375rem; height: 1.375rem; border-radius: 5px; }
.nav-links { display: flex; align-items: center; gap: 1.75rem; font-size: 0.8125rem; color: var(--text-2); }
.nav-links a:hover { color: var(--text-1); }
.nav-cta {
display: inline-flex; align-items: center; gap: 0.5rem;
font-size: 0.8125rem; font-weight: 500;
color: var(--text-1);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0.375rem 0.75rem;
transition: border-color 0.15s, background 0.15s;
}
.nav-cta:hover { border-color: var(--accent-border); background: var(--accent-muted); color: var(--text-1); }
.nav-cta svg { width: 0.9375rem; height: 0.9375rem; }
@media (max-width: 720px) { .nav-links a:not(.nav-cta) { display: none; } }
/* ------------------------------ hero ------------------------------ */
.hero {
position: relative;
padding: 9.5rem 0 5rem;
border-bottom: 1px solid var(--border);
overflow: hidden;
}
.hero::before {
content: "";
position: absolute; inset: 0;
background-image:
linear-gradient(to right, rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 90% 70% at 50% 0%, black 30%, transparent 75%);
-webkit-mask-image: radial-gradient(ellipse 90% 70% at 50% 0%, black 30%, transparent 75%);
pointer-events: none;
}
.hero-inner { position: relative; text-align: center; }
.eyebrow {
display: inline-flex; align-items: center; gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.3125rem 0.875rem;
margin-bottom: 1.75rem;
}
.eyebrow .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); }
.hero h1 {
font-size: clamp(2.5rem, 6.5vw, 4.25rem);
font-weight: 600;
letter-spacing: -0.04em;
line-height: 1.05;
max-width: 17ch;
margin: 0 auto 1.25rem;
}
.hero h1 em { font-style: normal; color: var(--accent); }
.hero-sub {
font-size: 1.0625rem;
color: var(--text-2);
max-width: 34rem;
margin: 0 auto 2.5rem;
}
.hero-actions { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 0.5rem;
font-family: inherit;
font-size: 0.875rem; font-weight: 500;
border-radius: var(--radius);
padding: 0.625rem 1.125rem;
border: 1px solid transparent;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-primary { background: var(--accent); color: var(--accent-fg); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--surface); border-color: var(--border-strong); color: var(--text-1); }
.btn-secondary:hover { border-color: var(--text-3); }
.btn svg { width: 1rem; height: 1rem; }
.install {
display: inline-flex; align-items: center; gap: 0.875rem;
margin-top: 2rem;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.625rem 0.625rem 0.625rem 1rem;
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--text-2);
max-width: 100%;
}
.install .prompt { color: var(--accent); user-select: none; }
.install code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.install button {
flex: none;
display: inline-flex; align-items: center; gap: 0.375rem;
font-family: var(--font-mono); font-size: 0.6875rem; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--text-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.375rem 0.625rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.install button:hover { color: var(--text-1); border-color: var(--border-strong); }
.install button.copied { color: var(--success); border-color: var(--success); }
.install button svg { width: 0.8125rem; height: 0.8125rem; }
@media (max-width: 560px) {
.install { font-size: 0.6875rem; }
}
/* --------------------------- screenshot --------------------------- */
.shot-section { padding: 0 0 5.5rem; border-bottom: 1px solid var(--border); }
.shot {
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--bg-panel);
box-shadow: 0 24px 64px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.03);
margin-top: -1px;
}
.shot-bar {
display: flex; align-items: center; gap: 0.375rem;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--border);
}
.shot-bar .led { width: 9px; height: 9px; border-radius: 50%; background: var(--surface-3); }
.shot-bar .url {
margin-left: 0.75rem;
font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-3);
background: var(--surface); border: 1px solid var(--border); border-radius: 5px;
padding: 0.1875rem 0.625rem;
}
.shot img { display: block; width: 100%; }
/* ---------------------------- sections ---------------------------- */
section.block { padding: 5.5rem 0; border-bottom: 1px solid var(--border); }
.section-head { margin-bottom: 3rem; max-width: 40rem; }
.section-head .kicker {
font-family: var(--font-mono); font-size: 0.6875rem;
letter-spacing: 0.14em; text-transform: uppercase;
color: var(--accent);
display: block; margin-bottom: 0.875rem;
}
.section-head h2 {
font-size: clamp(1.625rem, 3.5vw, 2.25rem);
font-weight: 600; letter-spacing: -0.03em; line-height: 1.15;
margin-bottom: 0.75rem;
}
.section-head p { color: var(--text-2); }
/* fact strip */
.facts {
display: grid; grid-template-columns: repeat(4, 1fr);
border: 1px solid var(--border); border-radius: var(--radius-lg);
overflow: hidden; background: var(--bg-panel);
}
.fact { padding: 1.5rem; border-left: 1px solid var(--border); }
.fact:first-child { border-left: 0; }
.fact .v { font-family: var(--font-mono); font-size: 1.375rem; font-weight: 500; color: var(--text-1); font-variant-numeric: tabular-nums; }
.fact .v em { font-style: normal; color: var(--accent); }
.fact .k { font-size: 0.75rem; color: var(--text-3); margin-top: 0.25rem; }
@media (max-width: 720px) {
.facts { grid-template-columns: 1fr 1fr; }
.fact:nth-child(3) { border-left: 0; }
.fact:nth-child(n+3) { border-top: 1px solid var(--border); }
}
/* feature grid */
.grid {
display: grid; grid-template-columns: repeat(3, 1fr);
border: 1px solid var(--border); border-radius: var(--radius-lg);
overflow: hidden; background: var(--bg-panel);
}
.cell {
padding: 1.75rem;
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
transition: background 0.15s;
}
.cell:hover { background: var(--surface); }
.cell:nth-child(3n+1) { border-left: 0; }
.cell:nth-child(-n+3) { border-top: 0; }
.cell .icon {
width: 2rem; height: 2rem;
display: flex; align-items: center; justify-content: center;
border: 1px solid var(--border-strong); border-radius: var(--radius);
color: var(--accent); margin-bottom: 1.125rem;
background: var(--accent-muted);
border-color: var(--accent-border);
}
.cell .icon svg { width: 1rem; height: 1rem; }
.cell h3 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 0.375rem; }
.cell p { font-size: 0.8125rem; color: var(--text-2); }
.cell .tag {
display: inline-block; margin-top: 0.875rem;
font-family: var(--font-mono); font-size: 0.625rem; letter-spacing: 0.08em;
color: var(--text-3); border: 1px solid var(--border); border-radius: 4px;
padding: 0.125rem 0.4375rem;
}
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr 1fr; }
.cell:nth-child(3n+1) { border-left: 1px solid var(--border); }
.cell:nth-child(odd) { border-left: 0; }
.cell:nth-child(-n+3) { border-top: 1px solid var(--border); }
.cell:nth-child(-n+2) { border-top: 0; }
}
@media (max-width: 560px) {
.grid { grid-template-columns: 1fr; }
.cell { border-left: 0 !important; border-top: 1px solid var(--border) !important; }
.cell:first-child { border-top: 0 !important; }
}
/* architecture */
.arch {
border: 1px solid var(--border); border-radius: var(--radius-lg);
background: var(--bg-panel);
padding: 2rem;
overflow-x: auto;
}
.arch pre {
font-family: var(--font-mono);
font-size: 0.8125rem;
line-height: 1.75;
color: var(--text-2);
min-width: 40rem;
}
.arch .hl { color: var(--accent); }
.arch .dim { color: var(--text-3); }
.arch .fg { color: var(--text-1); }
/* screenshots */
.shots { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
.shot-card {
border: 1px solid var(--border); border-radius: var(--radius-lg);
overflow: hidden; background: var(--bg-panel);
transition: border-color 0.15s;
}
.shot-card:hover { border-color: var(--border-strong); }
.shot-card img { display: block; width: 100%; border-bottom: 1px solid var(--border); }
.shot-card figcaption {
display: flex; align-items: baseline; justify-content: space-between;
padding: 0.875rem 1.125rem;
}
.shot-card figcaption b { font-size: 0.8125rem; font-weight: 600; }
.shot-card figcaption span { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-3); }
@media (max-width: 720px) { .shots { grid-template-columns: 1fr; } }
/* ecosystem */
.eco { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.25rem; }
.eco-card {
border: 1px solid var(--border); border-radius: var(--radius-lg);
background: var(--bg-panel); padding: 1.75rem;
}
.eco-card h3 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 0.375rem; display: flex; align-items: center; gap: 0.5rem; }
.eco-card h3 svg { width: 1rem; height: 1rem; color: var(--accent); }
.eco-card p { font-size: 0.8125rem; color: var(--text-2); margin-bottom: 1rem; }
.eco-card code.snip {
display: block;
font-size: 0.75rem; color: var(--text-2);
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
padding: 0.625rem 0.875rem;
overflow-x: auto; white-space: nowrap;
}
.eco-card code.snip .p { color: var(--accent); }
@media (max-width: 720px) { .eco { grid-template-columns: 1fr; } }
/* final cta */
.cta { text-align: center; padding: 6rem 0; border-bottom: 1px solid var(--border); }
.cta h2 { font-size: clamp(1.75rem, 4vw, 2.5rem); font-weight: 600; letter-spacing: -0.03em; margin-bottom: 0.75rem; }
.cta p { color: var(--text-2); margin-bottom: 2rem; }
/* footer */
footer { padding: 2.5rem 0; }
.foot { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.foot .left { display: flex; align-items: center; gap: 0.625rem; font-size: 0.8125rem; color: var(--text-3); }
.foot .left img { width: 1.125rem; height: 1.125rem; border-radius: 4px; }
.foot .links { display: flex; gap: 1.5rem; font-size: 0.8125rem; color: var(--text-3); }
.foot .links a:hover { color: var(--text-1); }
</style>
</head>
<body>
<nav class="nav">
<div class="wrap nav-inner">
<a class="nav-brand" href="#top">
<img src="primora.svg" alt="Primora logo">
Primora
</a>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#screenshots">Screenshots</a>
<a href="#stack">Stack</a>
<a href="#ecosystem">Ecosystem</a>
<a class="nav-cta" href="https://github.com/Dvorinka/Primora" rel="noopener">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
GitHub
</a>
</div>
</div>
</nav>
<header class="hero" id="top">
<div class="wrap hero-inner">
<span class="eyebrow"><span class="dot"></span>self-hosted &middot; MIT licensed</span>
<h1>Your backend, <em>your hardware</em>.</h1>
<p class="hero-sub">
Primora is a self-hosted backend platform — auth, Postgres-backed APIs,
S3-style object storage, JSON collections, scoped API keys, and a full
audit log behind one dashboard. No hosted tier.
</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#install">Get started</a>
<a class="btn btn-secondary" href="https://github.com/Dvorinka/Primora" rel="noopener">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
View on GitHub
</a>
</div>
<div class="install" id="install">
<span class="prompt">$</span>
<code id="install-cmd">curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash</code>
<button id="copy-btn" type="button" aria-label="Copy install command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span>copy</span>
</button>
</div>
</div>
</header>
<div class="shot-section">
<div class="wrap">
<figure class="shot">
<div class="shot-bar">
<span class="led"></span><span class="led"></span><span class="led"></span>
<span class="url">http://localhost</span>
</div>
<img src="screenshots/overview.png" alt="Primora project overview dashboard" loading="eager">
</figure>
</div>
</div>
<section class="block">
<div class="wrap">
<div class="facts">
<div class="fact"><div class="v"><em>1</em> cmd</div><div class="k">install — Docker Compose up</div></div>
<div class="fact"><div class="v"><em>0</em> hosted tiers</div><div class="k">data never leaves the box</div></div>
<div class="fact"><div class="v">MIT</div><div class="k">open source license</div></div>
<div class="fact"><div class="v"><em>100%</em> audit</div><div class="k">every mutation logged</div></div>
</div>
</div>
</section>
<section class="block" id="features">
<div class="wrap">
<div class="section-head">
<span class="kicker">// features</span>
<h2>Everything a backend needs. Nothing it doesn't.</h2>
<p>Inspired by the UX of Appwrite and Supabase — rebuilt as a single self-hosted stack you own outright.</p>
</div>
<div class="grid">
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg></div>
<h3>Organizations &amp; projects</h3>
<p>Workspaces with scoped org and project roles. Invite members by link, assign owner/admin/developer.</p>
<span class="tag">rbac</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg></div>
<h3>Auth</h3>
<p>Email/password via Better Auth, optional GitHub, Google, Discord and Microsoft OAuth. JWTs verified against JWKS.</p>
<span class="tag">better-auth + jwks</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/></svg></div>
<h3>Object storage</h3>
<p>S3-style buckets on your own filesystem, public or private. Upload, move, rename, inline preview.</p>
<span class="tag">local fs</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.66 3.58 3 8 3s8-1.34 8-3V5"/><path d="M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3"/></svg></div>
<h3>Collections</h3>
<p>Schema-flexible JSON documents stored in Postgres JSONB. Query, create and update without migrations.</p>
<span class="tag">postgres jsonb</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-2 2m-7.6 7.6a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78Zm0 0L15.5 7.5m3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg></div>
<h3>Scoped API keys</h3>
<p><code class="mono">prm_&lt;prefix&gt;_&lt;secret&gt;</code> credentials, shown once at creation. Revocable, rate-limited per key.</p>
<span class="tag">prm_*</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/></svg></div>
<h3>Audit log</h3>
<p>Every mutating request recorded — actor, action, target. Filterable in the UI, exportable as CSV or JSON.</p>
<span class="tag">csv / json export</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z"/></svg></div>
<h3>Automation</h3>
<p>Scheduled jobs and live event streams. Webhooks and connectors push project events to external systems.</p>
<span class="tag">schedules + webhooks</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg></div>
<h3>Instance admin</h3>
<p>Sign-up policy, mail transport, and rate limits managed in-app — no restart, secrets stored AES-256-GCM.</p>
<span class="tag">settings → instance</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg></div>
<h3>Demo mode</h3>
<p>Fully client-side seeded workspace — append <code class="mono">?demo=true</code> to the URL, no sign-up required.</p>
<span class="tag">?demo=true</span>
</div>
</div>
</div>
</section>
<section class="block" id="stack">
<div class="wrap">
<div class="section-head">
<span class="kicker">// stack</span>
<h2>Boring on purpose.</h2>
<p>One Postgres, one API, one process boundary per concern. Everything ships in Docker Compose.</p>
</div>
<div class="arch">
<pre><span class="fg">Browser</span> <span class="dim">──▶</span> <span class="fg">Nginx</span> <span class="dim">──┬──▶</span> <span class="hl">Frontend</span> <span class="dim">SolidJS + Vite</span>
<span class="dim">├──▶</span> <span class="hl">Auth</span> <span class="dim">Better Auth + Hono</span>
<span class="dim">└──▶</span> <span class="hl">API</span> <span class="dim">Go + Gin</span>
<span class="dim">│</span>
<span class="dim">├──▶</span> <span class="fg">PostgreSQL</span> <span class="dim">primary datastore</span>
<span class="dim">├──▶</span> <span class="fg">DragonflyDB</span> <span class="dim">cache + rate limits</span>
<span class="dim">└──▶</span> <span class="fg">Local FS</span> <span class="dim">object storage</span></pre>
</div>
</div>
</section>
<section class="block" id="screenshots">
<div class="wrap">
<div class="section-head">
<span class="kicker">// screenshots</span>
<h2>The console, as shipped.</h2>
<p>Real UI, no mockups. Every screen is dark-first, hairline-divided, and keyboard-driven.</p>
</div>
<div class="shots">
<figure class="shot-card">
<img src="screenshots/storage.png" alt="Object storage buckets and files" loading="lazy">
<figcaption><b>Storage</b><span>buckets / objects</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/collections.png" alt="JSON document collections" loading="lazy">
<figcaption><b>Collections</b><span>jsonb documents</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/telemetry.png" alt="Telemetry and component health" loading="lazy">
<figcaption><b>Telemetry</b><span>component health</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/automation.png" alt="Scheduled automation jobs" loading="lazy">
<figcaption><b>Automation</b><span>schedules</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/automation-live.png" alt="Live automation events" loading="lazy">
<figcaption><b>Live events</b><span>real-time stream</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/audit.png" alt="Audit log with export" loading="lazy">
<figcaption><b>Audit log</b><span>csv / json export</span></figcaption>
</figure>
</div>
</div>
</section>
<section class="block" id="ecosystem">
<div class="wrap">
<div class="section-head">
<span class="kicker">// ecosystem</span>
<h2>Beyond the browser.</h2>
<p>The same API powers a CLI, an MCP server, a desktop shell, and an installable PWA.</p>
</div>
<div class="eco">
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>CLI</h3>
<p><code class="mono">primora</code> manages orgs, projects, buckets, objects, keys, and audit from the terminal.</p>
<code class="snip"><span class="p">$</span> primora storage objects --bucket assets --limit 10</code>
</div>
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>MCP server</h3>
<p>Exposes <code class="mono">primora_*</code> tools to MCP clients — drive the platform from an agent.</p>
<code class="snip">primora_list_projects · primora_upload_object · …</code>
</div>
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>Desktop</h3>
<p>Tauri 2 shell that connects to your deployment, with tray presence and native notifications.</p>
<code class="snip">apps/desktop · tauri 2</code>
</div>
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg>PWA</h3>
<p>The dashboard installs on mobile and desktop browsers — same console, native frame.</p>
<code class="snip">installable · offline-aware shell</code>
</div>
</div>
</div>
</section>
<section class="cta">
<div class="wrap">
<h2>Run it on your own box.</h2>
<p>Docker with the Compose plugin is the only prerequisite. Up in one command.</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#install">Install Primora</a>
<a class="btn btn-secondary" href="https://github.com/Dvorinka/Primora/releases" rel="noopener">Releases</a>
</div>
</div>
</section>
<footer>
<div class="wrap foot">
<div class="left">
<img src="primora.svg" alt="">
<span>Primora — MIT licensed</span>
</div>
<div class="links">
<a href="https://github.com/Dvorinka/Primora" rel="noopener">GitHub</a>
<a href="https://github.com/Dvorinka/Primora/blob/master/QUICK_START.md" rel="noopener">Docs</a>
<a href="https://github.com/Dvorinka/Primora/blob/master/CONTRIBUTING.md" rel="noopener">Contributing</a>
<a href="https://github.com/Dvorinka/Primora/blob/master/SECURITY.md" rel="noopener">Security</a>
</div>
</div>
</footer>
<script>
document.getElementById("copy-btn").addEventListener("click", async function () {
const cmd = document.getElementById("install-cmd").textContent;
try {
await navigator.clipboard.writeText(cmd);
} catch {
const ta = document.createElement("textarea");
ta.value = cmd;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
this.classList.add("copied");
this.querySelector("span").textContent = "copied";
setTimeout(() => {
this.classList.remove("copied");
this.querySelector("span").textContent = "copy";
}, 1600);
});
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#f4693a"/>
<g fill="#ffffff">
<rect x="15" y="14" width="34" height="10" rx="5"/>
<rect x="15" y="27" width="23" height="10" rx="5" opacity="0.7"/>
<rect x="15" y="40" width="10" height="10" rx="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "primora", "name": "primora",
"version": "0.7.0", "version": "0.7.1",
"private": true, "private": true,
"packageManager": "[email protected]", "packageManager": "[email protected]",
"workspaces": [ "workspaces": [