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 { useContext, useEffect } from 'preact/hooks'
|
||||||
import { prependBaseUrl } from './api'
|
import { prependBaseUrl } from './api'
|
||||||
import { ServerContext } from './hooks'
|
import { ServerContext } from './hooks'
|
||||||
import { AdminPage } from './pages/Admin'
|
import { UserProvider } from './hooks/useCurrentUser'
|
||||||
|
|
||||||
import { HomePage } from './pages/Home'
|
import { AdminPage } from './pages/AdminPage'
|
||||||
import { LoginPage } from './pages/Login'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { ProblemPage } from './pages/Problem'
|
import { LoginPage } from './pages/LoginPage'
|
||||||
import { ProfilePage } from './pages/Profile'
|
import { ProblemPage } from './pages/ProblemPage'
|
||||||
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
|
|
||||||
const Redirect = ({ to }: { to: string }) => {
|
const Redirect = ({ to }: { to: string }) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,35 +27,35 @@ export const App = ({ url }: { url?: string }) => {
|
|||||||
// during server side rendering don't prepend the BASE_URL
|
// during server side rendering don't prepend the BASE_URL
|
||||||
const pbu = useContext(ServerContext) ? (s: string) => s : prependBaseUrl
|
const pbu = useContext(ServerContext) ? (s: string) => s : prependBaseUrl
|
||||||
|
|
||||||
console.log(`URL "${url}"`)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router url={url}>
|
<UserProvider>
|
||||||
<HomePage
|
<Router url={url}>
|
||||||
// @ts-ignore
|
<HomePage
|
||||||
path={pbu('/')}
|
// @ts-ignore
|
||||||
/>
|
path={pbu('/')}
|
||||||
<LoginPage
|
/>
|
||||||
// @ts-ignore
|
<LoginPage
|
||||||
path={pbu('/login')}
|
// @ts-ignore
|
||||||
/>
|
path={pbu('/login')}
|
||||||
<ProfilePage
|
/>
|
||||||
// @ts-ignore
|
<ProfilePage
|
||||||
path={pbu('/profile')}
|
// @ts-ignore
|
||||||
/>
|
path={pbu('/profile')}
|
||||||
<ProblemPage
|
/>
|
||||||
// @ts-ignore
|
<ProblemPage
|
||||||
path={pbu('/problem/:id')}
|
// @ts-ignore
|
||||||
/>
|
path={pbu('/problem/:id')}
|
||||||
<AdminPage
|
/>
|
||||||
// @ts-ignore
|
<AdminPage
|
||||||
path={pbu('/admin')}
|
// @ts-ignore
|
||||||
/>
|
path={pbu('/admin')}
|
||||||
<Redirect
|
/>
|
||||||
// @ts-ignore
|
<Redirect
|
||||||
default
|
// @ts-ignore
|
||||||
to="/"
|
default
|
||||||
/>
|
to="/"
|
||||||
</Router>
|
/>
|
||||||
|
</Router>
|
||||||
|
</UserProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export const server = {
|
|||||||
body: JSON.stringify(body),
|
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()
|
return await res.json()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link } from 'preact-router/match'
|
import { Link } from 'preact-router/match'
|
||||||
import { isAdministrator, User, UserRole } from '../../shared/model'
|
import { isAdministrator, User, UserRole } from '../../shared/model'
|
||||||
import { prependBaseUrl } from '../api'
|
import { prependBaseUrl } from '../api'
|
||||||
|
import { useCurrentUser } from '../hooks/useCurrentUser'
|
||||||
|
|
||||||
const ROLE_LABEL: Record<UserRole, string> = {
|
const ROLE_LABEL: Record<UserRole, string> = {
|
||||||
['admin']: 'Admin',
|
['admin']: 'Admin',
|
||||||
@@ -9,39 +10,42 @@ const ROLE_LABEL: Record<UserRole, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user?: User | null
|
|
||||||
noLogin?: boolean
|
noLogin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header = ({ user, noLogin }: Props) => (
|
export const Header = ({ noLogin }: Props) => {
|
||||||
<header>
|
const [user] = useCurrentUser()
|
||||||
<div class="logo">
|
|
||||||
<a href={prependBaseUrl('/')}>PHC / Problemi</a>
|
return (
|
||||||
</div>
|
<header>
|
||||||
<nav>
|
<div class="logo">
|
||||||
{user ? (
|
<a href={prependBaseUrl('/')}>PHC / Problemi</a>
|
||||||
<>
|
</div>
|
||||||
{isAdministrator(user.role) && (
|
<nav>
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
{isAdministrator(user.role) && (
|
||||||
|
<div class="nav-item">
|
||||||
|
<Link activeClassName="active" href={prependBaseUrl('/admin')}>
|
||||||
|
Pannello Admin
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
<Link activeClassName="active" href={prependBaseUrl('/admin')}>
|
<Link activeClassName="active" href={prependBaseUrl('/profile')}>
|
||||||
Pannello Admin
|
@{user.id}
|
||||||
|
{isAdministrator(user.role) && <> ({ROLE_LABEL[user.role]})</>}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
<div class="nav-item">
|
) : (
|
||||||
<Link activeClassName="active" href={prependBaseUrl('/profile')}>
|
!noLogin && (
|
||||||
@{user.id}
|
<div class="nav-item">
|
||||||
{isAdministrator(user.role) && <> ({ROLE_LABEL[user.role]})</>}
|
<Link href={prependBaseUrl('/login')}>Login</Link>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
)
|
||||||
</>
|
)}
|
||||||
) : (
|
</nav>
|
||||||
!noLogin && (
|
</header>
|
||||||
<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'
|
import { Markdown } from './Markdown'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
placeholder?: string
|
||||||
source: string
|
source: string
|
||||||
setSource: StateUpdater<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)
|
const editorRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,23 +31,17 @@ export const MarkdownEditor = ({ source, setSource }: Props) => {
|
|||||||
<div class="editor">
|
<div class="editor">
|
||||||
<h1>Editor</h1>
|
<h1>Editor</h1>
|
||||||
<textarea
|
<textarea
|
||||||
onInput={e =>
|
onInput={e => setSource(e.target instanceof HTMLTextAreaElement ? e.target.value : '')}
|
||||||
setSource(e.target instanceof HTMLTextAreaElement ? e.target.value : '')
|
|
||||||
}
|
|
||||||
value={source}
|
value={source}
|
||||||
cols={60}
|
cols={60}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
placeholder="Scrivi una nuova soluzione..."
|
placeholder={placeholder}
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview">
|
<div class="preview">
|
||||||
<h1>Preview</h1>
|
<h1>Preview</h1>
|
||||||
<div class="preview-content">
|
<div class="preview-content">
|
||||||
{source.trim().length ? (
|
{source.trim().length ? <Markdown source={source} /> : <div class="placeholder">{placeholder}</div>}
|
||||||
<Markdown source={source} />
|
|
||||||
) : (
|
|
||||||
<div class="placeholder">Scrivi una nuova soluzione...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
|
import { Problem as ProblemModel } from '../../shared/model'
|
||||||
import { prependBaseUrl } from '../api'
|
import { prependBaseUrl } from '../api'
|
||||||
import { Markdown } from './Markdown'
|
import { Markdown } from './Markdown'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id: string
|
id: string
|
||||||
|
title: string
|
||||||
content: string
|
content: string
|
||||||
solutionsCount?: number
|
solutionsCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Problem = ({ id, content, solutionsCount }: Props) => {
|
export const Problem = ({ id, title, content, solutionsCount }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div class="problem">
|
<div class="problem">
|
||||||
<div class="problem-header">
|
<div class="problem-header">
|
||||||
<div class="problem-title">
|
<div class="problem-title">
|
||||||
<a href={prependBaseUrl(`/problem/${id}`)}>Problema {id}</a>
|
<a href={prependBaseUrl(`/problem/${id}`)}>{title}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="problem-content">
|
<div class="problem-content">
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { useState } from 'preact/hooks'
|
import { useState } from 'preact/hooks'
|
||||||
import { JSX } from 'preact/jsx-runtime'
|
import { JSX } from 'preact/jsx-runtime'
|
||||||
import {
|
import { ProblemId, Solution as SolutionModel, SolutionId, SolutionStatus, UserId } from '../../shared/model'
|
||||||
MetadataProps,
|
|
||||||
ProblemId,
|
|
||||||
Solution as SolutionModel,
|
|
||||||
SolutionId,
|
|
||||||
SolutionStatus,
|
|
||||||
UserId,
|
|
||||||
} from '../../shared/model'
|
|
||||||
import { prependBaseUrl, server } from '../api'
|
import { prependBaseUrl, server } from '../api'
|
||||||
|
import { useCurrentUser } from '../hooks/useCurrentUser'
|
||||||
import { Markdown } from './Markdown'
|
import { Markdown } from './Markdown'
|
||||||
import { Select } from './Select'
|
|
||||||
|
|
||||||
const STATUS_SELECT_OPTIONS: Record<SolutionStatus, JSX.Element> = {
|
const STATUS_SELECT_OPTIONS: Record<SolutionStatus, JSX.Element> = {
|
||||||
['pending']: <div class="pending">Soluzione in attesa di correzione</div>,
|
['pending']: <div class="pending">In attesa di correzione</div>,
|
||||||
['correct']: <div class="correct">Soluzione corretta</div>,
|
['correct']: <div class="correct">Corretta</div>,
|
||||||
['wrong']: <div class="wrong">Soluzione sbagliata</div>,
|
['wrong']: <div class="wrong">Sbagliata</div>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -43,6 +36,8 @@ export const Solution = ({
|
|||||||
setSolution,
|
setSolution,
|
||||||
refreshSolution,
|
refreshSolution,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const [user] = useCurrentUser()
|
||||||
|
|
||||||
const markAsCorrect = async () => {
|
const markAsCorrect = async () => {
|
||||||
setSolution?.(prevSolution => ({ ...prevSolution, status: 'correct' }))
|
setSolution?.(prevSolution => ({ ...prevSolution, status: 'correct' }))
|
||||||
|
|
||||||
@@ -73,6 +68,13 @@ export const Solution = ({
|
|||||||
refreshSolution?.()
|
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 [viewRaw, setViewRaw] = useState<boolean>(false)
|
||||||
|
|
||||||
const toggleViewRaw = () => {
|
const toggleViewRaw = () => {
|
||||||
@@ -85,7 +87,9 @@ export const Solution = ({
|
|||||||
<div class={['solution', status].join(' ')}>
|
<div class={['solution', status].join(' ')}>
|
||||||
<div class="solution-header">
|
<div class="solution-header">
|
||||||
<div>
|
<div>
|
||||||
Soluzione
|
<span title={id} class="dotted">
|
||||||
|
Soluzione
|
||||||
|
</span>
|
||||||
{sentBy && (
|
{sentBy && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
@@ -95,18 +99,15 @@ export const Solution = ({
|
|||||||
{forProblem && (
|
{forProblem && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
per il{' '}
|
per il <a href={prependBaseUrl(`/problem/${forProblem}`)}>Problema {forProblem}</a>
|
||||||
<a href={prependBaseUrl(`/problem/${forProblem}`)}>Problema {forProblem}</a>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isNaN(d as any) && (
|
{!isNaN(d as any) && (
|
||||||
<>
|
<>
|
||||||
{' del '}
|
{' del '}
|
||||||
<span title={!isNaN(d as any) ? d.toISOString() : undefined} class="dotted">
|
<span title={!isNaN(d as any) ? d.toISOString() : undefined} class="dotted">
|
||||||
{d.getFullYear()}/{d.getMonth().toString().padStart(2, '0')}/
|
{d.getFullYear()}/{d.getMonth().toString().padStart(2, '0')}/{d.getDate().toString().padStart(2, '0')}{' '}
|
||||||
{d.getDate().toString().padStart(2, '0')}{' '}
|
{d.getHours().toString().padStart(2, '0')}:{d.getMinutes().toString().padStart(2, '0')}
|
||||||
{d.getHours().toString().padStart(2, '0')}:
|
|
||||||
{d.getMinutes().toString().padStart(2, '0')}
|
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -127,29 +128,37 @@ export const Solution = ({
|
|||||||
<div class="status-label">{STATUS_SELECT_OPTIONS[status]}</div>
|
<div class="status-label">{STATUS_SELECT_OPTIONS[status]}</div>
|
||||||
{adminControls && (
|
{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>
|
<span class="material-symbols-outlined correct">check_circle</span>
|
||||||
</button>
|
</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>
|
<span class="material-symbols-outlined wrong">cancel</span>
|
||||||
</button>
|
</button>
|
||||||
{status !== 'pending' && (
|
{status !== 'pending' && (
|
||||||
<button class="icon" onClick={changeVisibility}>
|
<button
|
||||||
<span class="material-symbols-outlined">
|
title={visible ? 'Nascondi al pubblico' : 'Rendi visibile al pubblico'}
|
||||||
{visible ? 'visibility' : 'visibility_off'}
|
class="icon"
|
||||||
</span>
|
onClick={changeVisibility}
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined">{visible ? 'visibility' : 'visibility_off'}</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
class="icon"
|
class="icon"
|
||||||
onClick={toggleViewRaw}
|
onClick={toggleViewRaw}
|
||||||
title={!viewRaw ? 'Mostra markdown grezzo' : 'Mostra testo matematicoso'}
|
title={!viewRaw ? 'Mostra markdown grezzo' : 'Mostra testo matematicoso'}
|
||||||
>
|
>
|
||||||
<span class="material-symbols-outlined">
|
<span class="material-symbols-outlined">{!viewRaw ? 'data_object' : 'functions'}</span>
|
||||||
{!viewRaw ? 'data_object' : 'functions'}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,26 +14,6 @@ export const MetadataContext = createContext<Metadata>({})
|
|||||||
export const ServerContext = createContext<boolean>(false)
|
export const ServerContext = createContext<boolean>(false)
|
||||||
export const ClientContext = 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 RefreshFunction = () => AbortController
|
||||||
type HeuristicStateUpdater<S> = StateUpdater<S>
|
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 { route } from 'preact-router'
|
||||||
import { useState } from 'preact/hooks'
|
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 { sortByStringKey } from '../../shared/utils'
|
||||||
import { prependBaseUrl, server } from '../api'
|
import { prependBaseUrl, server } from '../api'
|
||||||
import { Header } from '../components/Header'
|
import { Header } from '../components/Header'
|
||||||
import { MarkdownEditor } from '../components/MarkdownEditor'
|
import { MarkdownEditor } from '../components/MarkdownEditor'
|
||||||
import { Select } from '../components/Select'
|
import { Select } from '../components/Select'
|
||||||
import { Solution } from '../components/Solution'
|
import { Solution } from '../components/Solution'
|
||||||
import { useCurrentUser, useListResource } from '../hooks'
|
import { useListResource } from '../hooks'
|
||||||
|
import { useCurrentUser } from '../hooks/useCurrentUser'
|
||||||
|
|
||||||
const CreateProblem = ({}) => {
|
const CreateProblem = ({}) => {
|
||||||
const [source, setSource] = useState('')
|
const [source, setSource] = useState('')
|
||||||
|
|
||||||
|
type ProblemFields = {
|
||||||
|
title?: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
const createProblem = async () => {
|
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,
|
content: source,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -21,7 +33,7 @@ const CreateProblem = ({}) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MarkdownEditor {...{ source, setSource }} />
|
<MarkdownEditor placeholder="Scrivi un nuovo problema..." {...{ source, setSource }} />
|
||||||
<button onClick={createProblem}>Aggiungi Problema</button>
|
<button onClick={createProblem}>Aggiungi Problema</button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -30,30 +42,34 @@ const CreateProblem = ({}) => {
|
|||||||
type SortOrder = 'latest' | 'oldest'
|
type SortOrder = 'latest' | 'oldest'
|
||||||
|
|
||||||
export const AdminPage = ({}) => {
|
export const AdminPage = ({}) => {
|
||||||
const [user] = useCurrentUser(user => {
|
const [user, ready] = useCurrentUser()
|
||||||
if (!user) {
|
if (!ready) {
|
||||||
route(prependBaseUrl('/login'), true)
|
return <></>
|
||||||
} else if (isStudent(user.role)) {
|
}
|
||||||
route(prependBaseUrl('/'), true)
|
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 [solutions, refreshSolutions, setSolutionHeuristic] = useListResource<SolutionModel>(`/api/solutions`)
|
||||||
|
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('oldest')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('oldest')
|
||||||
|
|
||||||
const sortedSolutions = sortByStringKey(solutions, s => s.createdAt, sortOrder === 'oldest')
|
const sortedSolutions = sortByStringKey(solutions, s => s.createdAt, sortOrder === 'oldest')
|
||||||
|
|
||||||
const [trackInteracted, setTrackedInteracted] = useState<Set<SolutionId>>(new Set())
|
const [trackInteracted, setTrackedInteracted] = useState<Set<SolutionId>>(new Set())
|
||||||
|
|
||||||
const hasUntrackedPending = sortedSolutions.filter(s => s.status === 'pending' || trackInteracted.has(s.id)).length > 0
|
const hasUntrackedPending = sortedSolutions.filter(s => s.status === 'pending' || trackInteracted.has(s.id)).length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
user && (
|
<>
|
||||||
|
<Header />
|
||||||
<main class="page-admin">
|
<main class="page-admin">
|
||||||
<Header {...{ user }} />
|
|
||||||
<div class="subtitle">Nuovo problema</div>
|
<div class="subtitle">Nuovo problema</div>
|
||||||
<CreateProblem />
|
<CreateProblem />
|
||||||
|
<hr />
|
||||||
<div class="subtitle">Soluzioni da correggere</div>
|
<div class="subtitle">Soluzioni da correggere</div>
|
||||||
{hasUntrackedPending ? (
|
{hasUntrackedPending ? (
|
||||||
<div class="solution-list">
|
<div class="solution-list">
|
||||||
@@ -90,6 +106,6 @@ export const AdminPage = ({}) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { route } from 'preact-router'
|
import { useContext, useState } from 'preact/hooks'
|
||||||
import { useContext, useEffect, useState } from 'preact/hooks'
|
|
||||||
import { Problem as ProblemModel } from '../../shared/model'
|
import { Problem as ProblemModel } from '../../shared/model'
|
||||||
import { sortByNumericKey, sortByStringKey } from '../../shared/utils'
|
import { sortByNumericKey, sortByStringKey } from '../../shared/utils'
|
||||||
import { Header } from '../components/Header'
|
import { Header } from '../components/Header'
|
||||||
|
|
||||||
import { Problem } from '../components/Problem'
|
import { Problem } from '../components/Problem'
|
||||||
import { Select } from '../components/Select'
|
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 {
|
function byTime(p: ProblemModel): string {
|
||||||
return p.createdAt
|
return p.createdAt
|
||||||
@@ -30,7 +30,6 @@ export const HomePage = () => {
|
|||||||
metadata.title = `PHC Problemi`
|
metadata.title = `PHC Problemi`
|
||||||
metadata.description = 'Bacheca di problemi del PHC'
|
metadata.description = 'Bacheca di problemi del PHC'
|
||||||
|
|
||||||
const [user] = useCurrentUser()
|
|
||||||
const [problems] = useResource<(ProblemModel & { solutionsCount: number })[]>('/api/problems', [])
|
const [problems] = useResource<(ProblemModel & { solutionsCount: number })[]>('/api/problems', [])
|
||||||
|
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('oldest')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('oldest')
|
||||||
@@ -41,28 +40,30 @@ export const HomePage = () => {
|
|||||||
: sortByNumericKey(problems, bySolvedProblems, SORT_ORDER[sortOrder])
|
: sortByNumericKey(problems, bySolvedProblems, SORT_ORDER[sortOrder])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main class="page-home">
|
<>
|
||||||
<Header {...{ user }} />
|
<Header />
|
||||||
<div class="board">
|
<main class="page-home">
|
||||||
<div class="fill-row board-controls">
|
<div class="board">
|
||||||
<div class="sort-order">
|
<div class="fill-row board-controls">
|
||||||
<Select
|
<div class="sort-order">
|
||||||
value={sortOrder}
|
<Select
|
||||||
setValue={setSortOrder}
|
value={sortOrder}
|
||||||
options={{
|
setValue={setSortOrder}
|
||||||
'latest': 'Prima più recenti',
|
options={{
|
||||||
'oldest': 'Prima più antichi',
|
'latest': 'Prima più recenti',
|
||||||
'top-solved': 'Prima più risolti',
|
'oldest': 'Prima più antichi',
|
||||||
'least-solved': 'Prima meno risolti',
|
'top-solved': 'Prima più risolti',
|
||||||
}}
|
'least-solved': 'Prima meno risolti',
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{sortedProblems.map(p => (
|
{sortedProblems.map(p => (
|
||||||
<Problem {...p} />
|
<Problem {...p} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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 {
|
&:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.6;
|
opacity: 0.4;
|
||||||
|
|
||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
|
|
||||||
@@ -189,8 +189,30 @@ a:visited {
|
|||||||
// Typography
|
// 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 {
|
.dotted {
|
||||||
text-decoration: underline dotted gray;
|
cursor: help;
|
||||||
|
border-bottom: 2px dotted #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.math-inline {
|
.math-inline {
|
||||||
@@ -275,8 +297,15 @@ main {
|
|||||||
|
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
|
||||||
|
.col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 22px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fill-main {
|
.fill-main {
|
||||||
@@ -347,6 +376,12 @@ header {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
padding: 2rem 0;
|
||||||
|
|
||||||
|
// border-bottom: 1px solid #ddd;
|
||||||
|
box-shadow: 0 0 0.5rem 0 #00000028;
|
||||||
|
background: #fdfdfd;
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-size: 42px;
|
font-size: 42px;
|
||||||
font-family: 'Lato';
|
font-family: 'Lato';
|
||||||
@@ -360,7 +395,7 @@ header {
|
|||||||
|
|
||||||
nav {
|
nav {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 2rem;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -410,6 +445,8 @@ header {
|
|||||||
|
|
||||||
.problem-content {
|
.problem-content {
|
||||||
@extend .text-body;
|
@extend .text-body;
|
||||||
|
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.problem-footer {
|
.problem-footer {
|
||||||
@@ -504,6 +541,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-label {
|
.status-label {
|
||||||
|
user-select: none;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,7 +637,7 @@ header {
|
|||||||
// On mobile
|
// On mobile
|
||||||
@media screen and (max-width: $device-s-width), (pointer: coarse) {
|
@media screen and (max-width: $device-s-width), (pointer: coarse) {
|
||||||
main {
|
main {
|
||||||
padding: 1rem 1rem 6rem;
|
padding: 2rem 1rem 6rem;
|
||||||
|
|
||||||
&.page-home {
|
&.page-home {
|
||||||
.board {
|
.board {
|
||||||
@@ -614,6 +652,7 @@ header {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -52,23 +52,14 @@ async function createDevRouter() {
|
|||||||
|
|
||||||
const { html, metadata } = render(req.originalUrl)
|
const { html, metadata } = render(req.originalUrl)
|
||||||
|
|
||||||
process.stdout.write('[Metadata] ')
|
|
||||||
console.dir(metadata)
|
|
||||||
|
|
||||||
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
|
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
|
||||||
const metaTagsHtml =
|
const metaTagsHtml =
|
||||||
'' +
|
'' +
|
||||||
(metadata.title ? `<meta property="og:title" content="${metadata.title}" />\n` : '') +
|
(metadata.title ? `<meta property="og:title" content="${metadata.title}" />\n` : '') +
|
||||||
(metadata.description
|
(metadata.description ? `<meta property="og:description" content="${metadata.description}" />\n` : '') +
|
||||||
? `<meta property="og:description" content="${metadata.description}" />\n`
|
|
||||||
: '') +
|
|
||||||
`<meta property="og:url" content="${fullUrl}" />\n`
|
`<meta property="og:url" content="${fullUrl}" />\n`
|
||||||
|
|
||||||
res.send(
|
res.send(transformedTemplate.replace('<!-- INJECT META TAGS -->', metaTagsHtml).replace('<!-- SSR OUTLET -->', html))
|
||||||
transformedTemplate
|
|
||||||
.replace('<!-- INJECT META TAGS -->', metaTagsHtml)
|
|
||||||
.replace('<!-- SSR OUTLET -->', html)
|
|
||||||
)
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
vite.ssrFixStacktrace(error)
|
vite.ssrFixStacktrace(error)
|
||||||
next(error)
|
next(error)
|
||||||
@@ -95,23 +86,14 @@ async function createProductionRouter() {
|
|||||||
|
|
||||||
const { html, metadata } = render(req.originalUrl)
|
const { html, metadata } = render(req.originalUrl)
|
||||||
|
|
||||||
process.stdout.write('[Metadata] ')
|
|
||||||
console.dir(metadata)
|
|
||||||
|
|
||||||
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
|
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
|
||||||
const metaTagsHtml =
|
const metaTagsHtml =
|
||||||
'' +
|
'' +
|
||||||
(metadata.title ? `<meta property="og:title" content="${metadata.title}" />\n` : '') +
|
(metadata.title ? `<meta property="og:title" content="${metadata.title}" />\n` : '') +
|
||||||
(metadata.description
|
(metadata.description ? `<meta property="og:description" content="${metadata.description}" />\n` : '') +
|
||||||
? `<meta property="og:description" content="${metadata.description}" />\n`
|
|
||||||
: '') +
|
|
||||||
`<meta property="og:url" content="${fullUrl}" />\n`
|
`<meta property="og:url" content="${fullUrl}" />\n`
|
||||||
|
|
||||||
res.send(
|
res.send(transformedTemplate.replace('<!-- INJECT META TAGS -->', metaTagsHtml).replace('<!-- SSR OUTLET -->', html))
|
||||||
transformedTemplate
|
|
||||||
.replace('<!-- INJECT META TAGS -->', metaTagsHtml)
|
|
||||||
.replace('<!-- SSR OUTLET -->', html)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
r.use('/', express.static('dist/entry-client'))
|
r.use('/', express.static('dist/entry-client'))
|
||||||
@@ -126,7 +108,7 @@ async function createProductionRouter() {
|
|||||||
async function main() {
|
async function main() {
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
app.use(morgan('dev'))
|
app.use(morgan('[Request] :method :url :status :response-time ms - :res[content-length]'))
|
||||||
|
|
||||||
app.use('/', await createApiRouter())
|
app.use('/', await createApiRouter())
|
||||||
|
|
||||||
@@ -137,7 +119,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
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 { readFile, writeFile, access, constants } from 'fs/promises'
|
||||||
|
|
||||||
import {
|
import { MetadataProps as MetaProps, Problem, ProblemId, Solution, SolutionId, User, UserId } from '../../shared/model'
|
||||||
MetadataProps as MetaProps,
|
|
||||||
Problem,
|
|
||||||
ProblemId,
|
|
||||||
Solution,
|
|
||||||
SolutionId,
|
|
||||||
User,
|
|
||||||
UserId,
|
|
||||||
} from '../../shared/model'
|
|
||||||
|
|
||||||
function once<T extends (...args: any) => any>(fn: T, message: string): T {
|
function once<T extends (...args: any) => any>(fn: T, message: string): T {
|
||||||
let flag = false
|
let flag = false
|
||||||
@@ -36,23 +28,23 @@ function createMutex(): Mutex {
|
|||||||
|
|
||||||
const unlock = () => {
|
const unlock = () => {
|
||||||
if (waiters.length > 0) {
|
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()!!
|
const resolve = waiters.shift()!!
|
||||||
resolve(once(unlock, `lock already released`))
|
resolve(once(unlock, `lock already released`))
|
||||||
} else {
|
} else {
|
||||||
locked = false
|
locked = false
|
||||||
console.log(`[Mutex] Releasing the lock`)
|
console.log(`[Database/Mutex] Releasing the lock`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lock = (): Promise<Lock> => {
|
const lock = (): Promise<Lock> => {
|
||||||
if (locked) {
|
if (locked) {
|
||||||
console.log(`[Mutex] Putting into queue`)
|
console.log(`[Database/Mutex] Putting into queue`)
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
waiters.push(resolve)
|
waiters.push(resolve)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
console.log(`[Mutex] Acquiring the lock`)
|
console.log(`[Database/Mutex] Acquiring the lock`)
|
||||||
locked = true
|
locked = true
|
||||||
return Promise.resolve(once(unlock, `lock already released`))
|
return Promise.resolve(once(unlock, `lock already released`))
|
||||||
}
|
}
|
||||||
@@ -81,10 +73,7 @@ export function createDatabase(path: string, initialValue: Database) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withDatabase<R>(
|
async function withDatabase<R>({ path, initialValue, mu }: DatabaseConnection, fn: (db: Database) => R | Promise<R>): Promise<R> {
|
||||||
{ path, initialValue, mu }: DatabaseConnection,
|
|
||||||
fn: (db: Database) => R | Promise<R>
|
|
||||||
): Promise<R> {
|
|
||||||
const unlock: Lock = await mu.lock()
|
const unlock: Lock = await mu.lock()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -125,18 +114,19 @@ export const getUser: (db: DatabaseConnection, id: string) => Promise<User | nul
|
|||||||
// Problems
|
// Problems
|
||||||
//
|
//
|
||||||
|
|
||||||
export const createProblem = (
|
export const createProblem = (db: DatabaseConnection, { title, content, createdBy }: Omit<Problem, MetaProps>): Promise<ProblemId> =>
|
||||||
db: DatabaseConnection,
|
|
||||||
{ content, createdBy }: Omit<Problem, 'id' | 'createdAt'>
|
|
||||||
): Promise<ProblemId> =>
|
|
||||||
withDatabase(db, state => {
|
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] = {
|
state.problems[nextId] = {
|
||||||
id: nextId,
|
id: nextId,
|
||||||
|
createdAt: new Date().toJSON(),
|
||||||
|
deleted: false,
|
||||||
|
|
||||||
|
title,
|
||||||
content,
|
content,
|
||||||
createdBy,
|
createdBy,
|
||||||
createdAt: new Date().toJSON(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextId
|
return nextId
|
||||||
@@ -144,12 +134,28 @@ export const createProblem = (
|
|||||||
|
|
||||||
export const getProblem = (db: DatabaseConnection, id: string): Promise<Problem | null> =>
|
export const getProblem = (db: DatabaseConnection, id: string): Promise<Problem | null> =>
|
||||||
withDatabase(db, state => {
|
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[]> =>
|
export const getProblems = (db: DatabaseConnection): Promise<Problem[]> =>
|
||||||
withDatabase(db, state => {
|
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] = {
|
state.solutions[id] = {
|
||||||
id,
|
id,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
deleted: false,
|
||||||
|
|
||||||
sentBy,
|
sentBy,
|
||||||
forProblem,
|
forProblem,
|
||||||
@@ -179,14 +186,23 @@ export const createSolution = (
|
|||||||
|
|
||||||
export const getSolution = (db: DatabaseConnection, id: SolutionId): Promise<Solution | null> =>
|
export const getSolution = (db: DatabaseConnection, id: SolutionId): Promise<Solution | null> =>
|
||||||
withDatabase(db, state => {
|
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 = (
|
export const getSolutions = (db: DatabaseConnection) =>
|
||||||
db: DatabaseConnection,
|
withDatabase(db, state => {
|
||||||
id: SolutionId,
|
return Object.values(state.solutions).filter(s => !s.deleted)
|
||||||
solution: Partial<Omit<Solution, MetaProps>>
|
})
|
||||||
): Promise<Solution> =>
|
|
||||||
|
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 => {
|
withDatabase(db, state => {
|
||||||
state.solutions[id] = {
|
state.solutions[id] = {
|
||||||
...state.solutions[id],
|
...state.solutions[id],
|
||||||
@@ -196,14 +212,7 @@ export const updateSolution = (
|
|||||||
return state.solutions[id]
|
return state.solutions[id]
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getSolutions = (db: DatabaseConnection) =>
|
export const deleteSolution = (db: DatabaseConnection, id: SolutionId): Promise<void> =>
|
||||||
withDatabase(db, state => {
|
withDatabase(db, state => {
|
||||||
let solutions = Object.values(state.solutions)
|
state.solutions[id].deleted = true
|
||||||
|
|
||||||
return solutions
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getVisibleSolutions = (db: DatabaseConnection) =>
|
|
||||||
withDatabase(db, state => {
|
|
||||||
return Object.values(state.solutions).filter(s => s.visible)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -52,10 +52,6 @@ interface AuthenticatedRequest extends Request {
|
|||||||
user: User | null
|
user: User | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authenticatedMiddleware = (
|
export const authenticatedMiddleware = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
req: AuthenticatedRequest,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) => {
|
|
||||||
req.user && next()
|
req.user && next()
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-16
@@ -3,9 +3,7 @@ import crypto from 'crypto'
|
|||||||
import bodyParser from 'body-parser'
|
import bodyParser from 'body-parser'
|
||||||
import cookieParser from 'cookie-parser'
|
import cookieParser from 'cookie-parser'
|
||||||
|
|
||||||
import express, { Request, Response, Router } from 'express'
|
import express, { Request, Router } from 'express'
|
||||||
|
|
||||||
import { createStatusRouter } from './middlewares'
|
|
||||||
|
|
||||||
import { StatusCodes } from 'http-status-codes'
|
import { StatusCodes } from 'http-status-codes'
|
||||||
|
|
||||||
@@ -13,13 +11,15 @@ import {
|
|||||||
createDatabase,
|
createDatabase,
|
||||||
createProblem,
|
createProblem,
|
||||||
createSolution,
|
createSolution,
|
||||||
|
deleteProblem,
|
||||||
|
deleteSolution,
|
||||||
getProblem,
|
getProblem,
|
||||||
getProblems,
|
getProblems,
|
||||||
getSolution,
|
getSolution,
|
||||||
getSolutions,
|
getSolutions,
|
||||||
getUser,
|
getUser,
|
||||||
getUsers,
|
getUsers,
|
||||||
getVisibleSolutions,
|
updateProblem,
|
||||||
updateSolution,
|
updateSolution,
|
||||||
} from './db/database'
|
} from './db/database'
|
||||||
|
|
||||||
@@ -28,10 +28,11 @@ import {
|
|||||||
isAdministrator,
|
isAdministrator,
|
||||||
isStudent,
|
isStudent,
|
||||||
Opaque,
|
Opaque,
|
||||||
Problem,
|
Problem as ProblemModel,
|
||||||
ProblemId,
|
ProblemId,
|
||||||
Solution as SolutionModel,
|
Solution as SolutionModel,
|
||||||
SolutionId,
|
SolutionId,
|
||||||
|
User as UserModel,
|
||||||
UserId,
|
UserId,
|
||||||
} from '../shared/model'
|
} from '../shared/model'
|
||||||
import { initialDatabaseValue } from './db/example-data'
|
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)
|
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)
|
const userId = sessions.getUserForSession(req.cookies.sid)
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(userId)
|
|
||||||
|
|
||||||
return await getUser(db, userId)
|
return await getUser(db, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +70,9 @@ export async function createApiRouter() {
|
|||||||
r.use(bodyParser.json())
|
r.use(bodyParser.json())
|
||||||
r.use(cookieParser())
|
r.use(cookieParser())
|
||||||
|
|
||||||
r.use('/api/status', createStatusRouter())
|
r.get('/api/status', (req, res) => {
|
||||||
// r.use('/api/ping', new PingRouter())
|
res.json({ url: req.originalUrl, status: 'ok' })
|
||||||
|
})
|
||||||
|
|
||||||
r.get('/api/current-user', async (req, res) => {
|
r.get('/api/current-user', async (req, res) => {
|
||||||
res.json(await getRequestUser(req))
|
res.json(await getRequestUser(req))
|
||||||
@@ -108,7 +108,7 @@ export async function createApiRouter() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
r.get('/api/problems', async (req, res) => {
|
r.get('/api/problems', async (req, res) => {
|
||||||
type ProblemWithSolutionsCount = Problem & { solutionsCount?: number }
|
type ProblemWithSolutionsCount = ProblemModel & { solutionsCount?: number }
|
||||||
|
|
||||||
const problems: ProblemWithSolutionsCount[] = await getProblems(db)
|
const problems: ProblemWithSolutionsCount[] = await getProblems(db)
|
||||||
const solutions = await getSolutions(db)
|
const solutions = await getSolutions(db)
|
||||||
@@ -131,18 +131,76 @@ export async function createApiRouter() {
|
|||||||
res.json(await getProblem(db, req.params.id))
|
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)
|
const user = await getRequestUser(req)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.sendStatus(StatusCodes.UNAUTHORIZED)
|
res.sendStatus(StatusCodes.UNAUTHORIZED)
|
||||||
return
|
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') {
|
if (user.role !== 'admin' && user.role !== 'moderator') {
|
||||||
res.sendStatus(StatusCodes.UNAUTHORIZED)
|
res.sendStatus(StatusCodes.UNAUTHORIZED)
|
||||||
return
|
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, {
|
const id = await createProblem(db, {
|
||||||
|
title: req.body.title ?? `Problema ${problemNextId}`,
|
||||||
content: req.body.content,
|
content: req.body.content,
|
||||||
createdBy: user.id,
|
createdBy: user.id,
|
||||||
})
|
})
|
||||||
@@ -236,10 +294,7 @@ export async function createApiRouter() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// un moderatore può modificare solo i campi "content", "visible", "status"
|
// un moderatore può modificare solo i campi "content", "visible", "status"
|
||||||
if (
|
if (user.role === 'moderator' && !validateObjectKeys<keyof SolutionModel>(req.body, ['content', 'status', 'visible'])) {
|
||||||
user.role === 'moderator' &&
|
|
||||||
!validateObjectKeys<keyof SolutionModel>(req.body, ['content', 'status', 'visible'])
|
|
||||||
) {
|
|
||||||
res.status(StatusCodes.UNAUTHORIZED)
|
res.status(StatusCodes.UNAUTHORIZED)
|
||||||
res.send(`a moderator can only modify the fields "content", "visible", "status"`)
|
res.send(`a moderator can only modify the fields "content", "visible", "status"`)
|
||||||
return
|
return
|
||||||
@@ -250,6 +305,35 @@ export async function createApiRouter() {
|
|||||||
res.json({ status: 'ok' })
|
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) => {
|
r.get('/api/user/:id', async (req, res) => {
|
||||||
const user = await getRequestUser(req)
|
const user = await getRequestUser(req)
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -2,7 +2,7 @@
|
|||||||
// Common
|
// 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 Opaque<T, K, L extends string = 'opaque'> = T & { _: K; __: L }
|
||||||
export type Id<T> = Opaque<string, T, 'id'>
|
export type Id<T> = Opaque<string, T, 'id'>
|
||||||
@@ -41,7 +41,9 @@ export type ProblemId = Id<Problem>
|
|||||||
export type Problem = {
|
export type Problem = {
|
||||||
id: ProblemId
|
id: ProblemId
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
deleted: boolean
|
||||||
|
|
||||||
|
title: string
|
||||||
content: string
|
content: string
|
||||||
createdBy: UserId
|
createdBy: UserId
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,7 @@ export type SolutionId = Id<Solution>
|
|||||||
export type Solution = {
|
export type Solution = {
|
||||||
id: SolutionId
|
id: SolutionId
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
deleted: boolean
|
||||||
|
|
||||||
sentBy: UserId
|
sentBy: UserId
|
||||||
forProblem: ProblemId
|
forProblem: ProblemId
|
||||||
|
|||||||
Reference in New Issue
Block a user