Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
112cf84362 | ||
|
|
61ede5ee99 | ||
|
|
2fbde04563 | ||
|
|
9c118d41c2 | ||
|
|
c0c5e1cfcb | ||
|
|
28b10eee2b | ||
|
|
23868ef7c9 | ||
|
|
10c667dffd |
No files matched your search
@@ -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}" '
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -94,12 +94,18 @@ export const auth = betterAuth({
|
|||||||
sendOnSignUp: true,
|
sendOnSignUp: true,
|
||||||
autoSignInAfterVerification: true,
|
autoSignInAfterVerification: true,
|
||||||
async sendVerificationEmail({ user, url }) {
|
async sendVerificationEmail({ user, url }) {
|
||||||
await sendTransactionalEmail({
|
// Verification is optional — a misconfigured transport must not fail
|
||||||
to: user.email,
|
// sign-up or sign-in.
|
||||||
subject: "Verify your Primora email",
|
try {
|
||||||
text: `Verify your Primora email: ${url}`,
|
await sendTransactionalEmail({
|
||||||
html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`,
|
to: user.email,
|
||||||
});
|
subject: "Verify your Primora email",
|
||||||
|
text: `Verify your Primora email: ${url}`,
|
||||||
|
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
|
||||||
|
|||||||
@@ -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,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,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"
|
||||||
|
|||||||
@@ -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() });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.vercel
|
||||||
|
.env*
|
||||||
@@ -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 · 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 & 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_<prefix>_<secret></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>
|
||||||
@@ -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 |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -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": [
|
||||||
|
|||||||