diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..b65dd41 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,134 @@ +--- +name: release +description: Cut a tagged release of Vortex Studio - bump the version, open the develop-to-master release PR, merge it, tag master, publish the GitHub release, and merge master back into develop. Use whenever the user asks to release, cut a release, ship a version, tag a version, do a major/minor/patch release, or "do the whole release process". Covers where the version number lives and the exact branch, commit-message and tag conventions the repo already follows. +--- + +# Releasing Vortex Studio + +The canonical rules live in `CONTRIBUTING.md` under "Releases". This skill is the +executable version of them plus the details that only show up when you actually do it. + +Releases go out from `develop`. `master` is the released branch and is always tagged. + +## Pick the number + +From `CONTRIBUTING.md`: + +- **last number** (1.0.2 → 1.0.3) for fixes only +- **middle number** (1.0.2 → 1.1.0) when there are new features +- **first number** (1.0.2 → 2.0.0) only when the saved map format changes in a way + older files or older builds cannot handle + +Read the commits before deciding — do not take the user's word for "minor" if the log +shows a format change: + +```bash +git log --oneline "$(git describe --tags --abbrev=0 master)"..develop --no-merges +``` + +If the range touches the map format — `resources/js/studio/vortexProject.js`, its +round-trip tests in `scripts/project.test.mjs`, or any migration under +`database/migrations` that rewrites saved parts — stop and confirm the major bump with +the user before continuing. + +## Where the version lives + +`package.json` is the only source of truth. Nothing else hardcodes it: + +- `vite.config.js` reads `package.json` at build time into `__APP_VERSION__` +- `resources/js/studio/version.js` re-exports that as `APP_VERSION`, falling back to `'dev'` +- `MenuBar.jsx` shows it in Help → About; `StartScreen.jsx` shows it at the bottom of the start screen + +So a release edits exactly two files, and the diff is three lines: + +- `package.json` — the `"version"` key +- `package-lock.json` — the `"version"` at the top **and** the one under `packages.""` + +**Do not bump `live-editing-server/package.json`.** It has sat at 1.0.0 through every +release so far and is versioned separately. Changing that is a decision for the user, +not a step in this process. + +Do not run `npm version` — it makes a tag and a commit that do not match the conventions below. + +## The process + +Confirm the merge the release is built on actually landed on `develop` before starting. + +### 1. Tests, before anything else + +```bash +npm test # scripts/*.test.mjs +./vendor/bin/pest # tests/Feature +``` + +Both must be green. If either fails, stop and report — do not release around a failure. +Keep the counts, they go in the PR body. + +### 2. Bump on `develop` + +```bash +git checkout develop && git pull --ff-only +# edit package.json + package-lock.json +git add package.json package-lock.json +git commit -m "set the version to X.Y.Z" +git push origin develop +``` + +Commit message is exactly `set the version to X.Y.Z`, lowercase, no `v`. + +### 3. Release PR + +```bash +gh pr create --base master --head develop --title "release vX.Y.Z" --body "..." +``` + +Body: one line on why the middle/last number moved, an **In this release** list written +from the commit log in plain user-facing language, and a checklist of the steps below. + +### 4. Merge, tag, publish + +```bash +gh pr merge --merge --subject "merge develop into master for vX.Y.Z" --body "" +git checkout master && git pull --ff-only +git tag vX.Y.Z && git push origin vX.Y.Z +``` + +A **merge commit**, never squash and never rebase — the tag has to sit on the merge +commit, the way `v1.0.2` (`3e5b4a2`) and `v1.1.0` (`ce380ec`) do. Tag name carries the +`v`, the version in `package.json` does not. + +Then the GitHub release, matching the voice of the existing ones (`gh release view v1.1.0`): +a one-line summary of what kind of release it is, an **In this release** bullet list +ending with "the version bumped to X.Y.Z", an optional **Thanks** section crediting +outside contributors by handle and PR number, and a compare link last. + +```bash +gh release create vX.Y.Z --title "vX.Y.Z" --notes "..." +``` + +### 5. Merge back — do not skip + +```bash +git checkout develop && git pull --ff-only +git merge master --no-ff -m "merge master back into develop after vX.Y.Z" +git push origin develop +``` + +Without this the branches diverge and the next release PR carries stale history. +See `6051d7c` after v1.0.2 and `af6ffd9` after v1.1.0. + +### 6. Verify + +```bash +git diff origin/master origin/develop --stat # must be empty +git log --graph --oneline --all -8 +gh release list | head -3 +``` + +## Permission prompts + +`gh pr merge` and `git checkout master` have been blocked by the auto-mode permission +classifier mid-release. That leaves the release half-finished — bumped and PR'd but not +tagged. If it happens, say plainly which step is blocked and print the remaining commands +rather than probing for a way around it; the user can run them with `!` or add a Bash +permission rule. diff --git a/.env.example b/.env.example index 582d92b..97afb35 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,7 @@ DB_CONNECTION=sqlite # DB_PASSWORD= SESSION_DRIVER=database -SESSION_LIFETIME=120 +SESSION_LIFETIME=43200 SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null @@ -67,3 +67,5 @@ VITE_APP_NAME="${APP_NAME}" VITE_LIVE_URL=ws://localhost:8787 LIVE_SECRET= + +GITHUB_REPO=N7z/vortex-studio diff --git a/app/Http/Controllers/AboutController.php b/app/Http/Controllers/AboutController.php new file mode 100644 index 0000000..1e600a3 --- /dev/null +++ b/app/Http/Controllers/AboutController.php @@ -0,0 +1,65 @@ +json([ + 'deployed_at' => $deployed, + 'deploy_uptime' => $deployed ? time() - $deployed : null, + ]); + } + + public function contributors() + { + $repo = config('services.github.repo'); + + if (! $repo) { + return response()->json(['contributors' => []]); + } + + return response()->json([ + 'contributors' => Cached::remember("about_contributors_$repo", self::CONTRIBUTOR_TTL, function () use ($repo) { + try { + $r = Http::timeout(5) + ->withHeaders(['Accept' => 'application/vnd.github+json']) + ->get("https://api.github.com/repos/$repo/contributors", ['per_page' => 30]); + + if (! $r->successful()) { + return []; + } + + return collect($r->json()) + ->filter(fn ($c) => ($c['type'] ?? '') === 'User') + ->map(fn ($c) => [ + 'name' => $c['login'], + 'avatar' => $c['avatar_url'] ?? null, + 'url' => $c['html_url'] ?? null, + 'commits' => (int) ($c['contributions'] ?? 0), + ]) + ->values() + ->all(); + } catch (\Throwable $e) { + return []; + } + }), + ]); + } + + private static function deployedAt(): ?int + { + $manifest = public_path('build/manifest.json'); + $at = is_file($manifest) ? @filemtime($manifest) : false; + + return $at === false ? null : $at; + } +} diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 8bdb49d..113c441 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -85,7 +85,7 @@ public function register(Request $request) 'password' => $data['password'], ]); - return $this->signIn($request, $user); + return $this->signIn($request, $user, true); } public function login(Request $request) diff --git a/config/services.php b/config/services.php index 2e91d39..ba13ff6 100644 --- a/config/services.php +++ b/config/services.php @@ -22,6 +22,10 @@ 'key' => env('RESEND_API_KEY'), ], + 'github' => [ + 'repo' => env('GITHUB_REPO', 'N7z/vortex-studio'), + ], + // Shared with live-editing-server, which has no other way to know who a member is. 'live' => [ 'secret' => env('LIVE_SECRET'), diff --git a/package-lock.json b/package-lock.json index 4bbf4df..7578b9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "studio", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "studio", - "version": "1.1.0", + "version": "1.2.0", "license": "AGPL-3.0-or-later", "dependencies": { "@codemirror/autocomplete": "^6.20.3", diff --git a/package.json b/package.json index 5e5d5b9..f4c6447 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "wasmoon": "^1.16.0" }, "name": "studio", - "version": "1.1.0", + "version": "1.2.0", "description": "

\"Laravel

", "main": "vite.config.js", "directories": { diff --git a/resources/css/app.css b/resources/css/app.css index 5f9b369..4166d26 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -160,14 +160,6 @@ a.tool-btn { text-decoration: none; } } .help-close:hover { color: var(--ink); } -.about-pop { width: 300px; text-align: center; } -.about-pop .about-version { - margin: 2px 0 10px; - color: var(--dim); - font-variant-numeric: tabular-nums; -} -.about-pop a { color: var(--dim); } - .help-pop::-webkit-scrollbar { width: 8px; } .help-pop::-webkit-scrollbar-track { background: transparent; } .help-pop::-webkit-scrollbar-thumb { background: var(--line-mid); border-radius: 4px; } @@ -1044,7 +1036,8 @@ a.tool-btn { text-decoration: none; } .studio.mobile .arch-pop, .studio.mobile .team-panel, .studio.mobile .chat-panel, -.studio.mobile .stats-panel { +.studio.mobile .stats-panel, +.studio.mobile .about-panel { left: 50%; right: auto; top: 10px; @@ -1053,6 +1046,7 @@ a.tool-btn { text-decoration: none; } max-height: calc(100% - 20px); overflow-y: auto; } +.studio.mobile .about-panel { aspect-ratio: auto; } .studio.mobile .arch-head { cursor: default; } .studio.mobile .start { display: flex; flex-direction: column; } @@ -1666,6 +1660,95 @@ a.tool-btn { text-decoration: none; } .stats-panel { width: 214px; top: 46px; right: 10px; } .stats-panel .team-body { gap: 0; padding: 6px 10px 9px; } +.about-panel { + position: fixed; + top: 50%; + left: 50%; + right: auto; + transform: translate(-50%, -50%); + width: 440px; + max-width: calc(100vw - 32px); + aspect-ratio: 4 / 3; + max-height: calc(100vh - 32px); + display: flex; + flex-direction: column; + z-index: 50; +} +.about-panel .team-body { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + gap: 0; + padding: 6px 10px 9px; +} +.about-title { + font-size: 15px; + font-weight: 600; + color: var(--ink-hi); + text-align: center; + margin-top: 6px; +} +.about-version { + text-align: center; + color: var(--dim); + font-variant-numeric: tabular-nums; + margin: 2px 0 8px; +} +.about-blurb { color: var(--dim); margin-bottom: 8px; line-height: 1.5; text-align: center; } +.about-blurb a { color: var(--dim); } +.about-subject { + color: var(--dim); + margin-top: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.about-people { display: flex; flex-direction: column; gap: 6px; margin-top: 3px; } +.about-person { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + padding: 2px 4px; + margin: 0 -4px; + border-radius: 4px; + text-decoration: none; +} +a.about-person:hover { background: var(--field-alt); } +a.about-person:hover .about-name { color: var(--ink-hi); } +.about-dot { + flex: none; + width: 22px; + height: 22px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 600; + color: var(--ink-white); +} +.about-name { + flex: 1; + min-width: 0; + color: var(--ink-mid); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.about-bar { + flex: none; + width: 76px; + height: 4px; + border-radius: 2px; + background: var(--line); + overflow: hidden; +} +.about-face { object-fit: cover; border: 1px solid var(--line); } +.about-bar i { display: block; height: 100%; background: var(--accent); } +.about-count { flex: none; color: var(--dim); font-variant-numeric: tabular-nums; } + .stat-group { font-size: 10px; font-weight: 600; diff --git a/resources/js/studio/AboutWindow.jsx b/resources/js/studio/AboutWindow.jsx new file mode 100644 index 0000000..3546028 --- /dev/null +++ b/resources/js/studio/AboutWindow.jsx @@ -0,0 +1,159 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import useDraggable from './useDraggable'; +import { loadAbout, loadContributors } from './api'; +import { APP_VERSION, BUILD_INFO } from './version'; + +const n = (v) => (v ?? 0).toLocaleString(); + +function dur(s) { + if (s == null) return null; + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + if (d) return `${d} d ${h} h`; + if (h) return `${h} h ${m} min`; + if (m) return `${m} min`; + + return `${Math.floor(s)} s`; +} + +function ago(ts) { + if (!ts) return null; + const s = Math.floor(Date.now() / 1000 - ts); + + return s < 60 ? 'just now' : `${dur(s)} ago`; +} + +const full = (ts) => (ts ? new Date(ts * 1000).toLocaleString() : undefined); + +function initials(name) { + const parts = name.split(/[\s_-]+/).filter(Boolean); + + return ((parts[0]?.[0] ?? '') + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase(); +} + +function hue(name) { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360; + + return h; +} + +function Row({ label, value, hint }) { + if (value == null || value === '') return null; + + return ( +
+ {label} + {value} +
+ ); +} + +function Face({ person }) { + const [broken, setBroken] = useState(false); + const tint = { background: `hsl(${hue(person.name)} 55% 42%)` }; + + if (!person.avatar || broken) { + return {initials(person.name)}; + } + + return ( + setBroken(true)} + /> + ); +} + +function Person({ person, top }) { + const body = ( + <> + + {person.name} + + + + {n(person.commits)} + + ); + const title = `${n(person.commits)} commits`; + + if (!person.url) return
{body}
; + + return ( + + {body} + + ); +} + +export default function AboutWindow({ onClose }) { + const { style, onPointerDown } = useDraggable('about'); + const [server, setServer] = useState(null); + const [people, setPeople] = useState(null); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + let live = true; + loadAbout().then((about) => live && setServer({ ...about, at: Date.now() })).catch(() => {}); + loadContributors().then((list) => live && setPeople(list)).catch(() => live && setPeople([])); + + return () => { live = false; }; + }, []); + + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000); + + return () => clearInterval(t); + }, []); + + const commitAt = useMemo(() => { + const iso = BUILD_INFO.commitAt; + + return iso ? Math.floor(new Date(iso).getTime() / 1000) : null; + }, []); + + const uptime = server?.deploy_uptime == null ? null : server.deploy_uptime + Math.floor((now - server.at) / 1000); + const contributors = people?.length ? people : (BUILD_INFO.contributors ?? []); + const top = contributors[0]?.commits ?? 1; + const placed = style ? { ...style, transform: 'none' } : undefined; + + return ( +
+
+ About + +
+
+

Paulin Studio

+

+ {BUILD_INFO.commit ? `Version ${APP_VERSION} · ${BUILD_INFO.commit}` : `Version ${APP_VERSION}`} +

+

+ An independent fan-made map editor that runs in your browser. Not + affiliated with playvortex.io. +

+ + + + +
+ {contributors.length ? `Contributors (${contributors.length})` : 'Contributors'} +
+ {!contributors.length ? ( + {people == null ? 'Loading...' : 'Not available.'} + ) : ( +
+ {contributors.map((c) => )} +
+ )} +
+
+ ); +} diff --git a/resources/js/studio/MenuBar.jsx b/resources/js/studio/MenuBar.jsx index 738db88..020b739 100644 --- a/resources/js/studio/MenuBar.jsx +++ b/resources/js/studio/MenuBar.jsx @@ -1,10 +1,10 @@ import { Maximize, Minimize } from 'lucide-react'; import React, { useEffect, useRef, useState } from 'react'; +import AboutWindow from './AboutWindow'; import { loadStats } from './api'; import useFullscreen, { canFullscreen } from './useFullscreen'; import { MODES, PRESETS, SCALES, SHADOW_RES, presetName } from './graphics'; import { THEMES, setTheme, useTheme } from './theme'; -import { APP_VERSION } from './version'; const SEP = { sep: true }; @@ -363,20 +363,7 @@ export default function MenuBar({ )} - {pop === 'about' && ( - <> -
setPop(null)} /> -
- setPop(null)} /> -

Paulin Studio

-

Version {APP_VERSION}

-

- An independent fan-made map editor that runs in your browser. Not - affiliated with playvortex.io. -

-
- - )} + {pop === 'about' && setPop(null)} />}
); } diff --git a/resources/js/studio/api.js b/resources/js/studio/api.js index 9933504..634e40a 100644 --- a/resources/js/studio/api.js +++ b/resources/js/studio/api.js @@ -25,7 +25,7 @@ export const liveToken = (map = null, team = null) => { .then((d) => d.token ?? null).catch(() => null); }; export const register = (fields) => account('/register', fields); -export const login = (fields) => account('/login', fields); +export const login = (fields) => account('/login', { ...fields, remember: true }); export const logout = () => account('/logout', {}); export async function listMaps() { @@ -223,3 +223,15 @@ export async function loadStats() { if (!r.ok) throw new Error('failed to load stats'); return r.json(); } + +export async function loadAbout() { + const r = await fetch('/api/about'); + if (!r.ok) throw new Error('failed to load about'); + return r.json(); +} + +export async function loadContributors() { + const r = await fetch('/api/about/contributors'); + if (!r.ok) throw new Error('failed to load contributors'); + return (await r.json()).contributors ?? []; +} diff --git a/resources/js/studio/version.js b/resources/js/studio/version.js index 0beee94..c6f2135 100644 --- a/resources/js/studio/version.js +++ b/resources/js/studio/version.js @@ -1,6 +1,10 @@ // vite fills this in from package.json, so a test importing this file has nothing export const APP_VERSION = typeof __APP_VERSION__ === 'undefined' ? 'dev' : __APP_VERSION__; +const NO_BUILD = { commit: '', commitAt: null, subject: '', contributors: [], builtAt: null }; + +export const BUILD_INFO = typeof __BUILD_INFO__ === 'undefined' ? NO_BUILD : __BUILD_INFO__; + const POLL_MS = 5 * 60_000; const MIN_GAP_MS = 60_000; diff --git a/routes/web.php b/routes/web.php index 12a05cd..ae536ba 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ middleware('throttle:30,1'); +Route::get('/api/about', [AboutController::class, 'info'])->middleware('throttle:30,1'); +Route::get('/api/about/contributors', [AboutController::class, 'contributors'])->middleware('throttle:30,1'); Route::get('/api/thumbs/{key}.webp', [MapController::class, 'thumb'])->where('key', '[a-f0-9]{32}'); Route::middleware('throttle:60,1')->group(function () { diff --git a/tests/Feature/AccountTest.php b/tests/Feature/AccountTest.php index f195e25..2f54f29 100644 --- a/tests/Feature/AccountTest.php +++ b/tests/Feature/AccountTest.php @@ -3,6 +3,7 @@ use App\Http\Controllers\MapController; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; uses(RefreshDatabase::class); @@ -44,6 +45,18 @@ function account(array $over = []): array $this->postJson('/account/login', ['email' => 'p@example.com', 'password' => 'correct horse'])->assertOk(); }); +it('issues a remember cookie so a sign-in outlives the session', function () { + $this->postJson('/account/register', account()) + ->assertOk()->assertCookie(Auth::guard('web')->getRecallerName()); + + $this->postJson('/account/logout'); + + $this->postJson('/account/login', ['email' => account()['email'], 'password' => account()['password'], 'remember' => true]) + ->assertOk()->assertCookie(Auth::guard('web')->getRecallerName()); + + expect(User::where('email', account()['email'])->value('remember_token'))->not->toBeNull(); +}); + it('issues a live token only to a signed-in user with a secret configured', function () { config(['services.live.secret' => 'shared-with-laravel']); diff --git a/vite.config.js b/vite.config.js index 3782aba..dcf993c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,13 +2,43 @@ import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import react from '@vitejs/plugin-react'; import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); +const git = (...args) => { + try { + return execFileSync('git', args, { + cwd: import.meta.dirname, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +}; + +const [commit = '', commitAt = '', subject = ''] = git('log', '-1', '--format=%h%n%cI%n%s').split('\n'); + +const contributors = git('shortlog', '-sn', '--no-merges', 'HEAD') + .split('\n') + .map((line) => line.trim().match(/^(\d+)\s+(.+)$/)) + .filter(Boolean) + .map(([, commits, name]) => ({ name, commits: Number(commits) })); + +const buildInfo = { + commit, + commitAt: commitAt || null, + subject, + contributors, + builtAt: new Date().toISOString(), +}; + export default defineConfig({ root: import.meta.dirname, define: { __APP_VERSION__: JSON.stringify(version), + __BUILD_INFO__: JSON.stringify(buildInfo), }, plugins: [ laravel({