all: Aggiunte varie feature e riscritte svariate cose
This commit is contained in:
+35
-34
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+44
-5
@@ -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}...`)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+49
-40
@@ -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) =>
|
||||
export const deleteSolution = (db: DatabaseConnection, id: SolutionId): Promise<void> =>
|
||||
withDatabase(db, state => {
|
||||
let solutions = Object.values(state.solutions)
|
||||
|
||||
return solutions
|
||||
})
|
||||
|
||||
export const getVisibleSolutions = (db: DatabaseConnection) =>
|
||||
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()
|
||||
}
|
||||
|
||||
+100
-16
@@ -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.post('/api/problem', async (req, res) => {
|
||||
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)
|
||||
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user