all: Aggiunte varie feature e riscritte svariate cose

pull/1/head
parent bd055f484d
commit 8fdef2981d

@ -3,12 +3,13 @@ import { route } from 'preact-router'
import { useContext, useEffect } from 'preact/hooks'
import { prependBaseUrl } from './api'
import { ServerContext } from './hooks'
import { AdminPage } from './pages/Admin'
import { UserProvider } from './hooks/useCurrentUser'
import { HomePage } from './pages/Home'
import { LoginPage } from './pages/Login'
import { ProblemPage } from './pages/Problem'
import { ProfilePage } from './pages/Profile'
import { AdminPage } from './pages/AdminPage'
import { HomePage } from './pages/HomePage'
import { LoginPage } from './pages/LoginPage'
import { ProblemPage } from './pages/ProblemPage'
import { ProfilePage } from './pages/ProfilePage'
const Redirect = ({ to }: { to: string }) => {
useEffect(() => {
@ -26,35 +27,35 @@ export const App = ({ url }: { url?: string }) => {
// during server side rendering don't prepend the BASE_URL
const pbu = useContext(ServerContext) ? (s: string) => s : prependBaseUrl
console.log(`URL "${url}"`)
return (
<Router url={url}>
<HomePage
// @ts-ignore
path={pbu('/')}
/>
<LoginPage
// @ts-ignore
path={pbu('/login')}
/>
<ProfilePage
// @ts-ignore
path={pbu('/profile')}
/>
<ProblemPage
// @ts-ignore
path={pbu('/problem/:id')}
/>
<AdminPage
// @ts-ignore
path={pbu('/admin')}
/>
<Redirect
// @ts-ignore
default
to="/"
/>
</Router>
<UserProvider>
<Router url={url}>
<HomePage
// @ts-ignore
path={pbu('/')}
/>
<LoginPage
// @ts-ignore
path={pbu('/login')}
/>
<ProfilePage
// @ts-ignore
path={pbu('/profile')}
/>
<ProblemPage
// @ts-ignore
path={pbu('/problem/:id')}
/>
<AdminPage
// @ts-ignore
path={pbu('/admin')}
/>
<Redirect
// @ts-ignore
default
to="/"
/>
</Router>
</UserProvider>
)
}

@ -29,6 +29,14 @@ export const server = {
body: JSON.stringify(body),
})
return await res.json()
},
async delete(url: string) {
const res = await fetch(prependBaseUrl(url), {
method: 'DELETE',
credentials: 'include',
})
return await res.json()
},
}

@ -1,6 +1,7 @@
import { Link } from 'preact-router/match'
import { isAdministrator, User, UserRole } from '../../shared/model'
import { prependBaseUrl } from '../api'
import { useCurrentUser } from '../hooks/useCurrentUser'
const ROLE_LABEL: Record<UserRole, string> = {
['admin']: 'Admin',
@ -9,39 +10,42 @@ const ROLE_LABEL: Record<UserRole, string> = {
}
type Props = {
user?: User | null
noLogin?: boolean
}
export const Header = ({ user, noLogin }: Props) => (
<header>
<div class="logo">
<a href={prependBaseUrl('/')}>PHC / Problemi</a>
</div>
<nav>
{user ? (
<>
{isAdministrator(user.role) && (
export const Header = ({ noLogin }: Props) => {
const [user] = useCurrentUser()
return (
<header>
<div class="logo">
<a href={prependBaseUrl('/')}>PHC / Problemi</a>
</div>
<nav>
{user ? (
<>
{isAdministrator(user.role) && (
<div class="nav-item">
<Link activeClassName="active" href={prependBaseUrl('/admin')}>
Pannello Admin
</Link>
</div>
)}
<div class="nav-item">
<Link activeClassName="active" href={prependBaseUrl('/admin')}>
Pannello Admin
<Link activeClassName="active" href={prependBaseUrl('/profile')}>
@{user.id}
{isAdministrator(user.role) && <> ({ROLE_LABEL[user.role]})</>}
</Link>
</div>
)}
<div class="nav-item">
<Link activeClassName="active" href={prependBaseUrl('/profile')}>
@{user.id}
{isAdministrator(user.role) && <> ({ROLE_LABEL[user.role]})</>}
</Link>
</div>
</>
) : (
!noLogin && (
<div class="nav-item">
<Link href={prependBaseUrl('/login')}>Login</Link>
</div>
)
)}
</nav>
</header>
)
</>
) : (
!noLogin && (
<div class="nav-item">
<Link href={prependBaseUrl('/login')}>Login</Link>
</div>
)
)}
</nav>
</header>
)
}

@ -3,11 +3,14 @@ import { StateUpdater, useEffect, useRef } from 'preact/hooks'
import { Markdown } from './Markdown'
type Props = {
placeholder?: string
source: string
setSource: StateUpdater<string>
}
export const MarkdownEditor = ({ source, setSource }: Props) => {
export const MarkdownEditor = ({ placeholder, source, setSource }: Props) => {
placeholder ??= 'Scrivi qualcosa...'
const editorRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
@ -28,23 +31,17 @@ export const MarkdownEditor = ({ source, setSource }: Props) => {
<div class="editor">
<h1>Editor</h1>
<textarea
onInput={e =>
setSource(e.target instanceof HTMLTextAreaElement ? e.target.value : '')
}
onInput={e => setSource(e.target instanceof HTMLTextAreaElement ? e.target.value : '')}
value={source}
cols={60}
ref={editorRef}
placeholder="Scrivi una nuova soluzione..."
placeholder={placeholder}
></textarea>
</div>
<div class="preview">
<h1>Preview</h1>
<div class="preview-content">
{source.trim().length ? (
<Markdown source={source} />
) : (
<div class="placeholder">Scrivi una nuova soluzione...</div>
)}
{source.trim().length ? <Markdown source={source} /> : <div class="placeholder">{placeholder}</div>}
</div>
</div>
</div>

@ -1,18 +1,20 @@
import { Problem as ProblemModel } from '../../shared/model'
import { prependBaseUrl } from '../api'
import { Markdown } from './Markdown'
type Props = {
id: string
title: string
content: string
solutionsCount?: number
}
export const Problem = ({ id, content, solutionsCount }: Props) => {
export const Problem = ({ id, title, content, solutionsCount }: Props) => {
return (
<div class="problem">
<div class="problem-header">
<div class="problem-title">
<a href={prependBaseUrl(`/problem/${id}`)}>Problema {id}</a>
<a href={prependBaseUrl(`/problem/${id}`)}>{title}</a>
</div>
</div>
<div class="problem-content">

@ -1,21 +1,14 @@
import { useState } from 'preact/hooks'
import { JSX } from 'preact/jsx-runtime'
import {
MetadataProps,
ProblemId,
Solution as SolutionModel,
SolutionId,
SolutionStatus,
UserId,
} from '../../shared/model'
import { ProblemId, Solution as SolutionModel, SolutionId, SolutionStatus, UserId } from '../../shared/model'
import { prependBaseUrl, server } from '../api'
import { useCurrentUser } from '../hooks/useCurrentUser'
import { Markdown } from './Markdown'
import { Select } from './Select'
const STATUS_SELECT_OPTIONS: Record<SolutionStatus, JSX.Element> = {
['pending']: <div class="pending">Soluzione in attesa di correzione</div>,
['correct']: <div class="correct">Soluzione corretta</div>,
['wrong']: <div class="wrong">Soluzione sbagliata</div>,
['pending']: <div class="pending">In attesa di correzione</div>,
['correct']: <div class="correct">Corretta</div>,
['wrong']: <div class="wrong">Sbagliata</div>,
}
type Props = {
@ -43,6 +36,8 @@ export const Solution = ({
setSolution,
refreshSolution,
}: Props) => {
const [user] = useCurrentUser()
const markAsCorrect = async () => {
setSolution?.(prevSolution => ({ ...prevSolution, status: 'correct' }))
@ -73,6 +68,13 @@ export const Solution = ({
refreshSolution?.()
}
const deleteSolution = async () => {
if (confirm('Sei proprio sicuro di voler eliminare questa soluzione?')) {
await server.delete(`/api/solution/${id}`)
refreshSolution?.()
}
}
const [viewRaw, setViewRaw] = useState<boolean>(false)
const toggleViewRaw = () => {
@ -85,7 +87,9 @@ export const Solution = ({
<div class={['solution', status].join(' ')}>
<div class="solution-header">
<div>
Soluzione
<span title={id} class="dotted">
Soluzione
</span>
{sentBy && (
<>
{' '}
@ -95,18 +99,15 @@ export const Solution = ({
{forProblem && (
<>
{' '}
per il{' '}
<a href={prependBaseUrl(`/problem/${forProblem}`)}>Problema {forProblem}</a>
per il <a href={prependBaseUrl(`/problem/${forProblem}`)}>Problema {forProblem}</a>
</>
)}
{!isNaN(d as any) && (
<>
{' del '}
<span title={!isNaN(d as any) ? d.toISOString() : undefined} class="dotted">
{d.getFullYear()}/{d.getMonth().toString().padStart(2, '0')}/
{d.getDate().toString().padStart(2, '0')}{' '}
{d.getHours().toString().padStart(2, '0')}:
{d.getMinutes().toString().padStart(2, '0')}
{d.getFullYear()}/{d.getMonth().toString().padStart(2, '0')}/{d.getDate().toString().padStart(2, '0')}{' '}
{d.getHours().toString().padStart(2, '0')}:{d.getMinutes().toString().padStart(2, '0')}
</span>
</>
)}
@ -127,29 +128,37 @@ export const Solution = ({
<div class="status-label">{STATUS_SELECT_OPTIONS[status]}</div>
{adminControls && (
<>
<button disabled={status === 'correct'} class="icon" onClick={markAsCorrect}>
<button title="Segna come corretta" disabled={status === 'correct'} class="icon" onClick={markAsCorrect}>
<span class="material-symbols-outlined correct">check_circle</span>
</button>
<button disabled={status === 'wrong'} class="icon" onClick={markAsWrong}>
<button title="Segna come sbagliata" disabled={status === 'wrong'} class="icon" onClick={markAsWrong}>
<span class="material-symbols-outlined wrong">cancel</span>
</button>
{status !== 'pending' && (
<button class="icon" onClick={changeVisibility}>
<span class="material-symbols-outlined">
{visible ? 'visibility' : 'visibility_off'}
</span>
<button
title={visible ? 'Nascondi al pubblico' : 'Rendi visibile al pubblico'}
class="icon"
onClick={changeVisibility}
>
<span class="material-symbols-outlined">{visible ? 'visibility' : 'visibility_off'}</span>
</button>
)}
</>
)}
{user && (user.role === 'admin' || sentBy === user.id) && (
<>
<button class="icon" title="Elimina questa soluzione" onClick={() => deleteSolution()}>
<span class="material-symbols-outlined wrong">delete</span>
</button>
</>
)}
<div class="vr"></div>
<button
class="icon"
onClick={toggleViewRaw}
title={!viewRaw ? 'Mostra markdown grezzo' : 'Mostra testo matematicoso'}
>
<span class="material-symbols-outlined">
{!viewRaw ? 'data_object' : 'functions'}
</span>
<span class="material-symbols-outlined">{!viewRaw ? 'data_object' : 'functions'}</span>
</button>
</div>
</div>

@ -14,26 +14,6 @@ export const MetadataContext = createContext<Metadata>({})
export const ServerContext = createContext<boolean>(false)
export const ClientContext = createContext<boolean>(false)
type CurrentUserHook = (onLoaded?: (user: User | null) => void) => [User | null, () => Promise<void>]
export const useCurrentUser: CurrentUserHook = onLoaded => {
const [user, setUser] = useState(null)
const logout = async () => {
await server.post('/api/logout')
setUser(null)
}
useEffect(() => {
server.get('/api/current-user').then(user => {
setUser(user)
onLoaded?.(user)
})
}, [])
return [user, logout]
}
type RefreshFunction = () => AbortController
type HeuristicStateUpdater<S> = StateUpdater<S>

@ -0,0 +1,78 @@
import { ComponentChildren, createContext, JSX } from 'preact'
import { StateUpdater, useContext, useEffect, useState } from 'preact/hooks'
import { User } from '../../shared/model'
import { prependBaseUrl, server } from '../api'
type UserContextValue = {
ready: boolean
user: User | null
setUser: StateUpdater<User | null>
refreshUser: () => Promise<User | null>
}
const UserContext = createContext<UserContextValue | null>(null)
type CurrentUserHook = () => [User | null, boolean]
export const useCurrentUser: CurrentUserHook = () => {
const userContext = useContext(UserContext)
if (!userContext) {
return [null, false]
}
const { ready, user } = userContext
return [user, ready]
}
type UserFunctionsHook = () => {
login: (username: string) => Promise<void>
logout: () => Promise<void>
}
export const useUserFunctions: UserFunctionsHook = () => {
const login = async (username: string) => {
await fetch(prependBaseUrl(`/api/login`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: username,
}),
})
await refreshUser()
}
const userContext = useContext(UserContext)
if (!userContext) {
return { login, logout: async () => {} }
}
const { setUser, refreshUser } = userContext
const logout = async () => {
await server.post('/api/logout')
setUser(null)
}
return { login, logout }
}
export const UserProvider = ({ children }: { children: ComponentChildren }): JSX.Element => {
const [ready, setReady] = useState<boolean>(false)
const [user, setUser] = useState<User | null>(null)
const refreshUser = async () => {
const user = await server.get('/api/current-user')
setUser(user)
setReady(true)
return user
}
// Fetch user when first mounted
useEffect(() => {
refreshUser()
}, [])
return <UserContext.Provider value={{ ready, user, setUser, refreshUser }}>{children}</UserContext.Provider>
}

@ -1,18 +1,30 @@
import { route } from 'preact-router'
import { useState } from 'preact/hooks'
import { isStudent, Solution as SolutionModel, SolutionId } from '../../shared/model'
import { isAdministrator, isStudent, Solution as SolutionModel, SolutionId } from '../../shared/model'
import { sortByStringKey } from '../../shared/utils'
import { prependBaseUrl, server } from '../api'
import { Header } from '../components/Header'
import { MarkdownEditor } from '../components/MarkdownEditor'
import { Select } from '../components/Select'
import { Solution } from '../components/Solution'
import { useCurrentUser, useListResource } from '../hooks'
import { useListResource } from '../hooks'
import { useCurrentUser } from '../hooks/useCurrentUser'
const CreateProblem = ({}) => {
const [source, setSource] = useState('')
type ProblemFields = {
title?: string
content: string
}
const createProblem = async () => {
const id = await server.post('/api/problem', {
if (source.trim().length === 0) {
alert('Il campo di testo è vuoto!')
return
}
const id = await server.post<ProblemFields>('/api/problem', {
content: source,
})
@ -21,7 +33,7 @@ const CreateProblem = ({}) => {
return (
<>
<MarkdownEditor {...{ source, setSource }} />
<MarkdownEditor placeholder="Scrivi un nuovo problema..." {...{ source, setSource }} />
<button onClick={createProblem}>Aggiungi Problema</button>
</>
)
@ -30,30 +42,34 @@ const CreateProblem = ({}) => {
type SortOrder = 'latest' | 'oldest'
export const AdminPage = ({}) => {
const [user] = useCurrentUser(user => {
if (!user) {
route(prependBaseUrl('/login'), true)
} else if (isStudent(user.role)) {
route(prependBaseUrl('/'), true)
}
})
const [user, ready] = useCurrentUser()
if (!ready) {
return <></>
}
if (user === null) {
route(prependBaseUrl('/login'), true)
return <></>
}
if (!isAdministrator(user.role)) {
route(prependBaseUrl('/'), true)
return <></>
}
const [solutions, refreshSolutions, setSolutionHeuristic] = useListResource<SolutionModel>(`/api/solutions`)
const [sortOrder, setSortOrder] = useState<SortOrder>('oldest')
const sortedSolutions = sortByStringKey(solutions, s => s.createdAt, sortOrder === 'oldest')
const [trackInteracted, setTrackedInteracted] = useState<Set<SolutionId>>(new Set())
const hasUntrackedPending = sortedSolutions.filter(s => s.status === 'pending' || trackInteracted.has(s.id)).length > 0
return (
user && (
<>
<Header />
<main class="page-admin">
<Header {...{ user }} />
<div class="subtitle">Nuovo problema</div>
<CreateProblem />
<hr />
<div class="subtitle">Soluzioni da correggere</div>
{hasUntrackedPending ? (
<div class="solution-list">
@ -90,6 +106,6 @@ export const AdminPage = ({}) => {
</>
)}
</main>
)
</>
)
}

@ -1,12 +1,12 @@
import { route } from 'preact-router'
import { useContext, useEffect, useState } from 'preact/hooks'
import { useContext, useState } from 'preact/hooks'
import { Problem as ProblemModel } from '../../shared/model'
import { sortByNumericKey, sortByStringKey } from '../../shared/utils'
import { Header } from '../components/Header'
import { Problem } from '../components/Problem'
import { Select } from '../components/Select'
import { useResource, useCurrentUser, MetadataContext } from '../hooks'
import { useResource, MetadataContext } from '../hooks'
import { useCurrentUser } from '../hooks/useCurrentUser'
function byTime(p: ProblemModel): string {
return p.createdAt
@ -30,7 +30,6 @@ export const HomePage = () => {
metadata.title = `PHC Problemi`
metadata.description = 'Bacheca di problemi del PHC'
const [user] = useCurrentUser()
const [problems] = useResource<(ProblemModel & { solutionsCount: number })[]>('/api/problems', [])
const [sortOrder, setSortOrder] = useState<SortOrder>('oldest')
@ -41,28 +40,30 @@ export const HomePage = () => {
: sortByNumericKey(problems, bySolvedProblems, SORT_ORDER[sortOrder])
return (
<main class="page-home">
<Header {...{ user }} />
<div class="board">
<div class="fill-row board-controls">
<div class="sort-order">
<Select
value={sortOrder}
setValue={setSortOrder}
options={{
'latest': 'Prima più recenti',
'oldest': 'Prima più antichi',
'top-solved': 'Prima più risolti',
'least-solved': 'Prima meno risolti',
}}
/>
<>
<Header />
<main class="page-home">
<div class="board">
<div class="fill-row board-controls">
<div class="sort-order">
<Select
value={sortOrder}
setValue={setSortOrder}
options={{
'latest': 'Prima più recenti',
'oldest': 'Prima più antichi',
'top-solved': 'Prima più risolti',
'least-solved': 'Prima meno risolti',
}}
/>
</div>
</div>
</div>
{sortedProblems.map(p => (
<Problem {...p} />
))}
</div>
</main>
{sortedProblems.map(p => (
<Problem {...p} />
))}
</div>
</main>
</>
)
}

@ -1,45 +0,0 @@
import { route } from 'preact-router'
import { useState } from 'preact/hooks'
import { prependBaseUrl } from '../api.js'
import { Header } from '../components/Header.jsx'
export const LoginPage = () => {
const [username, setUsername] = useState('')
const login = async () => {
// @ts-ignore
await fetch(prependBaseUrl(`/api/login`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: username,
}),
})
route(prependBaseUrl('/'))
}
return (
<main class="page-login">
<Header noLogin />
<div class="subtitle">Accedi</div>
<div class="form">
<label for="login-username">Username</label>
<input
id="login-username"
type="text"
value={username}
onInput={e => setUsername(e.target instanceof HTMLInputElement ? e.target.value : '')}
onKeyDown={e => e.key === 'Enter' && login()}
/>
<div class="fill">
<button onClick={login}>Accedi</button>
</div>
</div>
</main>
)
}

@ -0,0 +1,39 @@
import { route } from 'preact-router'
import { useState } from 'preact/hooks'
import { prependBaseUrl } from '../api.js'
import { Header } from '../components/Header.jsx'
import { useUserFunctions } from '../hooks/useCurrentUser.js'
export const LoginPage = () => {
const [username, setUsername] = useState('')
const { login } = useUserFunctions()
const handleLogin = async () => {
await login(username)
route(prependBaseUrl('/'))
}
return (
<>
<Header noLogin />
<main class="page-login">
<div class="subtitle">Accedi</div>
<div class="form">
<label for="login-username">Username</label>
<input
id="login-username"
type="text"
value={username}
onInput={e => setUsername(e.target instanceof HTMLInputElement ? e.target.value : '')}
onKeyDown={e => e.key === 'Enter' && handleLogin()}
/>
<div class="fill">
<button onClick={handleLogin}>Accedi</button>
</div>
</div>
</main>
</>
)
}

@ -1,76 +0,0 @@
import { route } from 'preact-router'
import { useContext, useState } from 'preact/hooks'
import { isAdministrator, Problem as ProblemModel, Solution as SolutionModel } from '../../shared/model'
import { prependBaseUrl, server } from '../api'
import { Header } from '../components/Header'
import { MarkdownEditor } from '../components/MarkdownEditor'
import { Problem } from '../components/Problem'
import { Solution } from '../components/Solution'
import { MetadataContext, useCurrentUser, useListResource, useResource } from '../hooks'
type RouteProps = {
id: string
}
export const ProblemPage = ({ id }: RouteProps) => {
const metadata = useContext(MetadataContext)
metadata.title = `Problem ${id}`
metadata.description = 'Bacheca di problemi del PHC'
const [user] = useCurrentUser()
const [source, setSource] = useState('')
const [problem] = useResource<ProblemModel | null>(`/api/problem/${id}`, null, problem => {
if (problem === null) {
route(prependBaseUrl('/'))
}
})
const content = problem?.content ?? ''
const [solutions, refreshSolutions, setSolutionHeuristic] = useListResource<SolutionModel>(
`/api/solutions?problem=${id}`
)
const sendSolution = async () => {
await server.post('/api/solution', {
forProblem: id,
content: source,
})
setSource('')
refreshSolutions()
}
return (
<main class="page-problem">
<Header {...{ user }} />
<div class="subtitle">Testo del problema</div>
<Problem id={id} content={content} />
{solutions.length > 0 && (
<details>
<summary>Soluzioni</summary>
<div class="solution-list">
{solutions.map((s, index) => (
<Solution
{...s}
adminControls={user !== null && isAdministrator(user.role)}
refreshSolution={refreshSolutions}
setSolution={solFn => setSolutionHeuristic(index, solFn)}
/>
))}
</div>
</details>
)}
{user && (
<>
<div class="subtitle">Invia una soluzione al problema</div>
<MarkdownEditor {...{ source, setSource }} />
<button onClick={sendSolution}>Invia Soluzione</button>
</>
)}
</main>
)
}

@ -0,0 +1,165 @@
import { route } from 'preact-router'
import { useContext, useEffect, useState } from 'preact/hooks'
import { isAdministrator, Problem as ProblemModel, Solution as SolutionModel } from '../../shared/model'
import { prependBaseUrl, server } from '../api'
import { Header } from '../components/Header'
import { MarkdownEditor } from '../components/MarkdownEditor'
import { Problem } from '../components/Problem'
import { Solution } from '../components/Solution'
import { MetadataContext, useListResource, useResource } from '../hooks'
import { useCurrentUser } from '../hooks/useCurrentUser'
type RouteProps = {
id: string
}
export const ProblemPage = ({ id }: RouteProps) => {
const metadata = useContext(MetadataContext)
metadata.title = `Problem ${id}`
metadata.description = 'Bacheca di problemi del PHC'
const [user] = useCurrentUser()
const [source, setSource] = useState('')
const [problem, refreshProblem] = useResource<ProblemModel | null>(`/api/problem/${id}`, null, problem => {
if (problem === null) {
route(prependBaseUrl('/'))
}
})
const [solutions, refreshSolutions, setSolutionHeuristic] = useListResource<SolutionModel>(`/api/solutions?problem=${id}`)
const userSolutions: [number, SolutionModel][] = solutions.flatMap((s, index) => (user && s.sentBy === user.id ? [[index, s]] : []))
const otherSolutions: [number, SolutionModel][] = solutions.flatMap((s, index) => (user && s.sentBy !== user.id ? [[index, s]] : []))
const notLoggedSolutions: [number, SolutionModel][] = user ? [] : solutions.map((s, index) => [index, s])
const sendSolution = async () => {
if (source.trim().length === 0) {
alert('La soluzione che hai inserito è vuota!')
return
}
await server.post('/api/solution', {
forProblem: id,
content: source,
})
setSource('')
refreshSolutions()
}
const [editing, setEditing] = useState<boolean>(false)
const [modifiedProblemSource, setModifiedProblemSource] = useState<string>('')
useEffect(() => {
if (problem) {
setModifiedProblemSource(problem.content)
}
}, [problem?.content])
const updateProblem = async () => {
await server.patch(`/api/problem/${id}`, {
content: modifiedProblemSource,
})
refreshProblem()
setEditing(false)
}
const deleteProblem = async () => {
if (confirm('Sei veramente sicuro di voler eliminare questo problema?')) {
await server.delete(`/api/problem/${id}`)
route(prependBaseUrl('/'))
}
}
return (
<>
<Header />
<main class="page-problem">
<div class="subtitle">Testo del problema</div>
{!editing ? (
<>
{problem && <Problem {...problem} />}
{user && isAdministrator(user.role) && (
<>
<button onClick={() => setEditing(true)}>Modifica problema</button>
<button onClick={() => deleteProblem()}>Elimina problema</button>
</>
)}
</>
) : (
<>
<MarkdownEditor
placeholder="Modifica testo del problema..."
source={modifiedProblemSource}
setSource={setModifiedProblemSource}
/>
<div class="col">
<button onClick={() => updateProblem()}>Aggiorna problema</button>
<button onClick={() => setEditing(false)}>Annulla</button>
</div>
</>
)}
{userSolutions.length > 0 && (
<details open>
<summary>Le tue soluzioni</summary>
<div class="solution-list">
{userSolutions.map(([index, s]) => (
<Solution
{...s}
adminControls={user !== null && isAdministrator(user.role)}
refreshSolution={refreshSolutions}
setSolution={solFn => setSolutionHeuristic(index, solFn)}
/>
))}
</div>
</details>
)}
{otherSolutions.length > 0 && (
<details>
<summary>Altre soluzioni</summary>
<div class="solution-list">
{otherSolutions.map(([index, s]) => (
<Solution
{...s}
adminControls={user !== null && isAdministrator(user.role)}
refreshSolution={refreshSolutions}
setSolution={solFn => setSolutionHeuristic(index, solFn)}
/>
))}
</div>
</details>
)}
{notLoggedSolutions.length > 0 && (
<details>
<summary>Soluzioni</summary>
<div class="solution-list">
{notLoggedSolutions.map(([index, s]) => (
<Solution
{...s}
adminControls={user !== null && isAdministrator(user.role)}
refreshSolution={refreshSolutions}
setSolution={solFn => setSolutionHeuristic(index, solFn)}
/>
))}
</div>
</details>
)}
{user && (
<>
<hr />
<div class="subtitle">Invia una soluzione al problema</div>
<MarkdownEditor placeholder="Scrivi una soluzione al problema..." {...{ source, setSource }} />
<button onClick={sendSolution}>Invia Soluzione</button>
<p class="icon-text">
<span class="material-symbols-outlined">warning</span>
Attenzione, una soluzione inviata non può essere modificata!
</p>
</>
)}
</main>
</>
)
}

@ -1,45 +0,0 @@
import { route } from 'preact-router'
import { useState } from 'preact/hooks'
import { isAdministrator, Solution as SolutionModel, User } from '../../shared/model'
import { prependBaseUrl, server } from '../api'
import { Header } from '../components/Header'
import { Select } from '../components/Select'
import { Solution } from '../components/Solution'
import { useCurrentUser, useResource } from '../hooks'
const SolutionList = ({ user }: { user: User }) => {
const [solutions, refresh] = useResource<SolutionModel[]>(`/api/solutions?user=${user.id}`, [])
return (
<div class="solution-list">
{solutions.map(solution => (
<Solution {...solution} adminControls={isAdministrator(user.role)} />
))}
</div>
)
}
export const ProfilePage = ({}) => {
const [user, logout] = useCurrentUser(user => {
if (!user) {
route(prependBaseUrl('/login'), true)
}
})
const handleLogout = async () => {
await logout()
route(prependBaseUrl('/'))
}
return (
user && (
<main class="page-profile">
<Header {...{ user }} />
<div class="subtitle">Profilo</div>
<button onClick={handleLogout}>Logout</button>
<div class="subtitle">Le tue soluzioni</div>
<SolutionList {...{ user }} />
</main>
)
)
}

@ -0,0 +1,45 @@
import { route } from 'preact-router'
import { isAdministrator, Solution as SolutionModel, User } from '../../shared/model'
import { sortByStringKey } from '../../shared/utils'
import { prependBaseUrl } from '../api'
import { Header } from '../components/Header'
import { Solution } from '../components/Solution'
import { useResource } from '../hooks'
import { useCurrentUser, useUserFunctions } from '../hooks/useCurrentUser'
export const ProfilePage = ({}) => {
const [user, ready] = useCurrentUser()
if (!ready) {
return <></>
}
if (!user) {
route(prependBaseUrl('/login'), true)
return <></>
}
const { logout } = useUserFunctions()!
const handleLogout = async () => {
await logout()
route(prependBaseUrl('/'))
}
const [solutions, refreshSolutions] = useResource<SolutionModel[]>(`/api/solutions?user=${user.id}`, [])
const sortedSolutions = sortByStringKey(solutions, s => s.createdAt, false)
return (
<>
<Header />
<main class="page-profile">
<div class="subtitle">Profilo</div>
<button onClick={handleLogout}>Logout</button>
<div class="subtitle">Le tue soluzioni</div>
<div class="solution-list">
{sortedSolutions.map(solution => (
<Solution refreshSolution={refreshSolutions} {...solution} adminControls={isAdministrator(user.role)} />
))}
</div>
</main>
</>
)
}

@ -150,7 +150,7 @@ button {
&:disabled {
cursor: default;
opacity: 0.6;
opacity: 0.4;
filter: grayscale(1);
@ -189,8 +189,30 @@ a:visited {
// Typography
//
.icon-text {
display: flex;
align-items: center;
gap: 0.5rem;
}
hr {
width: 100%;
max-width: 100ch;
height: 1px;
background: #ccc;
border: none;
margin: 0;
}
.vr {
height: 100%;
width: 1px;
background: #ccc;
}
.dotted {
text-decoration: underline dotted gray;
cursor: help;
border-bottom: 2px dotted #555;
}
.math-inline {
@ -275,8 +297,15 @@ main {
gap: 2rem;
.col {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.subtitle {
font-size: 22px;
font-size: 28px;
}
.fill-main {
@ -347,6 +376,12 @@ header {
width: 100%;
position: relative;
padding: 2rem 0;
// border-bottom: 1px solid #ddd;
box-shadow: 0 0 0.5rem 0 #00000028;
background: #fdfdfd;
.logo {
font-size: 42px;
font-family: 'Lato';
@ -360,7 +395,7 @@ header {
nav {
position: absolute;
right: 0;
right: 2rem;
display: flex;
gap: 1rem;
@ -410,6 +445,8 @@ header {
.problem-content {
@extend .text-body;
flex-grow: 1;
}
.problem-footer {
@ -504,6 +541,7 @@ header {
}
.status-label {
user-select: none;
text-align: right;
}
}
@ -599,7 +637,7 @@ header {
// On mobile
@media screen and (max-width: $device-s-width), (pointer: coarse) {
main {
padding: 1rem 1rem 6rem;
padding: 2rem 1rem 6rem;
&.page-home {
.board {
@ -614,6 +652,7 @@ header {
align-items: center;
gap: 2rem;
padding: 1rem;
nav {
position: relative;

@ -52,23 +52,14 @@ async function createDevRouter() {
const { html, metadata } = render(req.originalUrl)
process.stdout.write('[Metadata] ')
console.dir(metadata)
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
const metaTagsHtml =
'' +
(metadata.title ? `<meta property="og:title" content="${metadata.title}" />\n` : '') +
(metadata.description
? `<meta property="og:description" content="${metadata.description}" />\n`
: '') +
(metadata.description ? `<meta property="og:description" content="${metadata.description}" />\n` : '') +
`<meta property="og:url" content="${fullUrl}" />\n`
res.send(
transformedTemplate
.replace('<!-- INJECT META TAGS -->', metaTagsHtml)
.replace('<!-- SSR OUTLET -->', html)
)
res.send(transformedTemplate.replace('<!-- INJECT META TAGS -->', metaTagsHtml).replace('<!-- SSR OUTLET -->', html))
} catch (error: any) {
vite.ssrFixStacktrace(error)
next(error)
@ -95,23 +86,14 @@ async function createProductionRouter() {
const { html, metadata } = render(req.originalUrl)
process.stdout.write('[Metadata] ')
console.dir(metadata)
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
const metaTagsHtml =
'' +
(metadata.title ? `<meta property="og:title" content="${metadata.title}" />\n` : '') +
(metadata.description
? `<meta property="og:description" content="${metadata.description}" />\n`
: '') +
(metadata.description ? `<meta property="og:description" content="${metadata.description}" />\n` : '') +
`<meta property="og:url" content="${fullUrl}" />\n`
res.send(
transformedTemplate
.replace('<!-- INJECT META TAGS -->', metaTagsHtml)
.replace('<!-- SSR OUTLET -->', html)
)
res.send(transformedTemplate.replace('<!-- INJECT META TAGS -->', metaTagsHtml).replace('<!-- SSR OUTLET -->', html))
})
r.use('/', express.static('dist/entry-client'))
@ -126,7 +108,7 @@ async function createProductionRouter() {
async function main() {
const app = express()
app.use(morgan('dev'))
app.use(morgan('[Request] :method :url :status :response-time ms - :res[content-length]'))
app.use('/', await createApiRouter())
@ -137,7 +119,7 @@ async function main() {
}
app.listen(config.port, () => {
console.log(`Listening on port ${config.port}...`)
console.log(`[Server] Listening on port ${config.port}...`)
})
}

@ -2,15 +2,7 @@ import crypto from 'crypto'
import { readFile, writeFile, access, constants } from 'fs/promises'
import {
MetadataProps as MetaProps,
Problem,
ProblemId,
Solution,
SolutionId,
User,
UserId,
} from '../../shared/model'
import { MetadataProps as MetaProps, Problem, ProblemId, Solution, SolutionId, User, UserId } from '../../shared/model'
function once<T extends (...args: any) => any>(fn: T, message: string): T {
let flag = false
@ -36,23 +28,23 @@ function createMutex(): Mutex {
const unlock = () => {
if (waiters.length > 0) {
console.log(`[Mutex] Passing lock to next in queue (of size ${waiters.length})`)
console.log(`[Database/Mutex] Passing lock to next in queue (of size ${waiters.length})`)
const resolve = waiters.shift()!!
resolve(once(unlock, `lock already released`))
} else {
locked = false
console.log(`[Mutex] Releasing the lock`)
console.log(`[Database/Mutex] Releasing the lock`)
}
}
const lock = (): Promise<Lock> => {
if (locked) {
console.log(`[Mutex] Putting into queue`)
console.log(`[Database/Mutex] Putting into queue`)
return new Promise(resolve => {
waiters.push(resolve)
})
} else {
console.log(`[Mutex] Acquiring the lock`)
console.log(`[Database/Mutex] Acquiring the lock`)
locked = true
return Promise.resolve(once(unlock, `lock already released`))
}
@ -81,10 +73,7 @@ export function createDatabase(path: string, initialValue: Database) {
}
}
async function withDatabase<R>(
{ path, initialValue, mu }: DatabaseConnection,
fn: (db: Database) => R | Promise<R>
): Promise<R> {
async function withDatabase<R>({ path, initialValue, mu }: DatabaseConnection, fn: (db: Database) => R | Promise<R>): Promise<R> {
const unlock: Lock = await mu.lock()
try {
@ -125,18 +114,19 @@ export const getUser: (db: DatabaseConnection, id: string) => Promise<User | nul
// Problems
//
export const createProblem = (
db: DatabaseConnection,
{ content, createdBy }: Omit<Problem, 'id' | 'createdAt'>
): Promise<ProblemId> =>
export const createProblem = (db: DatabaseConnection, { title, content, createdBy }: Omit<Problem, MetaProps>): Promise<ProblemId> =>
withDatabase(db, state => {
const nextId = (Object.keys(state.problems).length + 1).toString() as ProblemId
const problemIds = Object.keys(state.problems)
const nextId = (problemIds.length > 0 ? String(parseInt(problemIds.at(-1)!) + 1) : '1') as ProblemId
state.problems[nextId] = {
id: nextId,
createdAt: new Date().toJSON(),
deleted: false,
title,
content,
createdBy,
createdAt: new Date().toJSON(),