all: Aggiunte varie feature e riscritte svariate cose

pull/1/head
Antonio De Lucreziis 2 years ago
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(),
}
return nextId
@ -144,12 +134,28 @@ export const createProblem = (
export const getProblem = (db: DatabaseConnection, id: string): Promise<Problem | null> =>
withDatabase(db, state => {
return state.problems[id] ?? null
const problem = (state.problems[id] ?? null) as Problem | null
return problem && !problem.deleted ? problem : null
})
export const getProblems = (db: DatabaseConnection): Promise<Problem[]> =>
withDatabase(db, state => {
return Object.values(state.problems)
return Object.values(state.problems).filter(p => !p.deleted)
})
export const updateProblem = (db: DatabaseConnection, id: ProblemId, problem: Partial<Omit<Problem, MetaProps>>): Promise<Problem> =>
withDatabase(db, state => {
state.problems[id] = {
...state.problems[id],
...problem,
}
return state.problems[id]
})
export const deleteProblem = (db: DatabaseConnection, id: ProblemId): Promise<void> =>
withDatabase(db, state => {
state.problems[id].deleted = true
})
//
@ -166,6 +172,7 @@ export const createSolution = (
state.solutions[id] = {
id,
createdAt: new Date().toISOString(),
deleted: false,
sentBy,
forProblem,
@ -179,14 +186,23 @@ export const createSolution = (
export const getSolution = (db: DatabaseConnection, id: SolutionId): Promise<Solution | null> =>
withDatabase(db, state => {
return state.solutions[id] ?? null
const solution = (state.solutions[id] ?? null) as Solution | null
return solution && !solution.deleted ? solution : null
})
export const updateSolution = (
db: DatabaseConnection,
id: SolutionId,
solution: Partial<Omit<Solution, MetaProps>>
): Promise<Solution> =>
export const getSolutions = (db: DatabaseConnection) =>
withDatabase(db, state => {
return Object.values(state.solutions).filter(s => !s.deleted)
})
export const getVisibleSolutions = (db: DatabaseConnection): Promise<Solution[]> =>
withDatabase(db, state => {
return Object.values(state.solutions)
.filter(s => !s.deleted)
.filter(s => s.visible)
})
export const updateSolution = (db: DatabaseConnection, id: SolutionId, solution: Partial<Omit<Solution, MetaProps>>): Promise<Solution> =>
withDatabase(db, state => {
state.solutions[id] = {
...state.solutions[id],
@ -196,14 +212,7 @@ export const updateSolution = (
return state.solutions[id]
})
export const getSolutions = (db: DatabaseConnection) =>
withDatabase(db, state => {
let solutions = Object.values(state.solutions)
return solutions
})
export const getVisibleSolutions = (db: DatabaseConnection) =>
export const deleteSolution = (db: DatabaseConnection, id: SolutionId): Promise<void> =>
withDatabase(db, state => {
return Object.values(state.solutions).filter(s => s.visible)
state.solutions[id].deleted = true
})

@ -52,10 +52,6 @@ interface AuthenticatedRequest extends Request {
user: User | null
}
export const authenticatedMiddleware = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) => {
export const authenticatedMiddleware = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
req.user && next()
}

@ -3,9 +3,7 @@ import crypto from 'crypto'
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import express, { Request, Response, Router } from 'express'
import { createStatusRouter } from './middlewares'
import express, { Request, Router } from 'express'
import { StatusCodes } from 'http-status-codes'
@ -13,13 +11,15 @@ import {
createDatabase,
createProblem,
createSolution,
deleteProblem,
deleteSolution,
getProblem,
getProblems,
getSolution,
getSolutions,
getUser,
getUsers,
getVisibleSolutions,
updateProblem,
updateSolution,
} from './db/database'
@ -28,10 +28,11 @@ import {
isAdministrator,
isStudent,
Opaque,
Problem,
Problem as ProblemModel,
ProblemId,
Solution as SolutionModel,
SolutionId,
User as UserModel,
UserId,
} from '../shared/model'
import { initialDatabaseValue } from './db/example-data'
@ -55,14 +56,12 @@ export async function createApiRouter() {
const db = createDatabase(process.env.DATABASE_PATH ?? './db.local.json', initialDatabaseValue)
async function getRequestUser(req: Request) {
async function getRequestUser(req: Request): Promise<UserModel | null> {
const userId = sessions.getUserForSession(req.cookies.sid)
if (!userId) {
return null
}
console.log(userId)
return await getUser(db, userId)
}
@ -71,8 +70,9 @@ export async function createApiRouter() {
r.use(bodyParser.json())
r.use(cookieParser())
r.use('/api/status', createStatusRouter())
// r.use('/api/ping', new PingRouter())
r.get('/api/status', (req, res) => {
res.json({ url: req.originalUrl, status: 'ok' })
})
r.get('/api/current-user', async (req, res) => {
res.json(await getRequestUser(req))
@ -108,7 +108,7 @@ export async function createApiRouter() {
})
r.get('/api/problems', async (req, res) => {
type ProblemWithSolutionsCount = Problem & { solutionsCount?: number }
type ProblemWithSolutionsCount = ProblemModel & { solutionsCount?: number }
const problems: ProblemWithSolutionsCount[] = await getProblems(db)
const solutions = await getSolutions(db)
@ -131,18 +131,76 @@ export async function createApiRouter() {
res.json(await getProblem(db, req.params.id))
})
r.patch('/api/problem/:id', async (req, res) => {
const id = req.params.id as ProblemId
const user = await getRequestUser(req)
// l'utente deve essere loggato
if (!user) {
res.sendStatus(StatusCodes.UNAUTHORIZED)
return
}
// l'utente deve essere un admin o un moderatore
if (!isAdministrator(user.role)) {
res.sendStatus(StatusCodes.UNAUTHORIZED)
return
}
// uno studente può modificare solo il campo "content"
if (user.role === 'moderator' && !validateObjectKeys<keyof ProblemModel>(req.body, ['content'])) {
res.status(StatusCodes.UNAUTHORIZED)
res.send(`a moderator can only modify the field "content"`)
return
}
await updateProblem(db, id, req.body)
res.send({ status: 'ok' })
})
r.delete('/api/problem/:id', async (req, res) => {
const id = req.params.id as ProblemId
const user = await getRequestUser(req)
if (!user) {
res.sendStatus(StatusCodes.UNAUTHORIZED)
return
}
if (user.role !== 'admin') {
res.status(StatusCodes.UNAUTHORIZED)
res.send(`only an admin can delete this entity`)
return
}
await deleteProblem(db, id)
res.send({ status: 'ok' })
})
r.post('/api/problem', async (req, res) => {
const user = await getRequestUser(req)
// l'utente deve essere loggato
if (!user) {
res.sendStatus(StatusCodes.UNAUTHORIZED)
return
}
// Solo un amministratore può inviare nuovi problemi
if (user.role !== 'admin' && user.role !== 'moderator') {
res.sendStatus(StatusCodes.UNAUTHORIZED)
return
}
// Il contenuto del problema deve essere non vuoto
if (req.body.content.trim().length === 0) {
res.sendStatus(StatusCodes.UNPROCESSABLE_ENTITY)
return
}
const problemNextId = (await getProblems(db)).map(p => parseInt(p.id)).reduce((acc, v) => Math.max(acc, v), 1) + 1
const id = await createProblem(db, {
title: req.body.title ?? `Problema ${problemNextId}`,
content: req.body.content,
createdBy: user.id,
})
@ -236,10 +294,7 @@ export async function createApiRouter() {
return
}
// un moderatore può modificare solo i campi "content", "visible", "status"
if (
user.role === 'moderator' &&
!validateObjectKeys<keyof SolutionModel>(req.body, ['content', 'status', 'visible'])
) {
if (user.role === 'moderator' && !validateObjectKeys<keyof SolutionModel>(req.body, ['content', 'status', 'visible'])) {
res.status(StatusCodes.UNAUTHORIZED)
res.send(`a moderator can only modify the fields "content", "visible", "status"`)
return
@ -250,6 +305,35 @@ export async function createApiRouter() {
res.json({ status: 'ok' })
})
r.delete('/api/solution/:id', async (req, res) => {
const id = req.params.id as SolutionId
const user = await getRequestUser(req)
// l'utente deve essere loggato
if (!user) {
res.sendStatus(StatusCodes.UNAUTHORIZED)
return
}
const solution = await getSolution(db, id)
// la soluzione deve esistere
if (solution === null) {
res.sendStatus(StatusCodes.NOT_FOUND)
return
}
// solo un admin può eliminare le soluzioni degli utenti
if (user.role !== 'admin' && solution.sentBy !== user.id) {
res.sendStatus(StatusCodes.UNAUTHORIZED)
return
}
await deleteSolution(db, id)
res.send({ status: 'ok' })
})
r.get('/api/user/:id', async (req, res) => {
const user = await getRequestUser(req)

@ -2,7 +2,7 @@
// Common
//
export type MetadataProps = 'id' | 'createdAt'
export type MetadataProps = 'id' | 'createdAt' | 'deleted'
export type Opaque<T, K, L extends string = 'opaque'> = T & { _: K; __: L }
export type Id<T> = Opaque<string, T, 'id'>
@ -41,7 +41,9 @@ export type ProblemId = Id<Problem>
export type Problem = {
id: ProblemId
createdAt: string
deleted: boolean
title: string
content: string
createdBy: UserId
}
@ -57,6 +59,7 @@ export type SolutionId = Id<Solution>
export type Solution = {
id: SolutionId
createdAt: string
deleted: boolean
sentBy: UserId
forProblem: ProblemId

Loading…
Cancel
Save