git actions
This commit is contained in:
parent
6af2853faa
commit
ddd839360a
11 changed files with 310 additions and 164 deletions
80
.github/workflows/build-test-push.yml
vendored
Normal file
80
.github/workflows/build-test-push.yml
vendored
Normal file
|
|
@ -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
|
||||
BIN
server
Executable file
BIN
server
Executable file
Binary file not shown.
|
|
@ -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 <main className="shell">
|
||||
<header className="header"><Link className="brand" to="/content"><span className="brand-prompt">~/</span> Urpi's
|
||||
backlog<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
|
||||
<div className="header-actions">{!loading &&
|
||||
<span className="mode-badge">{authenticated ? `${username ?? 'Owner'} · sudo` : 'Anonymous · read-only'}</span>}
|
||||
<button className="theme-button" type="button" aria-label="Demo users" onClick={() => setHelpOpen(value => !value)}>?</button>
|
||||
{!loading && !authenticated && users.length > 0 &&
|
||||
<button className="theme-button" type="button" onClick={() => setSudoOpen(true)}>sudo</button>}
|
||||
{!loading && authenticated && users.length > 0 &&
|
||||
<button className="theme-button" type="button" onClick={signOut}>logout</button>}
|
||||
{!loading && !authenticated && canSignIn &&
|
||||
<button className="google-button session-button" type="button" onClick={signIn}><span
|
||||
aria-hidden="true">G</span> Sign in with Google</button>}
|
||||
<button className="theme-button" type="button" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`}
|
||||
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>
|
||||
</div>
|
||||
</header>
|
||||
{helpOpen && users.length > 0 && <aside className="card terminal-panel">
|
||||
<strong>sudo users</strong><p className="muted">Demo only: each password is the same as the username.</p>
|
||||
{users.map(item => <div key={item.username}><code>sudo {item.username}</code> · {item.name}</div>)}
|
||||
</aside>}
|
||||
{sudoOpen && <div className="terminal-backdrop" role="presentation" onMouseDown={event => {
|
||||
if (event.target === event.currentTarget) setSudoOpen(false)
|
||||
}}>
|
||||
<form className="terminal-window" role="dialog" aria-modal="true" aria-labelledby="sudo-title" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
setSudoError('')
|
||||
void sudo(user, password).then(() => { setSudoOpen(false); setPassword('') }).catch(error => setSudoError(error instanceof Error ? error.message : 'sudo failed'))
|
||||
}}>
|
||||
<div className="terminal-titlebar">
|
||||
<span id="sudo-title">urpi@backlog: ~</span>
|
||||
<button type="button" className="terminal-close" aria-label="Close sudo terminal"
|
||||
onClick={() => setSudoOpen(false)}>×</button>
|
||||
</div>
|
||||
<div className="terminal-body">
|
||||
<p><span className="terminal-prompt">$</span> sudo login</p>
|
||||
<label><span className="terminal-prompt">$</span> sudo <input autoFocus value={user}
|
||||
onChange={event => setUser(event.target.value)} placeholder="user" aria-label="Username"/></label>
|
||||
<label><span className="terminal-prompt">$</span> password: <input type="password" value={password}
|
||||
onChange={event => setPassword(event.target.value)} aria-label="Password"/></label>
|
||||
{sudoError && <p className="terminal-error">{sudoError}</p>}
|
||||
<button className="terminal-submit" type="submit"><span className="terminal-prompt">$</span> Enter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>}
|
||||
{children}
|
||||
</main>
|
||||
}
|
||||
|
||||
function ContentCard({item}: { item: Content }) {
|
||||
return <article className="content-card">
|
||||
<span className="status">{item.status}</span>
|
||||
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2>
|
||||
<p>{item.summary}</p>
|
||||
<p className="content-meta">
|
||||
by {item.author ?? 'unknown'}
|
||||
{item.publishedAt && <> · published {new Date(item.publishedAt).toLocaleDateString()}</>}
|
||||
</p>
|
||||
{item.badges && item.badges.length > 0 &&
|
||||
<div className="badge-list">{item.badges.map(badge => <span className="badge"
|
||||
key={badge}>{badge}</span>)}</div>}
|
||||
<Link className="read-link" to={`/content/${item.id}`}>Read more <span aria-hidden="true">→</span></Link>
|
||||
</article>
|
||||
}
|
||||
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<Content[]>([])
|
||||
|
|
@ -161,41 +83,9 @@ function ContentList() {
|
|||
<div className="page-heading">
|
||||
<div><span className="eyebrow">$ cd ~/backlog && ls</span></div>
|
||||
</div>
|
||||
<div className="filters" aria-label="Content filters">
|
||||
<label className="status-filter">Status
|
||||
<select value={status} onChange={event => setStatus(event.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="category-filter">Category
|
||||
<select value={category} onChange={event => setCategory(event.target.value)}>
|
||||
<option value="">All categories</option>
|
||||
{categories.map(item => <option value={item.slug} key={item.slug}>{item.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="badge-filter">Badges
|
||||
<div className="badge-input">
|
||||
{badges.map(badge => <span className="filter-pill" key={badge}>{badge}
|
||||
<button type="button" aria-label={`Remove ${badge}`}
|
||||
onClick={() => setBadges(current => current.filter(value => value !== badge))}>×</button></span>)}
|
||||
<input list="badge-options" value={badgeInput} placeholder="Type a badge and press Enter"
|
||||
onChange={event => setBadgeInput(event.target.value)} onKeyDown={event => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
const badge = badgeInput.trim().toLowerCase()
|
||||
if (badge && !badges.includes(badge)) setBadges(current => [...current, badge])
|
||||
setBadgeInput('')
|
||||
}}/>
|
||||
<datalist
|
||||
id="badge-options">{badgeOptions.filter(item => !badges.includes(item.name)).map(item =>
|
||||
<option value={item.name} key={item.id}>{item.name}</option>)}</datalist>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<ContentFilters categories={categories} badgeOptions={badgeOptions} category={category} status={status}
|
||||
badges={badges} badgeInput={badgeInput} onCategoryChange={setCategory} onStatusChange={setStatus}
|
||||
onBadgesChange={setBadges} onBadgeInputChange={setBadgeInput}/>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{!loading && !error && items.length === 0 &&
|
||||
<div className="card empty-state"><h2>No content yet</h2><p className="muted">There is nothing published
|
||||
|
|
|
|||
|
|
@ -1,18 +1,6 @@
|
|||
export type User = {
|
||||
id?: string
|
||||
name?: string
|
||||
email?: string
|
||||
picture?: string
|
||||
capabilities?: Record<string, boolean>
|
||||
}
|
||||
|
||||
// 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<DemoUser[]> {
|
||||
const response = await fetch(apiUrl('/api/auth/users'), {headers: {Accept: 'application/json'}})
|
||||
if (!response.ok) return []
|
||||
|
|
@ -72,31 +58,6 @@ function demoAuthHeaders(): Record<string, string> {
|
|||
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<Partial<Content>, '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<Category[]> {
|
||||
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<Content> {
|
|||
return contentRequest<RawContent>(`/${encodeURIComponent(id)}`).then(normalizeContent)
|
||||
}
|
||||
|
||||
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'>
|
||||
|
||||
export function updateContent(id: string, content: ContentUpdate): Promise<Content> {
|
||||
return contentRequest<RawContent>(`/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
|
|
|
|||
15
ui/app/src/components/ContentCard.tsx
Normal file
15
ui/app/src/components/ContentCard.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import {Link} from 'react-router-dom'
|
||||
import {type Content} from '../models'
|
||||
|
||||
export function ContentCard({item}: {item: Content}) {
|
||||
return <article className="content-card">
|
||||
<span className="status">{item.status}</span>
|
||||
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2>
|
||||
<p>{item.summary}</p>
|
||||
<p className="content-meta">by {item.author ?? 'unknown'}
|
||||
{item.publishedAt && <> · published {new Date(item.publishedAt).toLocaleDateString()}</>}</p>
|
||||
{item.badges && item.badges.length > 0 &&
|
||||
<div className="badge-list">{item.badges.map(badge => <span className="badge" key={badge}>{badge}</span>)}</div>}
|
||||
<Link className="read-link" to={`/content/${item.id}`}>Read more <span aria-hidden="true">→</span></Link>
|
||||
</article>
|
||||
}
|
||||
48
ui/app/src/components/ContentFilters.tsx
Normal file
48
ui/app/src/components/ContentFilters.tsx
Normal file
|
|
@ -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 <div className="filters" aria-label="Content filters">
|
||||
<label className="status-filter">Status
|
||||
<select value={props.status} onChange={event => props.onStatusChange(event.target.value)}>
|
||||
<option value="">All statuses</option><option value="published">Published</option>
|
||||
<option value="draft">Draft</option><option value="review">Review</option><option value="archived">Archived</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="category-filter">Category
|
||||
<select value={props.category} onChange={event => props.onCategoryChange(event.target.value)}>
|
||||
<option value="">All categories</option>
|
||||
{props.categories.map(item => <option value={item.slug} key={item.slug}>{item.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="badge-filter">Badges
|
||||
<div className="badge-input">
|
||||
{props.badges.map(badge => <span className="filter-pill" key={badge}>{badge}
|
||||
<button type="button" aria-label={`Remove ${badge}`}
|
||||
onClick={() => props.onBadgesChange(props.badges.filter(value => value !== badge))}>×</button></span>)}
|
||||
<input list="badge-options" value={props.badgeInput} placeholder="Type a badge and press Enter"
|
||||
onChange={event => props.onBadgeInputChange(event.target.value)}
|
||||
onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); addBadge() } }}/>
|
||||
<datalist id="badge-options">{props.badgeOptions.filter(item => !props.badges.includes(item.name)).map(item =>
|
||||
<option value={item.name} key={item.id}>{item.name}</option>)}</datalist>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
50
ui/app/src/components/Shell.tsx
Normal file
50
ui/app/src/components/Shell.tsx
Normal file
|
|
@ -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<DemoUser[]>([])
|
||||
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 <main className="shell">
|
||||
<header className="header">
|
||||
<Link className="brand" to="/content"><span className="brand-prompt">~/</span> Urpi's backlog
|
||||
<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
|
||||
<div className="header-actions">
|
||||
{!loading && <span className="mode-badge">{authenticated ? `${username ?? 'Owner'} · sudo` : 'Anonymous · read-only'}</span>}
|
||||
<button className="theme-button" type="button" aria-label="Demo users"
|
||||
onClick={() => setHelpOpen(value => !value)}>?</button>
|
||||
{!loading && !authenticated && users.length > 0 &&
|
||||
<button className="theme-button" type="button" onClick={() => setSudoOpen(true)}>sudo</button>}
|
||||
{!loading && authenticated && users.length > 0 &&
|
||||
<button className="theme-button" type="button" onClick={signOut}>logout</button>}
|
||||
{!loading && !authenticated && canSignIn &&
|
||||
<button className="google-button session-button" type="button" onClick={signIn}>
|
||||
<span aria-hidden="true">G</span> Sign in with Google</button>}
|
||||
<button className="theme-button" type="button" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`}
|
||||
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>
|
||||
</div>
|
||||
</header>
|
||||
{helpOpen && users.length > 0 && <aside className="card terminal-panel">
|
||||
<strong>sudo users</strong><p className="muted">Demo only: each password is the same as the username.</p>
|
||||
{users.map(item => <div key={item.username}><code>sudo {item.username}</code> · {item.name}</div>)}
|
||||
</aside>}
|
||||
{sudoOpen && <SudoTerminal users={users} onLogin={async (user, password) => {
|
||||
await sudo(user, password)
|
||||
setSudoOpen(false)
|
||||
}} onClose={() => setSudoOpen(false)}/>}
|
||||
{children}
|
||||
</main>
|
||||
}
|
||||
61
ui/app/src/components/SudoTerminal.tsx
Normal file
61
ui/app/src/components/SudoTerminal.tsx
Normal file
|
|
@ -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<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SudoTerminal({users, onLogin, onClose}: Props) {
|
||||
const [user, setUser] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const form = useRef<HTMLFormElement>(null)
|
||||
const passwordInput = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
passwordInput.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
form.current?.requestSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="terminal-backdrop" role="presentation" onMouseDown={event => {
|
||||
if (event.target === event.currentTarget) onClose()
|
||||
}}>
|
||||
<form ref={form} className="terminal-window" role="dialog" aria-modal="true" aria-labelledby="sudo-title" onSubmit={submit}>
|
||||
<div className="terminal-titlebar">
|
||||
<span id="sudo-title">urpi@backlog: ~</span>
|
||||
<button type="button" className="terminal-close" aria-label="Close sudo terminal" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="terminal-body">
|
||||
<label><span className="terminal-prompt">$</span> sudo <input autoFocus value={user}
|
||||
onChange={event => setUser(event.target.value)} onKeyDown={handleUserKeyDown}
|
||||
placeholder="user" aria-label="Username"/></label>
|
||||
<label><span className="terminal-prompt">$</span> password: <input ref={passwordInput} type="password" value={password}
|
||||
onChange={event => setPassword(event.target.value)} onKeyDown={handlePasswordKeyDown} aria-label="Password"/></label>
|
||||
{error && <p className="terminal-error">{error}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
15
ui/app/src/models/auth.ts
Normal file
15
ui/app/src/models/auth.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export type User = {
|
||||
id?: string
|
||||
name?: string
|
||||
email?: string
|
||||
picture?: string
|
||||
capabilities?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export type SessionResponse = {
|
||||
authenticated?: boolean
|
||||
canEdit?: boolean
|
||||
username?: string
|
||||
}
|
||||
|
||||
export type DemoUser = { username: string; name: string }
|
||||
29
ui/app/src/models/content.ts
Normal file
29
ui/app/src/models/content.ts
Normal file
|
|
@ -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<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'>
|
||||
|
||||
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 }
|
||||
2
ui/app/src/models/index.ts
Normal file
2
ui/app/src/models/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type {Badge, Category, Content, ContentListResponse, ContentUpdate} from './content'
|
||||
export type {DemoUser, SessionResponse, User} from './auth'
|
||||
Loading…
Add table
Add a link
Reference in a new issue