diff --git a/.github/workflows/build-test-push.yml b/.github/workflows/build-test-push.yml new file mode 100644 index 0000000..41e6a73 --- /dev/null +++ b/.github/workflows/build-test-push.yml @@ -0,0 +1,80 @@ +name: Build, test, and publish + +on: + push: + branches: + - main + workflow_dispatch: + +env: + IMAGE_NAME: batty1039.startdedicated.net:5000/cms-demo + +jobs: + build-and-test: + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: ui/app/package-lock.json + + - name: Install frontend dependencies + run: npm ci + working-directory: ui/app + + - name: Build frontend + run: npm run build + working-directory: ui/app + + - name: Run Go tests + run: go test ./... + + - name: Run Go vet + run: go vet ./... + + - name: Build Go backend + run: go build ./cmd/server + + publish-image: + name: Build and push Docker image + needs: build-and-test + runs-on: ubuntu-latest + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Log in to private registry + uses: docker/login-action@v3 + with: + registry: batty1039.startdedicated.net:5000 + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/server b/server new file mode 100755 index 0000000..b9ce4ec Binary files /dev/null and b/server differ diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index af99154..69c6961 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -1,97 +1,19 @@ import {useEffect, useRef, useState} from 'react' import {Link, Navigate, Route, Routes, useParams} from 'react-router-dom' import { - type Badge, - type Category, type Content, type ContentUpdate, getContent, - listDemoUsers, listBadges, listCategories, listContent, updateContent } from './api' import {useAuth} from './auth' - -function Shell({children}: { children: React.ReactNode }) { - const {authenticated, loading, canSignIn, signIn, username, sudo, signOut} = useAuth() - const [sudoOpen, setSudoOpen] = useState(false) - const [helpOpen, setHelpOpen] = useState(false) - const [users, setUsers] = useState<{username: string; name: string}[]>([]) - const [user, setUser] = useState('') - const [password, setPassword] = useState('') - const [sudoError, setSudoError] = useState('') - useEffect(() => { void listDemoUsers().then(setUsers) }, []) - const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light') - useEffect(() => { - document.documentElement.dataset.theme = dark ? 'dark' : 'light' - window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light') - }, [dark]) - return
-
~/ Urpi's - backlog_linux · go · java · ideas -
{!loading && - {authenticated ? `${username ?? 'Owner'} · sudo` : 'Anonymous · read-only'}} - - {!loading && !authenticated && users.length > 0 && - } - {!loading && authenticated && users.length > 0 && - } - {!loading && !authenticated && canSignIn && - } - -
-
- {helpOpen && users.length > 0 && } - {sudoOpen &&
{ - if (event.target === event.currentTarget) setSudoOpen(false) - }}> -
{ - event.preventDefault() - setSudoError('') - void sudo(user, password).then(() => { setSudoOpen(false); setPassword('') }).catch(error => setSudoError(error instanceof Error ? error.message : 'sudo failed')) - }}> -
- urpi@backlog: ~ - -
-
-

$ sudo login

- - - {sudoError &&

{sudoError}

} - -
-
-
} - {children} -
-} - -function ContentCard({item}: { item: Content }) { - return
- {item.status} -

{item.title}

-

{item.summary}

-

- by {item.author ?? 'unknown'} - {item.publishedAt && <> · published {new Date(item.publishedAt).toLocaleDateString()}} -

- {item.badges && item.badges.length > 0 && -
{item.badges.map(badge => {badge})}
} - Read more -
-} +import {ContentCard} from './components/ContentCard' +import {ContentFilters} from './components/ContentFilters' +import {Shell} from './components/Shell' +import {type Badge, type Category} from './models' function ContentList() { const [items, setItems] = useState([]) @@ -161,41 +83,9 @@ function ContentList() {
$ cd ~/backlog && ls
-
- - - -
+ {error &&

{error}

} {!loading && !error && items.length === 0 &&

No content yet

There is nothing published diff --git a/ui/app/src/api.ts b/ui/app/src/api.ts index 5bc9f5f..2b4ff4d 100644 --- a/ui/app/src/api.ts +++ b/ui/app/src/api.ts @@ -1,18 +1,6 @@ -export type User = { - id?: string - name?: string - email?: string - picture?: string - capabilities?: Record -} - -// The backend never signs users in: an upstream SSO proxy asserts the owner's -// identity. The session endpoint only reports whether that identity is present. -export type SessionResponse = { - authenticated?: boolean - canEdit?: boolean - username?: string -} +import {type Badge, type Category, type Content, type ContentListResponse, type ContentUpdate} from './models' +import {type DemoUser, type SessionResponse} from './models' +export type {Badge, Category, Content, ContentListResponse, ContentUpdate, DemoUser, SessionResponse} const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '') export const apiConfig = { @@ -21,8 +9,6 @@ export const apiConfig = { signIn: import.meta.env.VITE_SSO_LOGIN_URL ?? '', } -export type DemoUser = { username: string; name: string } - export async function listDemoUsers(): Promise { const response = await fetch(apiUrl('/api/auth/users'), {headers: {Accept: 'application/json'}}) if (!response.ok) return [] @@ -72,31 +58,6 @@ function demoAuthHeaders(): Record { return token ? {Authorization: `Bearer ${token}`} : {} } -export type Content = { - id: string - title: string - summary: string - body: string - status: string - category?: string - badges?: string[] - createdAt?: string - updatedAt?: string - author?: string - publishedAt?: string -} - -export type ContentListResponse = { - items?: Content[] - content?: Content[] - data?: Content[] - nextCursor?: string | null - cursor?: string | null - hasMore?: boolean - offset?: number - limit?: number -} - // Raw JSON as sent by the Go API: lowercase keys, with the slug doubling as the // identifier used by the detail and update routes. type RawContent = Omit, 'id'> & { @@ -159,9 +120,6 @@ export async function listContent(cursor?: string | null, category = '', status return { ...response, items, nextCursor: items.length >= limit ? String(offset + items.length) : null } } -export type Category = { id: string; slug: string; name: string } -export type Badge = { id: string; name: string } - export async function listCategories(): Promise { const response = await contentRequest<{ items?: Array<{ id?: string | number; slug?: string; Slug?: string; name?: string; Name?: string }> }>('/categories') return (response.items ?? []).map(item => ({ id: String(item.slug ?? item.slug ?? item.id ?? ''), slug: item.slug ?? item.Slug ?? '', name: item.name ?? item.Name ?? '' })) @@ -176,8 +134,6 @@ export function getContent(id: string): Promise { return contentRequest(`/${encodeURIComponent(id)}`).then(normalizeContent) } -export type ContentUpdate = Pick - export function updateContent(id: string, content: ContentUpdate): Promise { return contentRequest(`/${encodeURIComponent(id)}`, { method: 'PUT', diff --git a/ui/app/src/components/ContentCard.tsx b/ui/app/src/components/ContentCard.tsx new file mode 100644 index 0000000..ddbc50e --- /dev/null +++ b/ui/app/src/components/ContentCard.tsx @@ -0,0 +1,15 @@ +import {Link} from 'react-router-dom' +import {type Content} from '../models' + +export function ContentCard({item}: {item: Content}) { + return

+ {item.status} +

{item.title}

+

{item.summary}

+

by {item.author ?? 'unknown'} + {item.publishedAt && <> · published {new Date(item.publishedAt).toLocaleDateString()}}

+ {item.badges && item.badges.length > 0 && +
{item.badges.map(badge => {badge})}
} + Read more +
+} diff --git a/ui/app/src/components/ContentFilters.tsx b/ui/app/src/components/ContentFilters.tsx new file mode 100644 index 0000000..f21cf74 --- /dev/null +++ b/ui/app/src/components/ContentFilters.tsx @@ -0,0 +1,48 @@ +import {type Badge, type Category} from '../models' + +type Props = { + categories: Category[] + badgeOptions: Badge[] + category: string + status: string + badges: string[] + badgeInput: string + onCategoryChange: (value: string) => void + onStatusChange: (value: string) => void + onBadgesChange: (badges: string[]) => void + onBadgeInputChange: (value: string) => void +} + +export function ContentFilters(props: Props) { + const addBadge = () => { + const badge = props.badgeInput.trim().toLowerCase() + if (badge && !props.badges.includes(badge)) props.onBadgesChange([...props.badges, badge]) + props.onBadgeInputChange('') + } + return
+ + + +
+} diff --git a/ui/app/src/components/Shell.tsx b/ui/app/src/components/Shell.tsx new file mode 100644 index 0000000..3b52718 --- /dev/null +++ b/ui/app/src/components/Shell.tsx @@ -0,0 +1,50 @@ +import {useEffect, useState, type ReactNode} from 'react' +import {Link} from 'react-router-dom' +import {listDemoUsers} from '../api' +import {useAuth} from '../auth' +import {type DemoUser} from '../models' +import {SudoTerminal} from './SudoTerminal' + +export function Shell({children}: {children: ReactNode}) { + const {authenticated, loading, canSignIn, signIn, username, sudo, signOut} = useAuth() + const [sudoOpen, setSudoOpen] = useState(false) + const [helpOpen, setHelpOpen] = useState(false) + const [users, setUsers] = useState([]) + const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light') + + useEffect(() => { void listDemoUsers().then(setUsers) }, []) + useEffect(() => { + document.documentElement.dataset.theme = dark ? 'dark' : 'light' + window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light') + }, [dark]) + + return
+
+ ~/ Urpi's backlog + _linux · go · java · ideas +
+ {!loading && {authenticated ? `${username ?? 'Owner'} · sudo` : 'Anonymous · read-only'}} + + {!loading && !authenticated && users.length > 0 && + } + {!loading && authenticated && users.length > 0 && + } + {!loading && !authenticated && canSignIn && + } + +
+
+ {helpOpen && users.length > 0 && } + {sudoOpen && { + await sudo(user, password) + setSudoOpen(false) + }} onClose={() => setSudoOpen(false)}/>} + {children} +
+} diff --git a/ui/app/src/components/SudoTerminal.tsx b/ui/app/src/components/SudoTerminal.tsx new file mode 100644 index 0000000..d2f608c --- /dev/null +++ b/ui/app/src/components/SudoTerminal.tsx @@ -0,0 +1,61 @@ +import {useRef, useState, type FormEvent, type KeyboardEvent} from 'react' +import {type DemoUser} from '../models' + +type Props = { + users: DemoUser[] + onLogin: (username: string, password: string) => Promise + onClose: () => void +} + +export function SudoTerminal({users, onLogin, onClose}: Props) { + const [user, setUser] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const form = useRef(null) + const passwordInput = useRef(null) + + async function submit(event: FormEvent) { + event.preventDefault() + setError('') + try { + await onLogin(user, password) + setPassword('') + } catch (err) { + setError(err instanceof Error ? err.message : 'sudo failed') + } + + } + + function handleUserKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter') { + event.preventDefault() + passwordInput.current?.focus() + } + } + + function handlePasswordKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter') { + event.preventDefault() + form.current?.requestSubmit() + } + } + + return
{ + if (event.target === event.currentTarget) onClose() + }}> +
+
+ urpi@backlog: ~ + +
+
+ + + {error &&

{error}

} +
+
+
+} diff --git a/ui/app/src/models/auth.ts b/ui/app/src/models/auth.ts new file mode 100644 index 0000000..be49c3c --- /dev/null +++ b/ui/app/src/models/auth.ts @@ -0,0 +1,15 @@ +export type User = { + id?: string + name?: string + email?: string + picture?: string + capabilities?: Record +} + +export type SessionResponse = { + authenticated?: boolean + canEdit?: boolean + username?: string +} + +export type DemoUser = { username: string; name: string } diff --git a/ui/app/src/models/content.ts b/ui/app/src/models/content.ts new file mode 100644 index 0000000..b80c265 --- /dev/null +++ b/ui/app/src/models/content.ts @@ -0,0 +1,29 @@ +export type Content = { + id: string + title: string + summary: string + body: string + status: string + category?: string + badges?: string[] + createdAt?: string + updatedAt?: string + author?: string + publishedAt?: string +} + +export type ContentUpdate = Pick + +export type ContentListResponse = { + items?: Content[] + content?: Content[] + data?: Content[] + nextCursor?: string | null + cursor?: string | null + hasMore?: boolean + offset?: number + limit?: number +} + +export type Category = { id: string; slug: string; name: string } +export type Badge = { id: string; name: string } diff --git a/ui/app/src/models/index.ts b/ui/app/src/models/index.ts new file mode 100644 index 0000000..b83f6e6 --- /dev/null +++ b/ui/app/src/models/index.ts @@ -0,0 +1,2 @@ +export type {Badge, Category, Content, ContentListResponse, ContentUpdate} from './content' +export type {DemoUser, SessionResponse, User} from './auth'