diff --git a/README.md b/README.md index 0fd639c..f73e454 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,41 @@ $ npm run dev $ npm run build $ npm run serve ``` + +# TODO + +- Pagina profilo utente + - Lista soluzioni inviate (con stato delle soluzioni: approvate o rifiutate) + +- Pagina dell'admin + - Lista delle soluzioni non corrette + - Creazione nuovi problemi + +- DBv2 + + ```js + relations: [ + { + from: 'Solution' + name: 'for' + to: 'Problem' + entries: [ + ['LRLAH2NoLFQAQHQgz', '1'] + ['JzyiDnwRCrkpzLL8W', '1'] + ['FFYMJjP2yr4ohdmdT', '2'] + ['VFHTb8fSrLOkPNVFx', '2'] + ] + } + { + from: 'User' + name: 'owns' + to: 'Solution' + entries: [ + ['aziis98', 'LRLAH2NoLFQAQHQgz'] + ['aziis98', 'JzyiDnwRCrkpzLL8W'] + ['BachoSeven', 'FFYMJjP2yr4ohdmdT'] + ['BachoSeven', 'VFHTb8fSrLOkPNVFx'] + ] + } + ] + ``` \ No newline at end of file diff --git a/client/App.jsx b/client/App.jsx index 8460e66..77863e2 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -1,10 +1,12 @@ import Router from 'preact-router' import { route } from 'preact-router' import { useEffect } from 'preact/hooks' +import { AdminPage } from './pages/Admin.jsx' import { HomePage } from './pages/Home.jsx' import { LoginPage } from './pages/Login.jsx' import { ProblemPage } from './pages/Problem.jsx' +import { ProfilePage } from './pages/Profile.jsx' const Redirect = ({ to }) => { useEffect(() => { @@ -23,7 +25,9 @@ export const App = ({ url }) => { + + ) diff --git a/client/api.jsx b/client/api.jsx new file mode 100644 index 0000000..b2e3135 --- /dev/null +++ b/client/api.jsx @@ -0,0 +1,18 @@ +export const server = { + async get(url) { + const res = await fetch(url, { credentials: 'include' }) + return await res.json() + }, + async post(url, body) { + const res = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + return await res.json() + }, +} diff --git a/client/components/Header.jsx b/client/components/Header.jsx new file mode 100644 index 0000000..005a09c --- /dev/null +++ b/client/components/Header.jsx @@ -0,0 +1,39 @@ +import { Link } from 'preact-router/match' + +const ROLE_LABEL = { + admin: 'Admin', + moderator: 'Moderatore', +} + +export const Header = ({ user, noLogin }) => ( +
+ + +
+) diff --git a/client/components/Markdown.jsx b/client/components/Markdown.jsx index 6060aee..e79250f 100644 --- a/client/components/Markdown.jsx +++ b/client/components/Markdown.jsx @@ -7,20 +7,34 @@ import remarkRehype from 'remark-rehype' import rehypeKatex from 'rehype-katex' import rehypeStringify from 'rehype-stringify' +async function renderMarkdownAsync(source) { + return await unified() + .use(remarkParse) + .use(remarkMath) + .use(remarkRehype) + .use(rehypeKatex, { throwOnError: false, errorColor: '#c60' }) + .use(rehypeStringify) + .process(source) +} + +// function renderMarkdownSync(source) { +// console.warn(`[Markdown] Rendering ${source.length} characters of markdown in sync mode`) + +// return unified() +// .use(remarkParse) +// .use(remarkMath) +// .use(remarkRehype) +// .use(rehypeKatex, { throwOnError: false, errorColor: '#c60' }) +// .use(rehypeStringify) +// .processSync(source) +// } + export const Markdown = ({ source }) => { const elementRef = useRef() useEffect(async () => { - const renderedHtml = await unified() - .use(remarkParse) - .use(remarkMath) - .use(remarkRehype) - .use(rehypeKatex, { throwOnError: false, errorColor: '#c60' }) - .use(rehypeStringify) - .process(source) - if (elementRef.current) { - elementRef.current.innerHTML = renderedHtml + elementRef.current.innerHTML = await renderMarkdownAsync(source) } }, [source]) diff --git a/client/components/MarkdownEditor.jsx b/client/components/MarkdownEditor.jsx new file mode 100644 index 0000000..bcf86f9 --- /dev/null +++ b/client/components/MarkdownEditor.jsx @@ -0,0 +1,44 @@ +import { useEffect, useRef } from 'preact/hooks' +import { Markdown } from './Markdown.jsx' + +export const MarkdownEditor = ({ source, setSource }) => { + const editorRef = useRef() + + useEffect(() => { + if (editorRef.current) { + // settare questo ad "auto" toglie l'altezza al contenitore che passa alla sua + // dimensione minima iniziale, ciò serve per permettere all'autosize della textarea di + // crescere e ridursi ma ha il problema che resetta lo scroll della pagina che deve + // essere preservato a mano + const oldScrollY = window.scrollY + editorRef.current.style.height = 'auto' + editorRef.current.style.height = editorRef.current.scrollHeight + 'px' + window.scrollTo(0, oldScrollY) + } + }, [source]) + + return ( +
+
+

Editor

+ +
+
+

Preview

+
+ {source.trim().length ? ( + + ) : ( +
Scrivi una nuova soluzione...
+ )} +
+
+
+ ) +} diff --git a/client/components/Problem.jsx b/client/components/Problem.jsx index cfcad39..f6ca9aa 100644 --- a/client/components/Problem.jsx +++ b/client/components/Problem.jsx @@ -7,7 +7,6 @@ export const Problem = ({ id, content, createdBy }) => {
Problema {id}
- {createdBy &&
Creato da @{createdBy}
}
diff --git a/client/components/Select.jsx b/client/components/Select.jsx new file mode 100644 index 0000000..0315c2c --- /dev/null +++ b/client/components/Select.jsx @@ -0,0 +1,12 @@ +export const Select = ({ options, value, setValue }) => ( +
+ + expand_more +
+) diff --git a/client/components/Solution.jsx b/client/components/Solution.jsx index 4bc888b..19c8393 100644 --- a/client/components/Solution.jsx +++ b/client/components/Solution.jsx @@ -1,10 +1,24 @@ import { Markdown } from './Markdown.jsx' -export const Solution = ({ userId, content }) => { +export const Solution = ({ userId, problemId, content }) => { return (
-
@{userId}
+
+ Soluzione + {userId && ( + <> + {' '} + di @{userId} + + )} + {problemId && ( + <> + {' '} + per il Problema {problemId} + + )} +
diff --git a/client/entry-client.jsx b/client/entry-client.jsx index f81a851..bcbb351 100644 --- a/client/entry-client.jsx +++ b/client/entry-client.jsx @@ -2,11 +2,3 @@ import { hydrate } from 'preact' import { App } from './App.jsx' hydrate(, document.body) - -window.fetchServer = url => { - fetch(url, { credentials: 'include' }) - .then(res => res.ok && res.json()) - .then(data => { - console.log(data) - }) -} diff --git a/client/hooks.jsx b/client/hooks.jsx index e387ce0..65880a3 100644 --- a/client/hooks.jsx +++ b/client/hooks.jsx @@ -1,32 +1,24 @@ import { useEffect, useState } from 'preact/hooks' import { createContext } from 'preact' +import { server } from './api.jsx' export const MetadataContext = createContext({}) export const useCurrentUser = onLoaded => { const [user, setUser] = useState(null) - const logout = () => { + const logout = async () => { + await server.post('/api/logout') setUser(null) } useEffect(async () => { - const res = await fetch(`/api/current-user`, { - credentials: 'include', - }) - const user = await res.json() + const user = await server.get('/api/current-user') setUser(user) - - console.log(`Current user is`, user) - onLoaded?.(user) }, []) - useEffect(() => { - console.log(user) - }, [user]) - return [user, logout] } @@ -35,8 +27,8 @@ export const useReadResource = (url, initialValue) => { function refresh() { const controller = new AbortController() - - fetch(url, { signal: controller.signal }) + const realUrl = typeof url === 'function' ? url() : url + fetch(realUrl, { signal: controller.signal }) .then(res => { if (res.ok) { return res.json() diff --git a/client/pages/Admin.jsx b/client/pages/Admin.jsx index a3024db..4bf38a9 100644 --- a/client/pages/Admin.jsx +++ b/client/pages/Admin.jsx @@ -1,26 +1,46 @@ import { route } from 'preact-router' -import { useEffect } from 'preact/hooks' +import { useEffect, useState } from 'preact/hooks' +import { server } from '../api.jsx' +import { Header } from '../components/Header.jsx' +import { MarkdownEditor } from '../components/MarkdownEditor.jsx' import { useCurrentUser } from '../hooks.jsx' -export const Admin = ({}) => { +const CreateProblem = ({}) => { + const [source, setSource] = useState('') + const createProblem = async () => { + const id = await server.post('/api/problem', { + content: source, + }) + + route(`/problem/${id}`) + } + + return ( + <> + + + + ) +} + +export const AdminPage = ({}) => { const [user] = useCurrentUser(user => { - if (!user || user.role !== 'admin') { + if (!user) { + route('/login', true) + } else if (user.role !== 'admin' && user.role !== 'moderator') { route('/', true) } }) return ( -
- -
- {user ? ( - <> - Logged in as {user.role} @{user.username} - - ) : ( - Login - )} -
-
+ user && ( +
+
+
Nuovo problema
+ +
Soluzioni ancora da approvare/rifiutare
+ ... +
+ ) ) } diff --git a/client/pages/Home.jsx b/client/pages/Home.jsx index 56737e0..ccba210 100644 --- a/client/pages/Home.jsx +++ b/client/pages/Home.jsx @@ -1,39 +1,33 @@ import { route } from 'preact-router' import { useEffect, useState } from 'preact/hooks' +import { Header } from '../components/Header.jsx' import { Problem } from '../components/Problem.jsx' +import { Select } from '../components/Select.jsx' import { useReadResource, useCurrentUser } from '../hooks.jsx' export const HomePage = () => { - const [user, logout] = useCurrentUser() - - const handleLogout = async () => { - await fetch(`/api/logout`, { - method: 'POST', - }) - - logout() - } - + const [user] = useCurrentUser() const [problems] = useReadResource('/api/problems', []) return ( -
- -
- {user ? ( - <> - Logged in as {user.role} @{user.username} ( - - Logout - - ) - - ) : ( - Login - )} -
+
+
+
+
+ { const [user] = useCurrentUser() const [source, setSource] = useState('') - const editorRef = useRef() const [{ content }] = useReadResource(`/api/problem/${id}`, { content: '' }) const [solutions] = useReadResource(`/api/solutions?problem=${id}`, []) const sendSolution = async () => { - const res = await fetch(`/api/solution`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - problemId: id, - content: source, - }), + await server.post('/api/solution', { + problemId: id, + content: source, }) location.reload() } - useEffect(() => { - if (editorRef.current) { - // settare questo ad "auto" toglie l'altezza al contenitore che passa alla sua - // dimensione minima iniziale, ciò serve per permettere all'autosize della textarea di - // crescere e ridursi ma ha il problema che resetta lo scroll della pagina che deve - // essere preservato a mano - const oldScrollY = window.scrollY - editorRef.current.style.height = 'auto' - editorRef.current.style.height = editorRef.current.scrollHeight + 'px' - window.scrollTo(0, oldScrollY) - } - }, [source]) - return ( -
- - {user && ( -
- Logged in as {user.role} @{user.username} -
- )} +
+
Testo del problema
{solutions.length > 0 && ( @@ -66,39 +42,12 @@ export const ProblemPage = ({ id }) => {
)} - {user ? ( + {user && ( <>
Invia una soluzione al problema
-
-
-

Editor

- -
-
-

Preview

-
- {source.trim().length ? ( - - ) : ( -
Scrivi una nuova soluzione...
- )} -
-
-
-
- -
+ + - ) : ( -
- Accedi per inviare una soluzione -
)}
) diff --git a/client/pages/Profile.jsx b/client/pages/Profile.jsx new file mode 100644 index 0000000..e67731c --- /dev/null +++ b/client/pages/Profile.jsx @@ -0,0 +1,39 @@ +import { route } from 'preact-router' +import { useState } from 'preact/hooks' +import { server } from '../api.jsx' +import { Header } from '../components/Header.jsx' +import { Solution } from '../components/Solution.jsx' +import { useCurrentUser, useReadResource } from '../hooks.jsx' + +export const ProfilePage = ({}) => { + const [solutions, setSolutions] = useState([]) + + const [user, logout] = useCurrentUser(async user => { + if (!user) { + route('/login', true) + } + + setSolutions(await server.get(`/api/solutions?user=${user.username}`)) + }) + + const handleLogout = () => { + logout() + route('/') + } + + return ( + user && ( +
+
+
Le tue soluzioni
+
+ {solutions.map(({ problemId, content }) => ( + + ))} +
+
Altro
+ +
+ ) + ) +} diff --git a/client/styles/main.scss b/client/styles/main.scss index 61c9e7d..3642128 100644 --- a/client/styles/main.scss +++ b/client/styles/main.scss @@ -1,6 +1,10 @@ $device-s-width: 640px; $device-m-width: 1200px; +:root { + --accent: #7b3a99; +} + // Normalize *, @@ -58,6 +62,51 @@ input[type='text'] { color: #555; } +.input-select { + display: inline-flex; + align-items: center; + justify-content: space-between; + + cursor: pointer; + font-family: 'Lato'; + font-weight: 600; + font-size: 18px; + color: #555; + + border: 1px solid #c8c8c8; + padding: 0 0.5rem; + margin: 0; + + box-shadow: -2px 2px 16px 0 #00000010; + border-radius: 0.25rem; + + background: linear-gradient(180deg, #f0f0f0, #e8e8e8 20%, #e0e0e0 90%, #cdcdcd); + + &:hover { + border: 1px solid #c4c4c4; + box-shadow: -2px 2px 20px 0 #00000010; + background: linear-gradient(180deg, #fff, #ededed 20%, #e8e8e8 90%, #c0c0c0); + } + + select { + cursor: pointer; + height: 100%; + + appearance: none; + + padding: 0.5rem 0; + margin: 0; + border: none; + outline: none; + background: none; + + font-family: 'Lato'; + font-weight: 600; + font-size: 18px; + color: #555; + } +} + button { cursor: pointer; @@ -88,7 +137,7 @@ a, a:visited { cursor: pointer; - color: #3a9999; + color: var(--accent); text-decoration: none; &:hover { @@ -96,7 +145,13 @@ a:visited { } } +// // Typography +// + +.math-inline { + font-size: 95%; +} p { margin: 0; @@ -176,21 +231,12 @@ main { gap: 2rem; - .logo { - // font-size: 42px; - // font-family: 'EB Garamond'; - // font-weight: 600; - font-size: 38px; - font-family: 'Lato'; - font-weight: 300; - } - .subtitle { font-size: 24px; } } -main.home { +main.page-home { .board { width: 100%; // min-width: 0; @@ -200,171 +246,208 @@ main.home { gap: 2rem; justify-content: center; + + .fill-row { + grid-column: 1 / -1; + } + + .board-controls { + justify-self: start; + } } } -main.problem { +main.page-problem { summary { padding: 1rem; text-align: center; user-select: none; } - - .solution-list { - display: flex; - flex-direction: column; - gap: 1rem; - } } // // Components // -main { - .problem { - padding: 1rem; +.solution-list { + display: flex; + flex-direction: column; + gap: 1rem; +} - max-width: 80ch; +header { + display: grid; + place-content: center; - box-shadow: -2px 4px 8px 1px #00000020, -1px 1px 1px 0px #00000010; - border-radius: 0.5rem; - background: #ffffff; + width: 100%; + position: relative; - display: grid; - grid-template-rows: auto 1fr; - gap: 0.5rem; + .logo { + font-size: 42px; + font-family: 'Lato'; + font-weight: 300; - .problem-header { - display: grid; - grid-template-columns: auto; - gap: 0.25rem; + a:hover { + text-decoration: none; + border-bottom: 3px solid var(--accent); + } + } - .problem-title { - font-size: 24px; - font-weight: 700; - } + nav { + position: absolute; + right: 0; + + display: flex; + gap: 1rem; - .problem-author { - grid-row: 2; + .nav-item { + font-size: 24px; + font-weight: 300; - font-size: 16px; - color: #000000dd; - // font-weight: 400; + a.active { + border-bottom: 1px solid var(--accent); } - } - .problem-content { - @extend .text-body; + a:hover { + text-decoration: none; + border-bottom: 2px solid var(--accent); + } } } +} - .solution { - padding: 1rem; +.problem { + padding: 1rem; - max-width: 80ch; + max-width: 80ch; - box-shadow: -2px 4px 8px 1px #00000020, -1px 1px 1px 0px #00000010; - border-radius: 0.5rem; - background: #ffffff; + box-shadow: -2px 4px 8px 1px #00000020, -1px 1px 1px 0px #00000010; + border-radius: 0.5rem; + background: #ffffff; + display: grid; + grid-template-rows: auto 1fr; + gap: 0.5rem; + + .problem-header { display: grid; - grid-template-rows: auto 1fr; - gap: 0.5rem; + grid-template-columns: auto; + gap: 0.25rem; - .solution-header { - display: grid; - grid-template-columns: auto; - gap: 0.25rem; + .problem-title { + font-size: 24px; + font-weight: 400; } + } - .solution-content { - @extend .text-body; - } + .problem-content { + @extend .text-body; } +} - .solution-editor { - display: grid; - width: 100%; - grid-template-columns: repeat(2, 1fr); +.solution { + padding: 1rem; - gap: 1rem; + max-width: 80ch; - .editor, - .preview { - width: 100%; - max-width: 70ch; + box-shadow: -2px 4px 8px 1px #00000020, -1px 1px 1px 0px #00000010; + border-radius: 0.5rem; + background: #ffffff; - display: flex; - flex-direction: column; - align-items: center; + display: grid; + grid-template-rows: auto 1fr; + gap: 0.5rem; - gap: 1rem; - } + .solution-header { + display: grid; + grid-template-columns: auto; + gap: 0.25rem; - .editor { - justify-self: end; + font-size: 18px; + } - textarea { - font-family: 'DM Mono', monospace; - font-size: 18px; + .solution-content { + @extend .text-body; + } +} - resize: none; - overflow-y: hidden; +.form { + min-width: 50ch; - min-height: 8rem; - } - } + background: #e0e0e0; - .preview { - justify-self: start; + display: grid; + grid-template-columns: auto 1fr; - .placeholder { - color: #444; - } + border: 1px solid #c0c0c0; + border-radius: 1rem; - .preview-content { - @extend .text-body; + // box-shadow: -2px 4px 16px 4px #00000018, -2px 4px 4px 1px #00000018; - width: 100%; + padding: 1.5rem 2rem 1rem; + gap: 1rem; - padding: 1rem; + align-items: center; - box-shadow: -2px 4px 8px 1px #00000020, -1px 1px 1px 0px #00000010; - border-radius: 0.25rem; - background: #ffffff; - } - } + .fill { + grid-column: span 2; + justify-self: center; } +} - .form { - min-width: 50ch; - - background: #e0e0e0; +.markdown-editor { + display: grid; + width: 100%; + grid-template-columns: repeat(2, 1fr); - display: grid; - grid-template-columns: auto 1fr; + gap: 1rem; - border: 1px solid #c0c0c0; - border-radius: 1rem; + .editor, + .preview { + width: 100%; + max-width: 70ch; - // box-shadow: -2px 4px 16px 4px #00000018, -2px 4px 4px 1px #00000018; + display: flex; + flex-direction: column; + align-items: center; - padding: 1.5rem 2rem 1rem; gap: 1rem; + } - align-items: center; + .editor { + justify-self: end; - .fill { - grid-column: span 2; - justify-self: center; + textarea { + font-family: 'DM Mono', monospace; + font-size: 18px; + + resize: none; + overflow-y: hidden; + + min-height: 8rem; } } -} -.math-inline { - font-size: 95%; + .preview { + justify-self: start; + + .placeholder { + color: #666; + } + + .preview-content { + @extend .text-body; + + width: 100%; + + padding: 1rem; + + box-shadow: -2px 4px 8px 1px #00000020, -1px 1px 1px 0px #00000010; + border-radius: 0.25rem; + background: #ffffff; + } + } } // @@ -379,23 +462,69 @@ main { // On mobile @media screen and (max-width: $device-s-width), (pointer: coarse) { main { - .solution-editor { - grid-template-columns: auto; - grid-template-rows: auto auto; + padding: 1rem 1rem 6rem; + + &.page-home { + .board { + gap: 1rem; + } + } + } + + header { + display: flex; + flex-direction: column; + align-items: center; + + gap: 2rem; + + nav { + position: relative; + right: unset; + + display: flex; + flex-direction: column; + align-items: center; + } + } + + .problem { + padding: 0.75rem; + } + + .markdown-editor { + grid-template-columns: auto; + grid-template-rows: auto auto; + } + + .form { + width: 100%; + padding: 1rem; + + min-width: unset; + + display: flex; + flex-direction: column; + + label { + align-self: start; } } + + textarea, + .markdown-editor .preview .preview-content { + padding: 0.75rem; + } } @media screen and (max-width: $device-m-width), (pointer: coarse) { - main { - .solution-editor { - grid-template-columns: auto; - grid-template-rows: auto auto; + .markdown-editor { + grid-template-columns: auto; + grid-template-rows: auto auto; - .preview, - .editor { - justify-self: center; - } + .preview, + .editor { + justify-self: center; } } } diff --git a/index.html b/index.html index afc92aa..54cf890 100644 --- a/index.html +++ b/index.html @@ -17,6 +17,10 @@ href="https://fonts.googleapis.com/css2?family=DM+Mono&display=swap" rel="stylesheet" /> + diff --git a/server.js b/server.js index 6c52fd1..4755444 100644 --- a/server.js +++ b/server.js @@ -8,7 +8,7 @@ import { createServer as createViteServer } from 'vite' import { createApiRouter } from './server/routes.js' -const HTML_ROUTES = ['/', '/login', '/problem/:id', '/admin'] +const HTML_ROUTES = ['/', '/login', '/problem/:id', '/admin', '/profile'] const config = { isDevelopment: process.env.MODE === 'development', diff --git a/server/db/database.js b/server/db/database.js index 063f9c2..e0d4146 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -112,6 +112,24 @@ export const getUser = (db, id) => // Problems // +export const createProblem = (db, { content, createdBy }) => + withDatabase(db, state => { + const nextId = ( + Object.keys(state.problems) + .map(k => parseInt(k)) + .reduce((acc, id) => Math.max(acc, id)) + 1 + ).toString() + + state.problems[nextId] = { + id: nextId, + content, + createdBy, + createdAt: new Date().toJSON(), + } + + return nextId + }) + export const getProblem = (db, id) => withDatabase(db, state => { return state.problems[id] diff --git a/server/routes.js b/server/routes.js index 440f529..b401c49 100644 --- a/server/routes.js +++ b/server/routes.js @@ -8,6 +8,7 @@ import express from 'express' import { createStatusRouter, PingRouter } from './middlewares.js' import { createDatabase, + createProblem, createSolution, getProblem, getProblems, @@ -92,6 +93,25 @@ export async function createApiRouter() { res.json(await getProblem(db, req.params.id)) }) + r.post('/api/problem', async (req, res) => { + const user = await getRequestUser(req) + if (!user) { + res.sendStatus(401) + return + } + if (user.role !== 'admin' && user.role !== 'moderator') { + res.sendStatus(401) + return + } + + const id = await createProblem(db, { + content: req.body.content, + createBy: user.username, + }) + + res.json(id) + }) + r.get('/api/solutions', async (req, res) => { let queryUserId = req.query.user let queryProblemId = req.query.problem @@ -146,10 +166,5 @@ export async function createApiRouter() { res.json(requestedUser) }) - // r.post('/api/user/:id', async (req, res) => { - // await updateUser(db, req.params.id, req.body) - // res.sendStatus(200) - // }) - return r }