Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ These are the rules that must not be forgotten or looked up — they're the ones
- Reusable UI primitives live flat in `assets/js/components/` (`Button`, `Spinner`, `Select`, `DropdownMenu`, `AlertDialog`, …) — no sub-folders
- Forms use Inertia's `useForm` hook; errors come from `assign_errors(conn, changeset)` on the server
- **No raw `try/catch` for async work — wrap every Promise in `go()` from `@api3/promise-utils`** (sync work uses `goSync`). It returns `{ success, data, error }`, which forces every call site to acknowledge the failure path explicitly and prevents the "swallow the error and move on" pattern that hides real bugs. The same applies to dynamic `import()`, `fetch()`, JSON parsing, and any vendor SDK call. The only acceptable exception is at top-level error boundaries that genuinely *do* need to catch everything synchronously
- Never edit `assets/js/_pages.ts` or `assets/js/_ssr_pages.ts` — they're auto-generated
- Never edit `assets/js/_pages.ts`, `assets/js/_ssr_pages.ts`, or `assets/js/routes.ts` — they're auto-generated
- **Frontend paths come from the `routes` helper, never string literals.** `assets/js/routes.ts` is generated from the Phoenix router by `mix routes.gen` (run in `assets.build`/`assets.deploy` + the dev watcher; `mix routes.gen --check` in `precommit` fails the build on drift). Use `routes.login()`, `routes.settingsEmail()`, etc. for every internal URL on the frontend (`<Link href>`, `useForm().post(...)`, `router.visit/delete(...)`) — the TS path-builder is the frontend counterpart to the server's `~p` sigil and keeps the two in sync. Names are the camelCased path (`/settings/email/apply-change` → `settingsEmailApplyChange`, `/` → `root`); `:param` segments become typed args and every builder takes an optional query object (`routes.confirmEmail({ token })`). Adding/removing a route + rebuilding regenerates the file; never hand-edit it
- **Validation errors must redirect, not re-render.** On `{:error, changeset}` always use `conn |> assign_errors(changeset) |> redirect(to: ~p"/current-page")`. Do **not** use `put_status(:unprocessable_entity) |> render_inertia(...)` — Inertia updates the browser URL to the POST/PUT target when you re-render, which is both wrong UX and a regression risk. The Inertia plug flashes errors through the session across the redirect, so the form still shows them
- Use the `~p"/..."` sigil (verified routes) for every internal URL — in controllers, tests, and anywhere else in Elixir. Never write raw route strings; compile-time verification catches typos and broken links
- **JSON serialisation goes through `*_json.ex` view modules**, never ad-hoc serializer modules (no `*Props`, `*Serializer`, or per-controller helpers). One module per resource at `lib/elixir_react_starter_web/controllers/<resource>_json.ex` (e.g. `ElixirReactStarterWeb.PostJSON`) exposes `index/1` and `show/1` (the Phoenix 1.8 equivalents of `render_many`/`render_one`) that both delegate to a single `data/2`. Inertia callers use `MyJSON.data(record, viewer)` directly inside `assign_prop`; JSON API endpoints use `index`/`show` via `render`. This keeps the wire shape for a resource in one place so multiple callers can't drift
Expand Down
3 changes: 2 additions & 1 deletion assets/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"!**/build/",
"!**/server/static/**",
"!js/_pages.ts",
"!js/_ssr_pages.ts"
"!js/_ssr_pages.ts",
"!js/routes.ts"
],
"indentStyle": "space",
"indentWidth": 2,
Expand Down
30 changes: 30 additions & 0 deletions assets/build/watch-routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Watches the Phoenix router for changes and regenerates the typed frontend
// route table (assets/js/routes.ts) via `mix routes.gen`. Used as a Phoenix
// dev watcher so routes.ts can't go stale while the dev server is running.
//
// Router edits are rare, so shelling out to `mix` (which boots a second BEAM)
// on change is an acceptable cost — and it reuses the exact same generator
// that assets.build and precommit use, so there's only one source of truth.

const path = require('node:path');
const { execFileSync } = require('node:child_process');

const ROOT = path.join(__dirname, '..', '..');
const ROUTER = path.join(ROOT, 'lib', 'elixir_react_starter_web', 'router.ex');

function regenerate() {
try {
execFileSync('mix', ['routes.gen'], { cwd: ROOT, stdio: 'inherit' });
} catch (_) {
// errors already printed by the child process
}
}

// Initial generation so a freshly-started server is always in sync.
regenerate();

// fs.watch on the single router file is enough — every route lives there.
require('node:fs').watch(ROUTER, () => regenerate());

// Keep process alive
process.stdin.resume();
3 changes: 2 additions & 1 deletion assets/js/components/LocaleSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { router, usePage } from '@inertiajs/react';
import { Check } from 'lucide-react';
import { routes } from '../routes';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './DropdownMenu';

interface LocaleSelectorProps {
Expand All @@ -18,7 +19,7 @@ export default function LocaleSelector({ className = '' }: LocaleSelectorProps)
function handleSelect(code: string) {
if (code !== locale) {
router.put(
'/locale',
routes.locale(),
{ locale: code },
{
preserveScroll: true,
Expand Down
7 changes: 4 additions & 3 deletions assets/js/layouts/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import Link from '../components/Link';
import LocaleSelector from '../components/LocaleSelector';
import ThemeToggle from '../components/ThemeToggle';
import { routes } from '../routes';
import type { CurrentUser } from '../types';

interface AppLayoutProps {
Expand Down Expand Up @@ -47,7 +48,7 @@ export default function AppLayout({ title, children }: AppLayoutProps) {
<header className="border-b border-gray-200 dark:border-gray-800">
<div className={`${containerClass} py-3 flex items-center justify-between gap-3`}>
<div className="flex items-center gap-6 min-w-0">
<Link href="/dashboard" className="text-base font-semibold shrink-0">
<Link href={routes.dashboard()} className="text-base font-semibold shrink-0">
ElixirReactStarter
</Link>
<nav aria-label={t('common.mainNav')} className="hidden md:flex items-center gap-1">
Expand Down Expand Up @@ -138,12 +139,12 @@ function UserMenu({
<DropdownMenuSeparator className="my-1 h-px bg-gray-200 dark:bg-gray-800" />
</div>

<DropdownMenuItem onSelect={() => router.visit('/settings')}>
<DropdownMenuItem onSelect={() => router.visit(routes.settings())}>
<Settings className="w-4 h-4" aria-hidden="true" />
<span className="flex-1">{t('common.settings')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1 h-px bg-gray-200 dark:bg-gray-800" />
<DropdownMenuItem onSelect={() => router.delete('/logout')}>
<DropdownMenuItem onSelect={() => router.delete(routes.logout())}>
<LogOut className="w-4 h-4" aria-hidden="true" />
<span className="flex-1">{t('common.logout')}</span>
</DropdownMenuItem>
Expand Down
7 changes: 4 additions & 3 deletions assets/js/pages/client/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import Button from '../../components/Button';
import { inputClass } from '../../components/ui';
import AppLayout from '../../layouts/AppLayout';
import { routes } from '../../routes';

export default function Settings() {
const { t } = useTranslation();
Expand Down Expand Up @@ -48,7 +49,7 @@ function ChangeEmailSection() {
<form
onSubmit={(e) => {
e.preventDefault();
put('/settings/email', { onSuccess: () => reset() });
put(routes.settingsEmail(), { onSuccess: () => reset() });
}}
className="space-y-4"
>
Expand Down Expand Up @@ -108,7 +109,7 @@ function ChangePasswordSection() {
<form
onSubmit={(e) => {
e.preventDefault();
put('/settings/password', { onSuccess: () => reset() });
put(routes.settingsPassword(), { onSuccess: () => reset() });
}}
className="space-y-4"
>
Expand Down Expand Up @@ -194,7 +195,7 @@ function DeleteAccountSection() {
<form
onSubmit={(e) => {
e.preventDefault();
destroy('/settings/account');
destroy(routes.settingsAccount());
}}
className="mt-4 space-y-3"
>
Expand Down
5 changes: 3 additions & 2 deletions assets/js/pages/ssr/Auth/ForgotPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Button from '../../../components/Button';
import Link from '../../../components/Link';
import { inputClass } from '../../../components/ui';
import AuthLayout from '../../../layouts/AuthLayout';
import { routes } from '../../../routes';

export default function ForgotPassword() {
const { t } = useTranslation();
Expand All @@ -16,7 +17,7 @@ export default function ForgotPassword() {
<form
onSubmit={(e) => {
e.preventDefault();
post('/forgot-password');
post(routes.forgotPassword());
}}
className="space-y-4"
>
Expand All @@ -40,7 +41,7 @@ export default function ForgotPassword() {
</Button>

<p className="text-sm text-center">
<Link href="/login" className="text-primary hover:underline">
<Link href={routes.login()} className="text-primary hover:underline">
{t('auth.backToLogin')}
</Link>
</p>
Expand Down
9 changes: 5 additions & 4 deletions assets/js/pages/ssr/Auth/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Button from '../../../components/Button';
import Link from '../../../components/Link';
import { inputClass } from '../../../components/ui';
import AuthLayout from '../../../layouts/AuthLayout';
import { routes } from '../../../routes';

export default function Login() {
const { t } = useTranslation();
Expand All @@ -17,7 +18,7 @@ export default function Login() {
<form
onSubmit={(e) => {
e.preventDefault();
post('/login');
post(routes.login());
}}
className="space-y-4"
>
Expand Down Expand Up @@ -57,16 +58,16 @@ export default function Login() {
</Button>

<div className="flex justify-between text-sm">
<Link href="/register" className="text-primary hover:underline">
<Link href={routes.register()} className="text-primary hover:underline">
{t('auth.login.createAccount')}
</Link>
<Link href="/forgot-password" className="text-primary hover:underline">
<Link href={routes.forgotPassword()} className="text-primary hover:underline">
{t('auth.login.forgotPassword')}
</Link>
</div>

<p className="text-center text-sm">
<Link href="/resend-confirmation" className="text-primary hover:underline">
<Link href={routes.resendConfirmation()} className="text-primary hover:underline">
{t('auth.login.resendConfirmation')}
</Link>
</p>
Expand Down
5 changes: 3 additions & 2 deletions assets/js/pages/ssr/Auth/Register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Button from '../../../components/Button';
import Link from '../../../components/Link';
import { inputClass } from '../../../components/ui';
import AuthLayout from '../../../layouts/AuthLayout';
import { routes } from '../../../routes';

export default function Register() {
const { t } = useTranslation();
Expand All @@ -17,7 +18,7 @@ export default function Register() {
<form
onSubmit={(e) => {
e.preventDefault();
post('/register');
post(routes.register());
}}
className="space-y-4"
>
Expand Down Expand Up @@ -60,7 +61,7 @@ export default function Register() {

<p className="text-sm text-center">
{t('auth.register.alreadyHaveAccount')}{' '}
<Link href="/login" className="text-primary hover:underline">
<Link href={routes.login()} className="text-primary hover:underline">
{t('auth.register.logIn')}
</Link>
</p>
Expand Down
5 changes: 3 additions & 2 deletions assets/js/pages/ssr/Auth/ResendConfirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Button from '../../../components/Button';
import Link from '../../../components/Link';
import { inputClass } from '../../../components/ui';
import AuthLayout from '../../../layouts/AuthLayout';
import { routes } from '../../../routes';

export default function ResendConfirmation() {
const { t } = useTranslation();
Expand All @@ -16,7 +17,7 @@ export default function ResendConfirmation() {
<form
onSubmit={(e) => {
e.preventDefault();
post('/resend-confirmation');
post(routes.resendConfirmation());
}}
className="space-y-4"
>
Expand All @@ -40,7 +41,7 @@ export default function ResendConfirmation() {
</Button>

<p className="text-sm text-center">
<Link href="/login" className="text-primary hover:underline">
<Link href={routes.login()} className="text-primary hover:underline">
{t('auth.backToLogin')}
</Link>
</p>
Expand Down
3 changes: 2 additions & 1 deletion assets/js/pages/ssr/Auth/ResetPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import Button from '../../../components/Button';
import { inputClass } from '../../../components/ui';
import AuthLayout from '../../../layouts/AuthLayout';
import { routes } from '../../../routes';

interface ResetPasswordProps {
token: string;
Expand All @@ -20,7 +21,7 @@ export default function ResetPassword({ token }: ResetPasswordProps) {
<form
onSubmit={(e) => {
e.preventDefault();
post('/reset-password');
post(routes.resetPassword());
}}
className="space-y-4"
>
Expand Down
9 changes: 5 additions & 4 deletions assets/js/pages/ssr/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import Button from '../../components/Button';
import GuestTopBar from '../../components/GuestTopBar';
import Link from '../../components/Link';
import { routes } from '../../routes';
import type { CurrentUser } from '../../types';

export default function Home() {
Expand All @@ -27,26 +28,26 @@ export default function Home() {
</p>
<div className="flex items-center justify-center gap-3">
<Link
href="/dashboard"
href={routes.dashboard()}
className="inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium bg-primary text-white hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
{t('home.goToDashboard')}
</Link>
<Button variant="secondary" onClick={() => router.delete('/logout')}>
<Button variant="secondary" onClick={() => router.delete(routes.logout())}>
{t('common.logout')}
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-center gap-3 pt-2">
<Link
href="/login"
href={routes.login()}
className="inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium bg-primary text-white hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
{t('home.login')}
</Link>
<Link
href="/register"
href={routes.register()}
className="inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium border border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500"
>
{t('home.createAccount')}
Expand Down
51 changes: 51 additions & 0 deletions assets/js/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Auto-generated by `mix routes.gen` — do not edit manually.
//
// Typed path builders mirroring the browser-facing routes in
// ElixirReactStarterWeb.Router. Phoenix 1.7+ has no named route helpers
// (the ~p sigil replaced them), so what is exported is the route *table*
// ~p validates against: one builder per distinct path, with :param
// segments as required typed args and an optional query object.
//
// Regenerated on `mix assets.build` / `assets.deploy` and the dev watcher;
// `mix routes.gen --check` (in `mix precommit`) fails the build if this
// file ever drifts from the router.

type QueryValue = string | number | boolean | null | undefined;
type QueryParams = Record<string, QueryValue | QueryValue[]>;

function query(params?: QueryParams): string {
if (!params) return '';
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined) continue;
if (Array.isArray(value)) {
for (const item of value) {
if (item !== null && item !== undefined) search.append(key, String(item));
}
} else {
search.append(key, String(value));
}
}
const qs = search.toString();
return qs ? `?${qs}` : '';
}

export const routes = {
confirmEmail: (q?: QueryParams) => `/confirm-email${query(q)}`,
dashboard: (q?: QueryParams) => `/dashboard${query(q)}`,
forgotPassword: (q?: QueryParams) => `/forgot-password${query(q)}`,
locale: (q?: QueryParams) => `/locale${query(q)}`,
login: (q?: QueryParams) => `/login${query(q)}`,
logout: (q?: QueryParams) => `/logout${query(q)}`,
register: (q?: QueryParams) => `/register${query(q)}`,
resendConfirmation: (q?: QueryParams) => `/resend-confirmation${query(q)}`,
resetPassword: (q?: QueryParams) => `/reset-password${query(q)}`,
root: (q?: QueryParams) => `/${query(q)}`,
settings: (q?: QueryParams) => `/settings${query(q)}`,
settingsAccount: (q?: QueryParams) => `/settings/account${query(q)}`,
settingsEmail: (q?: QueryParams) => `/settings/email${query(q)}`,
settingsEmailApplyChange: (q?: QueryParams) => `/settings/email/apply-change${query(q)}`,
settingsPassword: (q?: QueryParams) => `/settings/password${query(q)}`,
} as const;

export type RouteName = keyof typeof routes;
6 changes: 6 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ watchers =
:elixir_react_starter,
~w(--sourcemap=inline --watch --define:process.env.NODE_ENV="development")
]},
# Two `node:` keys is intentional: the watcher key is the *command*
# to run (node), not a unique label. Phoenix iterates the full list
# (Enum.map) and gives each watcher a unique child id (make_ref), so
# both node processes start independently. One regenerates the page
# registries, the other the typed route table.
node: ["build/watch-ssr-pages.js", cd: Path.expand("../assets", __DIR__)],
node: ["build/watch-routes.js", cd: Path.expand("../assets", __DIR__)],
esbuild_ssr: {Esbuild, :install_and_run, [:elixir_react_starter_ssr, ~w(--watch)]},
tailwind: {Tailwind, :install_and_run, [:elixir_react_starter, ~w(--watch)]}
]
Expand Down
Loading
Loading