diff --git a/client/App.tsx b/client/App.tsx index 741e5ca..b5728f1 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -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 ( - - - - - - - - + + + + + + + + + + ) } diff --git a/client/api.tsx b/client/api.tsx index 2395681..628b6e9 100644 --- a/client/api.tsx +++ b/client/api.tsx @@ -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() }, } diff --git a/client/components/Header.tsx b/client/components/Header.tsx index dff7d0f..26a1aa9 100644 --- a/client/components/Header.tsx +++ b/client/components/Header.tsx @@ -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 = { ['admin']: 'Admin', @@ -9,39 +10,42 @@ const ROLE_LABEL: Record = { } type Props = { - user?: User | null noLogin?: boolean } -export const Header = ({ user, noLogin }: Props) => ( -
- - +
+ ) +} diff --git a/client/components/MarkdownEditor.tsx b/client/components/MarkdownEditor.tsx index 0fc1e24..2628ef2 100644 --- a/client/components/MarkdownEditor.tsx +++ b/client/components/MarkdownEditor.tsx @@ -3,11 +3,14 @@ import { StateUpdater, useEffect, useRef } from 'preact/hooks' import { Markdown } from './Markdown' type Props = { + placeholder?: string source: string setSource: StateUpdater } -export const MarkdownEditor = ({ source, setSource }: Props) => { +export const MarkdownEditor = ({ placeholder, source, setSource }: Props) => { + placeholder ??= 'Scrivi qualcosa...' + const editorRef = useRef(null) useEffect(() => { @@ -28,23 +31,17 @@ export const MarkdownEditor = ({ source, setSource }: Props) => {

Editor

Preview

- {source.trim().length ? ( - - ) : ( -
Scrivi una nuova soluzione...
- )} + {source.trim().length ? :
{placeholder}
}
diff --git a/client/components/Problem.tsx b/client/components/Problem.tsx index a6472a5..286bde7 100644 --- a/client/components/Problem.tsx +++ b/client/components/Problem.tsx @@ -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 (
diff --git a/client/components/Solution.tsx b/client/components/Solution.tsx index d8e64a2..6fdf23e 100644 --- a/client/components/Solution.tsx +++ b/client/components/Solution.tsx @@ -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 = { - ['pending']:
Soluzione in attesa di correzione
, - ['correct']:
Soluzione corretta
, - ['wrong']:
Soluzione sbagliata
, + ['pending']:
In attesa di correzione
, + ['correct']:
Corretta
, + ['wrong']:
Sbagliata
, } 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(false) const toggleViewRaw = () => { @@ -85,7 +87,9 @@ export const Solution = ({
- Soluzione + + Soluzione + {sentBy && ( <> {' '} @@ -95,18 +99,15 @@ export const Solution = ({ {forProblem && ( <> {' '} - per il{' '} - Problema {forProblem} + per il Problema {forProblem} )} {!isNaN(d as any) && ( <> {' del '} - {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')} )} @@ -127,29 +128,37 @@ export const Solution = ({
{STATUS_SELECT_OPTIONS[status]}
{adminControls && ( <> - - {status !== 'pending' && ( - )} )} + {user && (user.role === 'admin' || sentBy === user.id) && ( + <> + + + )} +
diff --git a/client/hooks.tsx b/client/hooks.tsx index eebaf52..883aae5 100644 --- a/client/hooks.tsx +++ b/client/hooks.tsx @@ -14,26 +14,6 @@ export const MetadataContext = createContext({}) export const ServerContext = createContext(false) export const ClientContext = createContext(false) -type CurrentUserHook = (onLoaded?: (user: User | null) => void) => [User | null, () => Promise] - -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 = StateUpdater diff --git a/client/hooks/useCurrentUser.tsx b/client/hooks/useCurrentUser.tsx new file mode 100644 index 0000000..29e4ead --- /dev/null +++ b/client/hooks/useCurrentUser.tsx @@ -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 + refreshUser: () => Promise +} + +const UserContext = createContext(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 + logout: () => Promise +} + +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(false) + const [user, setUser] = useState(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 {children} +} diff --git a/client/pages/Admin.tsx b/client/pages/AdminPage.tsx similarity index 77% rename from client/pages/Admin.tsx rename to client/pages/AdminPage.tsx index 754316d..15e3cbd 100644 --- a/client/pages/Admin.tsx +++ b/client/pages/AdminPage.tsx @@ -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('/api/problem', { content: source, }) @@ -21,7 +33,7 @@ const CreateProblem = ({}) => { return ( <> - + ) @@ -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(`/api/solutions`) const [sortOrder, setSortOrder] = useState('oldest') - const sortedSolutions = sortByStringKey(solutions, s => s.createdAt, sortOrder === 'oldest') const [trackInteracted, setTrackedInteracted] = useState>(new Set()) - const hasUntrackedPending = sortedSolutions.filter(s => s.status === 'pending' || trackInteracted.has(s.id)).length > 0 return ( - user && ( + <> +
-
Nuovo problema
+
Soluzioni da correggere
{hasUntrackedPending ? (
@@ -90,6 +106,6 @@ export const AdminPage = ({}) => { )}
- ) + ) } diff --git a/client/pages/Home.tsx b/client/pages/HomePage.tsx similarity index 55% rename from client/pages/Home.tsx rename to client/pages/HomePage.tsx index 09c2f31..41ace04 100644 --- a/client/pages/Home.tsx +++ b/client/pages/HomePage.tsx @@ -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('oldest') @@ -41,28 +40,30 @@ export const HomePage = () => { : sortByNumericKey(problems, bySolvedProblems, SORT_ORDER[sortOrder]) return ( -
-
-
-
-
- +
-
- {sortedProblems.map(p => ( - - ))} -
- + {sortedProblems.map(p => ( + + ))} +
+ + ) } diff --git a/client/pages/Login.tsx b/client/pages/Login.tsx deleted file mode 100644 index 9d4bbe0..0000000 --- a/client/pages/Login.tsx +++ /dev/null @@ -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 ( -
-
-
Accedi
-
- - setUsername(e.target instanceof HTMLInputElement ? e.target.value : '')} - onKeyDown={e => e.key === 'Enter' && login()} - /> - -
- -
-
-
- ) -} diff --git a/client/pages/LoginPage.tsx b/client/pages/LoginPage.tsx new file mode 100644 index 0000000..76ee8cd --- /dev/null +++ b/client/pages/LoginPage.tsx @@ -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 ( + <> +
+
+
Accedi
+
+ + setUsername(e.target instanceof HTMLInputElement ? e.target.value : '')} + onKeyDown={e => e.key === 'Enter' && handleLogin()} + /> + +
+ +
+
+
+ + ) +} diff --git a/client/pages/Problem.tsx b/client/pages/Problem.tsx deleted file mode 100644 index fbd7996..0000000 --- a/client/pages/Problem.tsx +++ /dev/null @@ -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(`/api/problem/${id}`, null, problem => { - if (problem === null) { - route(prependBaseUrl('/')) - } - }) - - const content = problem?.content ?? '' - - const [solutions, refreshSolutions, setSolutionHeuristic] = useListResource( - `/api/solutions?problem=${id}` - ) - - const sendSolution = async () => { - await server.post('/api/solution', { - forProblem: id, - content: source, - }) - - setSource('') - - refreshSolutions() - } - - return ( -
-
-
Testo del problema
- - {solutions.length > 0 && ( -
- Soluzioni -
- {solutions.map((s, index) => ( - setSolutionHeuristic(index, solFn)} - /> - ))} -
-
- )} - {user && ( - <> -
Invia una soluzione al problema
- - - - )} -
- ) -} diff --git a/client/pages/ProblemPage.tsx b/client/pages/ProblemPage.tsx new file mode 100644 index 0000000..fdc1201 --- /dev/null +++ b/client/pages/ProblemPage.tsx @@ -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(`/api/problem/${id}`, null, problem => { + if (problem === null) { + route(prependBaseUrl('/')) + } + }) + + const [solutions, refreshSolutions, setSolutionHeuristic] = useListResource(`/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(false) + const [modifiedProblemSource, setModifiedProblemSource] = useState('') + + 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 ( + <> +
+
+
Testo del problema
+ {!editing ? ( + <> + {problem && } + {user && isAdministrator(user.role) && ( + <> + + + + )} + + ) : ( + <> + +
+ + +
+ + )} + {userSolutions.length > 0 && ( +
+ Le tue soluzioni +
+ {userSolutions.map(([index, s]) => ( + setSolutionHeuristic(index, solFn)} + /> + ))} +
+
+ )} + {otherSolutions.length > 0 && ( +
+ Altre soluzioni +
+ {otherSolutions.map(([index, s]) => ( + setSolutionHeuristic(index, solFn)} + /> + ))} +
+
+ )} + {notLoggedSolutions.length > 0 && ( +
+ Soluzioni +
+ {notLoggedSolutions.map(([index, s]) => ( + setSolutionHeuristic(index, solFn)} + /> + ))} +
+
+ )} + {user && ( + <> +
+
Invia una soluzione al problema
+ + +

+ warning + Attenzione, una soluzione inviata non può essere modificata! +

+ + )} +
+ + ) +} diff --git a/client/pages/Profile.tsx b/client/pages/Profile.tsx deleted file mode 100644 index e76ead5..0000000 --- a/client/pages/Profile.tsx +++ /dev/null @@ -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(`/api/solutions?user=${user.id}`, []) - - return ( -
- {solutions.map(solution => ( - - ))} -
- ) -} - -export const ProfilePage = ({}) => { - const [user, logout] = useCurrentUser(user => { - if (!user) { - route(prependBaseUrl('/login'), true) - } - }) - - const handleLogout = async () => { - await logout() - route(prependBaseUrl('/')) - } - - return ( - user && ( -
-
-
Profilo
- -
Le tue soluzioni
- -
- ) - ) -} diff --git a/client/pages/ProfilePage.tsx b/client/pages/ProfilePage.tsx new file mode 100644 index 0000000..0f5ed7e --- /dev/null +++ b/client/pages/ProfilePage.tsx @@ -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(`/api/solutions?user=${user.id}`, []) + const sortedSolutions = sortByStringKey(solutions, s => s.createdAt, false) + + return ( + <> +
+
+
Profilo
+ +
Le tue soluzioni
+
+ {sortedSolutions.map(solution => ( + + ))} +
+
+ + ) +} diff --git a/client/styles/main.scss b/client/styles/main.scss index 4512c01..d38f15c 100644 --- a/client/styles/main.scss +++ b/client/styles/main.scss @@ -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; diff --git a/server.ts b/server.ts index f2a2d09..9dd322f 100644 --- a/server.ts +++ b/server.ts @@ -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 ? `\n` : '') + - (metadata.description - ? `\n` - : '') + + (metadata.description ? `\n` : '') + `\n` - res.send( - transformedTemplate - .replace('', metaTagsHtml) - .replace('', html) - ) + res.send(transformedTemplate.replace('', metaTagsHtml).replace('', 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 ? `\n` : '') + - (metadata.description - ? `\n` - : '') + + (metadata.description ? `\n` : '') + `\n` - res.send( - transformedTemplate - .replace('', metaTagsHtml) - .replace('', html) - ) + res.send(transformedTemplate.replace('', metaTagsHtml).replace('', 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}...`) }) } diff --git a/server/db/database.ts b/server/db/database.ts index 177c179..e6feedb 100644 --- a/server/db/database.ts +++ b/server/db/database.ts @@ -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 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 => { 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( - { path, initialValue, mu }: DatabaseConnection, - fn: (db: Database) => R | Promise -): Promise { +async function withDatabase({ path, initialValue, mu }: DatabaseConnection, fn: (db: Database) => R | Promise): Promise { const unlock: Lock = await mu.lock() try { @@ -125,18 +114,19 @@ export const getUser: (db: DatabaseConnection, id: string) => Promise -): Promise => +export const createProblem = (db: DatabaseConnection, { title, content, createdBy }: Omit): Promise => 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 => 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 => 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>): Promise => + withDatabase(db, state => { + state.problems[id] = { + ...state.problems[id], + ...problem, + } + + return state.problems[id] + }) + +export const deleteProblem = (db: DatabaseConnection, id: ProblemId): Promise => + 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 => 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> -): Promise => +export const getSolutions = (db: DatabaseConnection) => + withDatabase(db, state => { + return Object.values(state.solutions).filter(s => !s.deleted) + }) + +export const getVisibleSolutions = (db: DatabaseConnection): Promise => + 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>): Promise => withDatabase(db, state => { state.solutions[id] = { ...state.solutions[id], @@ -196,14 +212,7 @@ export const updateSolution = ( return state.solutions[id] }) -export const getSolutions = (db: DatabaseConnection) => - withDatabase(db, state => { - let solutions = Object.values(state.solutions) - - return solutions - }) - -export const getVisibleSolutions = (db: DatabaseConnection) => +export const deleteSolution = (db: DatabaseConnection, id: SolutionId): Promise => withDatabase(db, state => { - return Object.values(state.solutions).filter(s => s.visible) + state.solutions[id].deleted = true }) diff --git a/server/middlewares.ts b/server/middlewares.ts index d52a019..bfd4e6a 100644 --- a/server/middlewares.ts +++ b/server/middlewares.ts @@ -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() } diff --git a/server/routes.ts b/server/routes.ts index 1b64287..e9cdc3d 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -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 { const userId = sessions.getUserForSession(req.cookies.sid) if (!userId) { return null } - console.log(userId) - return await getUser(db, userId) } @@ -71,8 +70,9 @@ export async function createApiRouter() { r.use(bodyParser.json()) r.use(cookieParser()) - r.use('/api/status', createStatusRouter()) - // r.use('/api/ping', new PingRouter()) + r.get('/api/status', (req, res) => { + res.json({ url: req.originalUrl, status: 'ok' }) + }) r.get('/api/current-user', async (req, res) => { res.json(await getRequestUser(req)) @@ -108,7 +108,7 @@ export async function createApiRouter() { }) r.get('/api/problems', async (req, res) => { - type ProblemWithSolutionsCount = Problem & { solutionsCount?: number } + type ProblemWithSolutionsCount = ProblemModel & { solutionsCount?: number } const problems: ProblemWithSolutionsCount[] = await getProblems(db) const solutions = await getSolutions(db) @@ -131,18 +131,76 @@ export async function createApiRouter() { res.json(await getProblem(db, req.params.id)) }) + r.patch('/api/problem/:id', async (req, res) => { + const id = req.params.id as ProblemId + + const user = await getRequestUser(req) + // l'utente deve essere loggato + if (!user) { + res.sendStatus(StatusCodes.UNAUTHORIZED) + return + } + + // l'utente deve essere un admin o un moderatore + if (!isAdministrator(user.role)) { + res.sendStatus(StatusCodes.UNAUTHORIZED) + return + } + + // uno studente può modificare solo il campo "content" + if (user.role === 'moderator' && !validateObjectKeys(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(req.body, ['content', 'status', 'visible']) - ) { + if (user.role === 'moderator' && !validateObjectKeys(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) diff --git a/shared/model.ts b/shared/model.ts index cad1010..f52c77e 100644 --- a/shared/model.ts +++ b/shared/model.ts @@ -2,7 +2,7 @@ // Common // -export type MetadataProps = 'id' | 'createdAt' +export type MetadataProps = 'id' | 'createdAt' | 'deleted' export type Opaque = T & { _: K; __: L } export type Id = Opaque @@ -41,7 +41,9 @@ export type ProblemId = Id export type Problem = { id: ProblemId createdAt: string + deleted: boolean + title: string content: string createdBy: UserId } @@ -57,6 +59,7 @@ export type SolutionId = Id export type Solution = { id: SolutionId createdAt: string + deleted: boolean sentBy: UserId forProblem: ProblemId