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()
+ }}>
+
+
+}
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'