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 (
-
- PHC / Problemi
-
- {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 (
-
- PHC / Problemi
-
- {user ? (
- <>
- Logged in as {user.role} @{user.username} (
-
- Logout
-
- )
- >
- ) : (
-
Login
- )}
-
+
+
+
+
{problems.map(p => (
))}
diff --git a/client/pages/Login.jsx b/client/pages/Login.jsx
index 5845c16..e912850 100644
--- a/client/pages/Login.jsx
+++ b/client/pages/Login.jsx
@@ -1,6 +1,7 @@
import { route } from 'preact-router'
import { useState } from 'preact/hooks'
+import { Header } from '../components/Header.jsx'
export const LoginPage = () => {
const [username, setUsername] = useState('')
@@ -20,10 +21,9 @@ export const LoginPage = () => {
}
return (
-
- PHC / Problemi
+
+
Accedi
-
)}
- {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
}