Compare commits

...

No commits in common. 'main' and 'next' have entirely different histories.
main ... next

@ -1,54 +0,0 @@
# This file defines a Drone pipeline that builds a static website with "npm run build". This
# pipeline must be marked as "Trusted" in the Drone project settings.
#
# We mount the target directory of the project at "/var/www/{project}" to the container
# "dist/" directory and the run the build. A caveat is that the container builds files
# with "root" permissions, so we need to fix those after each build with a second pipeline.
kind: pipeline
name: default
type: docker
steps:
- name: deploy
image: node:22-alpine
volumes:
- name: host-website-dist
path: /mnt/website
commands:
- uname -a
- node -v
- npm ci
- node -e 'import Sharp from "sharp"; console.log(Sharp)'
- npm run build
- cp -rT ./dist /mnt/website
volumes:
- name: host-website-dist
host: # this volume is mounted on the host machine
path: /var/www/website
trigger:
branch:
- main
event:
- push
---
kind: pipeline
name: caddy-permissions
type: exec # this job is executed on the host machine
depends_on:
- default
steps:
- name: chown
commands:
- chown -R caddy:caddy /var/www/website
trigger:
branch:
- main
event:
- push

27
.gitignore vendored

@ -1,23 +1,10 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# local data
.env
*.local*
bin/
# environment variables
.env
.env.production
.out/
out/
dist/
node_modules/
# macOS-specific files
.DS_Store
.vscode/

@ -0,0 +1,2 @@
# Needed by pnpm to work with "@preact/preset-vite"
shamefully-hoist=true

@ -1,27 +0,0 @@
/** @type {import("prettier").Config} */
export default {
printWidth: 120,
singleQuote: true,
quoteProps: 'consistent',
tabWidth: 4,
useTabs: false,
semi: false,
arrowParens: 'avoid',
plugins: ['prettier-plugin-astro'],
overrides: [
{
files: '*.astro',
options: {
parser: 'astro',
},
},
{
files: '*.{yml,yaml,json}',
excludeFiles: 'package-lock.json',
options: {
tabWidth: 2,
},
},
],
}

@ -1,11 +0,0 @@
{
"npm.packageManager": "bun",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[astro]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[yaml]": {
"editor.tabSize": 2,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

@ -0,0 +1,31 @@
# Architettura
Questo è un progetto _fullstack_ con una backend scritta in [Go](https://go.dev/) ed una frontend in JS, più precisamente utilizziamo NodeJS con [Vite](https://vitejs.dev/) per build-are la frontend.
La cosa più interessante è l'integrazione tra **Vite** e la backend in **Go** per siti con più pagine (il nostro caso). Di base Vite supporta siti con più file html ma ha bisogno che gli venga detto quali sono tutti gli _entrypoint_ HTML. Vedremo che questo progetto usa una tecnica che ci permette di indicare una volta sola le cose nel codice in Go senza stare a tenere sincronizzato il codice in Go e la configurazione di Vite.
## Usage
Ci sono vari modi per lanciare la nostra applicazione in base all'_environment_, in particolare sono tutti isolati sotto forma di eseguibili in Go:
- Quando saremo in produzione l'unico server sarà quello di Go lanciato attraverso l'_entry-point_ [./cmd/server/main.go](./cmd/server/main.go)
In particolare prima di poter lanciare questo server bisogna aver eseguito [./cmd/build/main.go](./cmd/build/main.go) che esegue solo il codice relativo al router della nostra applicazione e genera un file `out/routes.json`. Poi bisogna eseguire `npm run build` che chiama Vite e genera il codice per tutte le route dentro la cartella `out/frontend/`.
- Quando siamo in development usiamo solo [./cmd/devserver/main.go](./cmd/devserver/main.go) che lancia in background il server di Vite (chiama `npm run dev` che a sua volta è un alias per `node server.js`) quindi possiamo vedere tutto in tempo reale da `localhost:3000`.
Più precisamente il server di Vite all'avvio richiede al server in Go tutte le route da montare utilizzando la route speciale `/api/development/routes` (in particolare Fiber ed ExpressJS hanno la stessa sintassi per definire le route quindi questa cosa è facile da fare).
Poi quando si prova ad andare su una pagina ci sono due casi
- Se la route era **statica** allora leggiamo il file html, lo facciamo processare a Vite e poi lo rimandiamo all'utente.
- Se invece la route era di tipo **dinamico** allora leggiamo sempre il file e lo processiamo con Vite però ora utilizziamo l'altra route speciale che esiste solo in fase di sviluppo `/api/development/render` che renderizza la pagina applicando il _templating del server_ e poi una volta finito inviamo la pagina al client.
Invece quando saremo in produzione tutte le pagina saranno già state renderizzate da Vite quindi saremo nel caso standard di _http server_ con views da renderizzare con il _template engine_ del caso prima di mandare la pagina al client.
- L'ultimo _entrypoint_ è [./cmd/build/main.go](./cmd/build/main.go) e lancia la nostra applicazione in una modalità "finta" senza server http ma vengono comunque registrate tutte le route utilizzando sempre il modulo `dev`. Questo ci permette di costruire l'albero delle route (statiche e dinamiche) che poi servirà a Vite quando faremo `npm run build`.
Ciò serve perché così ci basta definire tutte le route una volta sola nel Go e poi in automatico funzioneranno anche nel server di Vite senza dover ripetere due volte il codice. (questa è la parte più magica di _meta-programming_ di tutto il progetto)
![architecture-1](./docs/architecture-1.svg)

@ -0,0 +1,10 @@
.PHONY: test
test:
PROJECT_DIR="$(shell pwd)" go test -v ./...
.PHONY: build
build:
go run -v ./cmd/build
pnpm run build
go build -o ./out/bin/server ./cmd/server

@ -1,35 +1,25 @@
# PHC Website
# Website 2
Questo è il repository del sito web del PHC. Il sito è costruito utilizzando Astro, un framework statico per la generazione di siti web.
Repo per il nuovo sito del PHC
## Installazione
## Docs
```bash
bun install
```
- [./ARCHITECTURE.md](./ARCHITECTURE.md)
## Sviluppo
Alcune note sull'architettura di questo progetto.
```bash
bun dev
```
## Usage
## Build
## Development
```bash
bun run build
```bash shell
# Starts the backend on port :4000 and the frontend development server on port :3000
$ go run -v ./cmd/devserver
```
## Deploy
Per ora c'è un file `.drone.yml` che viene usato per il deploy su un server remoto utilizzando Drone per il CD. Al momento il sito è solo statico e non ha ancora una backend.
## Come Contribuire
## Production
**Nota per Sviluppatori**: SE il branch `dev` non esiste o è indietro rispetto a `main`, bisogna portarlo avanti a `main` e poi continuare con le modifiche dal lì.
Sentitevi liberi di aprire una PR per qualsiasi modifica o aggiunta al sito web. Il branch `main` è protetto e corrisponde alla versione di produzione del sito web, le modifiche devono prima essere accettate su `dev` e poi mergeate su `main` da un amministratore.
### Cose da fare
- Aggiungere guide in [src/content/guides/](./src/content/guides/)
```bash shell
# Generates "routes.json", builds all frontend artifacts and finally the main binary
$ make build
```

@ -1,32 +0,0 @@
import { defineConfig } from 'astro/config'
import preact from '@astrojs/preact'
import mdx from '@astrojs/mdx'
import remarkMath from 'remark-math'
import yaml from '@rollup/plugin-yaml'
// https://astro.build/config
export default defineConfig({
vite: {
plugins: [yaml()],
},
server: {
port: 3000,
},
markdown: {
remarkPlugins: [remarkMath],
shikiConfig: {
theme: 'github-light',
},
},
integrations: [
preact({
compat: true,
}),
mdx({
remarkPlugins: [remarkMath],
}),
],
output: 'static',
})

Binary file not shown.

@ -0,0 +1,44 @@
package main
import (
"encoding/json"
"log"
"os"
"git.phc.dm.unipi.it/phc/website/services/config"
"git.phc.dm.unipi.it/phc/website/services/database"
"git.phc.dm.unipi.it/phc/website/services/server"
"git.phc.dm.unipi.it/phc/website/services/server/dev"
"git.phc.dm.unipi.it/phc/website/sl"
)
func main() {
l := sl.New()
// sl.Inject[config.Interface](l, &config.EnvConfig{})
sl.InjectValue(l, config.Slot, &config.Config{
Mode: "production",
})
sl.InjectValue(l, database.Slot, database.Database(
&database.Memory{}),
)
if _, err := server.Configure(l); err != nil {
log.Fatal(err)
}
f, err := os.Create("out/routes.json")
if err != nil {
log.Fatal(err)
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(dev.UseRoutesMetadata(l)); err != nil {
log.Fatal(err)
}
log.Printf(`generated "out/routes.json"`)
}

@ -0,0 +1,69 @@
package main
import (
"bufio"
"io"
"log"
"os/exec"
"git.phc.dm.unipi.it/phc/website/model"
"git.phc.dm.unipi.it/phc/website/services/config"
"git.phc.dm.unipi.it/phc/website/services/database"
"git.phc.dm.unipi.it/phc/website/services/server"
"git.phc.dm.unipi.it/phc/website/sl"
)
func init() {
log.SetFlags(0)
}
func main() {
l := sl.New()
cfg := sl.InjectValue(l, config.Slot, &config.Config{
Mode: "development",
Host: ":4000",
})
sl.InjectValue[database.Database](l, database.Slot, &database.Memory{
Users: []model.User{
{
Id: "claire",
FullName: "Claire Doe",
Nickname: "claire-doe",
AuthSources: map[string]model.AuthSource{},
},
{
Id: "john",
FullName: "John Smith",
Nickname: "john-smith",
AuthSources: map[string]model.AuthSource{},
},
},
})
srv, err := server.Configure(l)
if err != nil {
log.Fatal(err)
}
go func() {
log.Fatal(srv.Router.Listen(cfg.Host))
}()
r, w := io.Pipe()
cmd := exec.Command("npm", "run", "dev")
cmd.Stdout = w
go func() {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
log.Printf(`[cmd/devserver] [vitejs] %s`, scanner.Text())
}
}()
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

@ -0,0 +1,44 @@
package main
import (
"log"
"git.phc.dm.unipi.it/phc/website/model"
"git.phc.dm.unipi.it/phc/website/services/config"
"git.phc.dm.unipi.it/phc/website/services/database"
"git.phc.dm.unipi.it/phc/website/services/server"
"git.phc.dm.unipi.it/phc/website/sl"
)
func main() {
l := sl.New()
cfg := sl.InjectValue(l, config.Slot, &config.Config{
Mode: "production",
Host: ":4000",
})
sl.InjectValue[database.Database](l, database.Slot, &database.Memory{
Users: []model.User{
{
Id: "claire",
FullName: "Claire Doe",
Nickname: "claire-doe",
AuthSources: map[string]model.AuthSource{},
},
{
Id: "john",
FullName: "John Smith",
Nickname: "john-smith",
AuthSources: map[string]model.AuthSource{},
},
},
})
srv, err := server.Configure(l)
if err != nil {
log.Fatal(err)
}
log.Fatal(srv.Router.Listen(cfg.Host))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 251 KiB

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Homepage</title>
</head>
<body>
Homepage
</body>
</html>

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ .Title }} &bull; Articoli &bull; PHC</title>
<link rel="stylesheet" href="/styles/main.scss" />
<link rel="stylesheet" href="./typography.scss" />
</head>
<body>
<h1>Articolo "{{ .Example }}"</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Neque, quasi...</p>
</body>
</html>

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Articoli &bull; PHC</title>
<link rel="stylesheet" href="/styles/main.scss" />
</head>
<body>
<h1>Articoli</h1>
{{ .Example }}
</body>
</html>

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ListaUtenti</title>
</head>
<body>
<main class="page-lista-utenti"></main>
<script type="module" src="./src/main.jsx"></script>
</body>
</html>

@ -0,0 +1,13 @@
import { render } from 'preact'
import './lista-utenti.scss'
render(
<>
<h1>Lista Utenti</h1>
<div class="list">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Odit, illo molestiae. Sapiente cumque saepe maxime, temporibus ad
nulla id officiis impedit ut dolorem asperiores voluptate illo, molestiae facilis inventore. Ea.
</div>
</>,
document.querySelector('main')
)

@ -0,0 +1,5 @@
*,
*::before,
*::after {
box-sizing: border-box;
}

@ -0,0 +1,22 @@
module git.phc.dm.unipi.it/phc/website
go 1.19
require (
github.com/alecthomas/repr v0.2.0
github.com/gofiber/fiber/v2 v2.41.0
github.com/joho/godotenv v1.4.0
github.com/valyala/fasthttp v1.43.0
)
require (
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/klauspost/compress v1.15.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.1.0 // indirect
)

@ -0,0 +1,40 @@
github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk=
github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/gofiber/fiber/v2 v2.41.0 h1:YhNoUS/OTjEz+/WLYuQ01xI7RXgKEFnGBKMagAu5f0M=
github.com/gofiber/fiber/v2 v2.41.0/go.mod h1:RdebcCuCRFp4W6hr3968/XxwJVg0K+jr9/Ae0PFzZ0Q=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.43.0 h1:Gy4sb32C98fbzVWZlTM1oTMdLWGyvxR03VhM6cBIU4g=
github.com/valyala/fasthttp v1.43.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

@ -0,0 +1,18 @@
import fetch from 'node-fetch'
import { readFile } from 'fs/promises'
export async function getBuildRoutesMetadata(file) {
console.log('Loading routes from disk...')
const routesRaw = await readFile(file, 'utf8')
return JSON.parse(routesRaw)
}
export async function getDevRoutesMetadata(url) {
console.log('Loading routes from go server...')
const routesReq = await fetch(url)
const routes = await routesReq.json()
return routes
}

@ -0,0 +1,15 @@
package model
type User struct {
Id string
FullName string
Nickname string
AuthSources map[string]AuthSource
}
type AuthSource struct {
Provider string
AuthToken string
}

12480
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,54 +1,21 @@
{
"name": "website",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "run-s astro:sync astro:dev",
"build": "run-s astro:build",
"astro:sync": "astro sync",
"astro:dev": "astro dev",
"astro:build": "astro check && astro build"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/node": "^9.4.3",
"@astrojs/preact": "^4.1.1",
"@fontsource-variable/material-symbols-outlined": "^5.2.21",
"@fontsource/iosevka": "^5.2.5",
"@fontsource/mononoki": "^5.2.5",
"@fontsource/open-sans": "^5.2.6",
"@fontsource/source-code-pro": "^5.2.6",
"@fontsource/source-sans-pro": "^5.2.5",
"@fontsource/space-mono": "^5.2.8",
"@phosphor-icons/core": "^2.1.1",
"@phosphor-icons/react": "^2.1.10",
"@preact/signals": "^1.3.2",
"@types/jsdom": "^21.1.7",
"astro": "^5.13.7",
"fuse.js": "^7.1.0",
"katex": "^0.16.22",
"lucide-static": "^0.468.0",
"marked": "^15.0.12",
"node-addon-api": "^8.5.0",
"node-gyp": "^11.4.2",
"preact": "^10.27.2",
"sharp": "^0.34.3",
"typescript": "^5.9.2"
},
"devDependencies": {
"@astrojs/mdx": "^4.3.5",
"@rollup/plugin-yaml": "^4.1.2",
"@types/katex": "^0.16.7",
"jsdom": "^24.1.3",
"linkedom": "^0.18.12",
"npm-run-all": "^4.1.5",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"remark-math": "^6.0.0",
"remark-toc": "^9.0.0",
"sass": "^1.92.1",
"tsx": "^4.20.5"
}
"name": "website",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node server.js",
"build": "vite build --emptyOutDir"
},
"devDependencies": {
"@preact/preset-vite": "^2.5.0",
"axios": "^1.2.6",
"express": "^4.18.2",
"morgan": "^1.10.0",
"node-fetch": "^3.3.0",
"sass": "^1.57.1",
"vite": "^4.0.4"
},
"dependencies": {
"preact": "^10.11.3"
}
}

File diff suppressed because it is too large Load Diff

@ -1,95 +0,0 @@
<svg width="1000" height="500" viewBox="0 0 1000 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="80" y="190" width="60" height="120" fill="#1E6733" />
<rect x="160" y="50" width="150" height="60" fill="#1E6733" />
<rect x="140" y="90" width="10" height="50" fill="#ECC333" />
<rect x="140" y="200" width="10" height="50" fill="#ECC333" />
<rect x="140" y="410" width="10" height="20" fill="#ECC333" />
<rect x="140" y="350" width="10" height="50" fill="#ECC333" />
<rect x="240" y="110" width="70" height="10" fill="#ECC333" />
<rect x="250" y="130" width="60" height="130" fill="#1E6733" />
<rect x="340" y="50" width="60" height="120" fill="#1E6733" />
<rect x="340" y="190" width="60" height="120" fill="#1E6733" />
<rect x="590" y="190" width="60" height="120" fill="#1E6733" />
<rect x="690" y="180" width="60" height="120" fill="#1E6733" />
<rect x="690" y="310" width="60" height="140" fill="#1E6733" />
<rect x="690" y="50" width="60" height="120" fill="#1E6733" />
<rect x="590" y="320" width="60" height="130" fill="#1E6733" />
<rect x="590" y="50" width="60" height="130" fill="#1E6733" />
<rect x="420" y="240" width="150" height="60" fill="#1E6733" />
<rect x="340" y="320" width="60" height="130" fill="#1E6733" />
<rect x="240" y="140" width="10" height="50" fill="#ECC333" />
<rect x="350" y="170" width="40" height="10" fill="#ECC333" />
<rect x="330" y="330" width="10" height="50" fill="#ECC333" />
<rect x="160" y="200" width="80" height="60" fill="#1E6733" />
<rect x="650" y="200" width="10" height="50" fill="#ECC333" />
<rect x="750" y="330" width="10" height="60" fill="#ECC333" />
<rect x="800" y="450" width="40" height="10" fill="#ECC333" />
<rect x="850" y="450" width="30" height="10" fill="#ECC333" />
<rect x="750" y="90" width="10" height="50" fill="#ECC333" />
<rect x="810" y="110" width="60" height="10" fill="#ECC333" />
<rect x="580" y="330" width="10" height="50" fill="#ECC333" />
<rect x="580" y="60" width="10" height="50" fill="#ECC333" />
<rect x="710" y="420" width="20" height="20" fill="#C4C4C4" />
<rect x="710" y="390" width="20" height="20" fill="#C4C4C4" />
<rect x="100" y="280" width="20" height="10" fill="#C4C4C4" />
<rect x="100" y="260" width="20" height="10" fill="#C4C4C4" />
<rect x="110" y="210" width="20" height="20" fill="#C4C4C4" />
<rect x="350" y="430" width="40" height="10" fill="#303030" />
<rect x="350" y="410" width="40" height="10" fill="#303030" />
<rect x="350" y="390" width="40" height="10" fill="#303030" />
<rect x="700" y="70" width="20" height="40" fill="#303030" />
<rect x="700" y="120" width="20" height="40" fill="#303030" />
<rect x="610" y="280" width="20" height="20" fill="#C4C4C4" />
<rect x="600" y="240" width="20" height="20" fill="#C4C4C4" />
<rect x="430" y="250" width="10" height="10" fill="#C4C4C4" />
<rect x="430" y="265" width="10" height="10" fill="#C4C4C4" />
<rect x="430" y="280" width="10" height="10" fill="#C4C4C4" />
<rect x="445" y="250" width="10" height="10" fill="#C4C4C4" />
<rect x="445" y="265" width="10" height="10" fill="#C4C4C4" />
<rect x="460" y="280" width="40" height="10" fill="#C4C4C4" />
<rect x="475" y="250" width="10" height="10" fill="#C4C4C4" />
<rect x="475" y="265" width="10" height="10" fill="#C4C4C4" />
<rect x="505" y="250" width="10" height="10" fill="#C4C4C4" />
<rect x="505" y="265" width="10" height="10" fill="#C4C4C4" />
<rect x="520" y="265" width="10" height="15" fill="#C4C4C4" />
<rect x="505" y="280" width="25" height="10" fill="#C4C4C4" />
<rect x="535" y="250" width="10" height="10" fill="#C4C4C4" />
<rect x="535" y="265" width="10" height="10" fill="#C4C4C4" />
<rect x="535" y="280" width="10" height="10" fill="#C4C4C4" />
<rect x="445" y="280" width="10" height="10" fill="#C4C4C4" />
<rect x="460" y="250" width="10" height="10" fill="#C4C4C4" />
<rect x="460" y="265" width="10" height="10" fill="#C4C4C4" />
<rect x="490" y="250" width="10" height="10" fill="#C4C4C4" />
<rect x="490" y="265" width="10" height="10" fill="#C4C4C4" />
<rect x="520" y="250" width="10" height="10" fill="#C4C4C4" />
<rect x="550" y="250" width="10" height="10" fill="#C4C4C4" />
<rect x="550" y="265" width="10" height="10" fill="#C4C4C4" />
<rect x="550" y="280" width="10" height="10" fill="#C4C4C4" />
<rect x="620" y="210" width="20" height="20" fill="#C4C4C4" />
<rect x="370" y="70" width="20" height="30" fill="#303030" />
<rect x="370" y="110" width="20" height="30" fill="#303030" />
<rect x="870" y="440" width="40" height="10" transform="rotate(-90 870 440)" fill="#303030" />
<rect x="890" y="440" width="40" height="10" transform="rotate(-90 890 440)" fill="#303030" />
<rect x="810" y="440" width="40" height="10" transform="rotate(-90 810 440)" fill="#303030" />
<rect x="790" y="440" width="40" height="10" transform="rotate(-90 790 440)" fill="#303030" />
<rect x="270" y="100" width="40" height="10" transform="rotate(-90 270 100)" fill="#303030" />
<rect x="290" y="100" width="40" height="10" transform="rotate(-90 290 100)" fill="#303030" />
<rect x="190" y="100" width="40" height="10" transform="rotate(-90 190 100)" fill="#303030" />
<rect x="170" y="100" width="40" height="10" transform="rotate(-90 170 100)" fill="#303030" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M140 60V170C134.477 170 130 174.477 130 180H90C90 174.477 85.5228 170 80 170V60C85.5228 60 90 55.5228 90 50H130C130 55.5228 134.477 60 140 60Z"
fill="#1E6733" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M130 320H90C90 325.523 85.5229 330 80 330V440C85.5229 440 90 444.477 90 450H130C130 444.477 134.477 440 140 440V330C134.477 330 130 325.523 130 320Z"
fill="#1E6733" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M770 60C775.523 60 780 55.5228 780 50H910C910 55.5228 914.477 60 920 60V100C914.477 100 910 104.477 910 110H780C780 104.477 775.523 100 770 100V60Z"
fill="#1E6733" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M770 400C775.523 400 780 395.523 780 390H910C910 395.523 914.477 400 920 400V440C914.477 440 910 444.477 910 450H780C780 444.477 775.523 440 770 440V400Z"
fill="#1E6733" />
<rect x="750" y="190" width="10" height="40" fill="#ECC333" />
<rect x="750" y="240" width="10" height="20" fill="#ECC333" />
<rect x="400" y="200" width="10" height="40" fill="#ECC333" />
<rect x="400" y="250" width="10" height="20" fill="#ECC333" />
</svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

@ -1,87 +0,0 @@
<svg width="1000" height="500" viewBox="0 0 1000 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="80" y="190" width="60" height="120" fill="#1E6733"/>
<rect x="160" y="50" width="150" height="60" fill="#1E6733"/>
<rect x="140" y="90" width="10" height="50" fill="#ECC333"/>
<rect x="140" y="200" width="10" height="50" fill="#ECC333"/>
<rect x="140" y="410" width="10" height="20" fill="#ECC333"/>
<rect x="140" y="350" width="10" height="50" fill="#ECC333"/>
<rect x="240" y="110" width="70" height="10" fill="#ECC333"/>
<rect x="250" y="130" width="60" height="130" fill="#1E6733"/>
<rect x="340" y="50" width="60" height="120" fill="#1E6733"/>
<rect x="340" y="190" width="60" height="120" fill="#1E6733"/>
<rect x="590" y="190" width="60" height="120" fill="#1E6733"/>
<rect x="690" y="180" width="60" height="120" fill="#1E6733"/>
<rect x="690" y="310" width="60" height="140" fill="#1E6733"/>
<rect x="690" y="50" width="60" height="120" fill="#1E6733"/>
<rect x="590" y="320" width="60" height="130" fill="#1E6733"/>
<rect x="590" y="50" width="60" height="130" fill="#1E6733"/>
<rect x="420" y="240" width="150" height="60" fill="#1E6733"/>
<rect x="340" y="320" width="60" height="130" fill="#1E6733"/>
<rect x="240" y="140" width="10" height="50" fill="#ECC333"/>
<rect x="350" y="170" width="40" height="10" fill="#ECC333"/>
<rect x="330" y="330" width="10" height="50" fill="#ECC333"/>
<rect x="160" y="200" width="80" height="60" fill="#1E6733"/>
<rect x="650" y="200" width="10" height="50" fill="#ECC333"/>
<rect x="750" y="330" width="10" height="60" fill="#ECC333"/>
<rect x="800" y="450" width="40" height="10" fill="#ECC333"/>
<rect x="850" y="450" width="30" height="10" fill="#ECC333"/>
<rect x="750" y="90" width="10" height="50" fill="#ECC333"/>
<rect x="810" y="110" width="60" height="10" fill="#ECC333"/>
<rect x="580" y="330" width="10" height="50" fill="#ECC333"/>
<rect x="580" y="60" width="10" height="50" fill="#ECC333"/>
<rect x="710" y="420" width="20" height="20" fill="#C4C4C4"/>
<rect x="710" y="390" width="20" height="20" fill="#C4C4C4"/>
<rect x="100" y="280" width="20" height="10" fill="#C4C4C4"/>
<rect x="100" y="260" width="20" height="10" fill="#C4C4C4"/>
<rect x="110" y="210" width="20" height="20" fill="#C4C4C4"/>
<rect x="350" y="430" width="40" height="10" fill="#303030"/>
<rect x="350" y="410" width="40" height="10" fill="#303030"/>
<rect x="350" y="390" width="40" height="10" fill="#303030"/>
<rect x="700" y="70" width="20" height="40" fill="#303030"/>
<rect x="700" y="120" width="20" height="40" fill="#303030"/>
<rect x="610" y="280" width="20" height="20" fill="#C4C4C4"/>
<rect x="600" y="240" width="20" height="20" fill="#C4C4C4"/>
<rect x="430" y="250" width="10" height="10" fill="#C4C4C4"/>
<rect x="430" y="265" width="10" height="10" fill="#C4C4C4"/>
<rect x="430" y="280" width="10" height="10" fill="#C4C4C4"/>
<rect x="445" y="250" width="10" height="10" fill="#C4C4C4"/>
<rect x="445" y="265" width="10" height="10" fill="#C4C4C4"/>
<rect x="460" y="280" width="40" height="10" fill="#C4C4C4"/>
<rect x="475" y="250" width="10" height="10" fill="#C4C4C4"/>
<rect x="475" y="265" width="10" height="10" fill="#C4C4C4"/>
<rect x="505" y="250" width="10" height="10" fill="#C4C4C4"/>
<rect x="505" y="265" width="10" height="10" fill="#C4C4C4"/>
<rect x="520" y="265" width="10" height="15" fill="#C4C4C4"/>
<rect x="505" y="280" width="25" height="10" fill="#C4C4C4"/>
<rect x="535" y="250" width="10" height="10" fill="#C4C4C4"/>
<rect x="535" y="265" width="10" height="10" fill="#C4C4C4"/>
<rect x="535" y="280" width="10" height="10" fill="#C4C4C4"/>
<rect x="445" y="280" width="10" height="10" fill="#C4C4C4"/>
<rect x="460" y="250" width="10" height="10" fill="#C4C4C4"/>
<rect x="460" y="265" width="10" height="10" fill="#C4C4C4"/>
<rect x="490" y="250" width="10" height="10" fill="#C4C4C4"/>
<rect x="490" y="265" width="10" height="10" fill="#C4C4C4"/>
<rect x="520" y="250" width="10" height="10" fill="#C4C4C4"/>
<rect x="550" y="250" width="10" height="10" fill="#C4C4C4"/>
<rect x="550" y="265" width="10" height="10" fill="#C4C4C4"/>
<rect x="550" y="280" width="10" height="10" fill="#C4C4C4"/>
<rect x="620" y="210" width="20" height="20" fill="#C4C4C4"/>
<rect x="370" y="70" width="20" height="30" fill="#303030"/>
<rect x="370" y="110" width="20" height="30" fill="#303030"/>
<rect x="870" y="440" width="40" height="10" transform="rotate(-90 870 440)" fill="#303030"/>
<rect x="890" y="440" width="40" height="10" transform="rotate(-90 890 440)" fill="#303030"/>
<rect x="810" y="440" width="40" height="10" transform="rotate(-90 810 440)" fill="#303030"/>
<rect x="790" y="440" width="40" height="10" transform="rotate(-90 790 440)" fill="#303030"/>
<rect x="270" y="100" width="40" height="10" transform="rotate(-90 270 100)" fill="#303030"/>
<rect x="290" y="100" width="40" height="10" transform="rotate(-90 290 100)" fill="#303030"/>
<rect x="190" y="100" width="40" height="10" transform="rotate(-90 190 100)" fill="#303030"/>
<rect x="170" y="100" width="40" height="10" transform="rotate(-90 170 100)" fill="#303030"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M140 60V170C134.477 170 130 174.477 130 180H90C90 174.477 85.5228 170 80 170V60C85.5228 60 90 55.5228 90 50H130C130 55.5228 134.477 60 140 60Z" fill="#1E6733"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M130 320H90C90 325.523 85.5229 330 80 330V440C85.5229 440 90 444.477 90 450H130C130 444.477 134.477 440 140 440V330C134.477 330 130 325.523 130 320Z" fill="#1E6733"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M770 60C775.523 60 780 55.5228 780 50H910C910 55.5228 914.477 60 920 60V100C914.477 100 910 104.477 910 110H780C780 104.477 775.523 100 770 100V60Z" fill="#1E6733"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M770 400C775.523 400 780 395.523 780 390H910C910 395.523 914.477 400 920 400V440C914.477 440 910 444.477 910 450H780C780 444.477 775.523 440 770 440V400Z" fill="#1E6733"/>
<rect x="750" y="190" width="10" height="40" fill="#ECC333"/>
<rect x="750" y="240" width="10" height="20" fill="#ECC333"/>
<rect x="400" y="200" width="10" height="40" fill="#ECC333"/>
<rect x="400" y="250" width="10" height="20" fill="#ECC333"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

@ -1,8 +0,0 @@
<svg width="100%" height="2rem" viewBox="0 0 1 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="zig-zag" x="0" y="0" width="2" height="1">
<path fill="#C2A8EB" d="M 0,0 L 1,1 L 2,0 L 0,0"/>
</pattern>
</defs>
<rect fill="url(#zig-zag)" x="0" y="0" width="100%" height="1" />
</svg>

Before

Width:  |  Height:  |  Size: 344 B

@ -0,0 +1,93 @@
import express from 'express'
import { createServer as createViteServer } from 'vite'
import { getDevRoutesMetadata } from './meta/routes.js'
import fetch from 'node-fetch'
import { readFile } from 'fs/promises'
import { dirname, resolve } from 'path'
import morgan from 'morgan'
import { fileURLToPath } from 'url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
async function main() {
const routes = await getDevRoutesMetadata('http://127.0.0.1:4000/api/development/routes')
console.log('Found static routes:')
for (const [route, file] of Object.entries(routes.static)) {
console.log(`- ${route} -> "${file}"`)
}
console.log('Found dynamic routes:')
for (const [route, file] of Object.entries(routes.dynamic)) {
console.log(`- ${route} -> "${file}"`)
}
const app = express()
app.use(morgan(':method :url :status :response-time ms - :res[content-length]'))
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
})
app.use(vite.middlewares)
for (const [route, file] of Object.entries(routes.static)) {
app.get(route, async (req, res) => {
console.log(`Requested static route "${route}":`)
let htmlPage = await readFile(resolve(__dirname, './frontend/', file), 'utf8')
// Replace "./" with the absolute path of the html page
htmlPage = htmlPage.replace(/\.\//g, '/' + dirname(file) + '/')
console.log(`- applying vite transformations for "${file}"`)
const html = await vite.transformIndexHtml(file, htmlPage)
console.log(`- sending resulting page for "${route}"`)
res.writeHead(200, { 'Content-Type': 'text/html' }).end(html)
})
}
for (const [route, file] of Object.entries(routes.dynamic)) {
app.get(route, async (req, res) => {
console.log(`Requested dynamic route "${route}":`)
let htmlPage = await readFile(resolve(__dirname, './frontend/', file), 'utf8')
// Replace "./" with the absolute path of the html page
htmlPage = htmlPage.replace(/\.\//g, '/' + dirname(file) + '/')
console.log(`- applying vite transformations for "${file}"`)
const html = await vite.transformIndexHtml(file, htmlPage)
console.log(`- applying server transformations for "${file}"`)
const templateHtmlReq = await fetch('http://127.0.0.1:4000/api/development/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
route,
page: html,
request: {
params: req.params,
query: req.query,
},
}),
})
const renderedHtml = await templateHtmlReq.json()
console.log(`- sending resulting page for "${route}"`)
res.writeHead(200, { 'Content-Type': 'text/html' }).end(renderedHtml)
})
}
app.listen(3000, () => {
console.log(`Listening on port 3000...`)
})
}
main()

@ -0,0 +1,35 @@
package config
import (
"git.phc.dm.unipi.it/phc/website/sl"
"github.com/joho/godotenv"
)
var Slot = sl.NewSlot[*Config]()
type Config struct {
Mode string
Host string
}
func Load(l *sl.ServiceLocator) (*Config, error) {
m, err := godotenv.Read(".env")
if err != nil {
return nil, err
}
mode := "production"
if v, ok := m["MODE"]; ok {
mode = v
}
host := ":4000"
if v, ok := m["HOST"]; ok {
host = v
}
return &Config{
mode,
host,
}, nil
}

@ -0,0 +1,63 @@
package database
import (
"fmt"
"git.phc.dm.unipi.it/phc/website/model"
"git.phc.dm.unipi.it/phc/website/sl"
)
var Slot = sl.NewSlot[Database]()
type Database interface {
CreateUser(user model.User) error
ReadUser(id string) (model.User, error)
ReadUsers() ([]model.User, error)
UpdateUser(id string, user model.User) error
DeleteUser(id string) error
}
type Memory struct {
Users []model.User
}
func (m *Memory) CreateUser(user model.User) error {
m.Users = append(m.Users, user)
return nil
}
func (m *Memory) ReadUser(id string) (model.User, error) {
for _, u := range m.Users {
if u.Id == id {
return u, nil
}
}
return model.User{}, fmt.Errorf(`no user with id "%s"`, id)
}
func (m *Memory) ReadUsers() ([]model.User, error) {
return m.Users, nil
}
func (m *Memory) UpdateUser(id string, user model.User) error {
for i, u := range m.Users {
if u.Id == id {
m.Users[i] = user
return nil
}
}
return fmt.Errorf(`no user with id "%s"`, id)
}
func (m *Memory) DeleteUser(id string) error {
for i, u := range m.Users {
if u.Id == id {
m.Users = append(m.Users[:i], m.Users[i+1:]...)
return nil
}
}
return fmt.Errorf(`no user with id "%s"`, id)
}

@ -0,0 +1,58 @@
package articles
import (
"html/template"
"git.phc.dm.unipi.it/phc/website/services/server/dev"
"git.phc.dm.unipi.it/phc/website/services/server/router"
"git.phc.dm.unipi.it/phc/website/sl"
)
func Configure(l *sl.ServiceLocator) error {
router.UseRouteTemplatedPage(l, "/articles",
"pages/articles/index.html",
func(w dev.ResponseWriter, r dev.Request) error {
tmpl := template.New("")
tmpl, err := tmpl.Parse(string(r.Page()))
if err != nil {
return err
}
ctx := map[string]any{
"Example": "Bla bla",
}
if err := tmpl.Execute(w, ctx); err != nil {
return err
}
return nil
},
)
router.UseRouteTemplatedPage(l, "/articles/:slug",
"pages/articles/article.html",
func(w dev.ResponseWriter, r dev.Request) error {
tmpl := template.New("")
tmpl, err := tmpl.Parse(string(r.Page()))
if err != nil {
return err
}
ctx := map[string]any{
"Title": r.Param("slug"),
"Example": "Bla bla " + r.Param("slug"),
}
if err := tmpl.Execute(w, ctx); err != nil {
return err
}
return nil
},
)
return nil
}

@ -0,0 +1,169 @@
package dev
import (
"bytes"
"fmt"
"io"
"log"
"os"
"path"
"git.phc.dm.unipi.it/phc/website/services/config"
"git.phc.dm.unipi.it/phc/website/services/server/routes"
"git.phc.dm.unipi.it/phc/website/sl"
"github.com/alecthomas/repr"
"github.com/gofiber/fiber/v2"
)
// Logger is the debug logger, in the future this will be disabled and discard by default.
var Logger *log.Logger = log.New(os.Stderr, "[services/server/dev] ", log.Lmsgprefix)
// slot represents a private "write only" service
var slot = sl.NewSlot[*devService]()
// InjectInto a [*sl.ServiceLocator] an instance of the dev service
func InjectInto(l *sl.ServiceLocator) {
sl.InjectLazy(l, slot, Configure)
}
func UseRoutesMetadata(l *sl.ServiceLocator) map[string]any {
dev, err := sl.Use(l, slot)
if err != nil {
Logger.Fatal(err)
}
return map[string]any{
"static": dev.staticRoutes,
"dynamic": dev.dynamicRoutes,
}
}
type Request interface {
Page() []byte
Param(key string) string
Query(key string) string
}
type ResponseWriter interface {
io.Writer
}
// devServerRequest is used when handling request from the dev server where params and queries are parsed by express
type devServerRequest struct {
page []byte
params map[string]string
query map[string]string
}
func (r devServerRequest) Page() []byte {
return r.page
}
func (r devServerRequest) Param(key string) string {
return r.params[key]
}
func (r devServerRequest) Query(key string) string {
return r.query[key]
}
// Handler is a custom routes handler
type Handler func(ResponseWriter, Request) error
type devService struct {
staticRoutes map[string]string
dynamicRoutes map[string]string
dynamicRoutesHandlers map[string]Handler
}
func Configure(l *sl.ServiceLocator) (*devService, error) {
d := &devService{
map[string]string{},
map[string]string{},
map[string]Handler{},
}
r, err := sl.Use(l, routes.Root)
if err != nil {
return nil, err
}
config, _ := sl.Use(l, config.Slot)
if config.Mode != "development" {
return d, nil
}
r.Get("/api/development/routes", func(c *fiber.Ctx) error {
return c.JSON(map[string]any{
"static": d.staticRoutes,
"dynamic": d.dynamicRoutes,
})
})
r.Post("/api/development/render", func(c *fiber.Ctx) error {
var data struct {
Route string `json:"route"`
HtmlPage string `json:"page"`
Request struct {
ParamsMap map[string]string `json:"params"`
QueryMap map[string]string `json:"query"`
} `json:"request"`
}
if err := c.BodyParser(&data); err != nil {
return err
}
Logger.Printf(`server rendering route "%s"`, data.Route)
Logger.Printf(`- params: %s`, repr.String(data.Request.ParamsMap))
Logger.Printf(`- query: %s`, repr.String(data.Request.QueryMap))
handler, ok := d.dynamicRoutesHandlers[data.Route]
if !ok {
return fmt.Errorf(`no handler for "%s"`, data.Route)
}
var buf bytes.Buffer
if err := handler(&buf, devServerRequest{
[]byte(data.HtmlPage),
data.Request.ParamsMap,
data.Request.QueryMap,
}); err != nil {
return err
}
return c.JSON(buf.String())
})
return d, nil
}
// RegisterRoute will register the provided "mountPoint" to the "frontendHtml" page
func RegisterRoute(l *sl.ServiceLocator, mountPoint, frontendFile string) {
dev, err := sl.Use(l, slot)
if err != nil {
Logger.Fatal(err)
}
dev.staticRoutes[mountPoint] = frontendFile
Logger.Printf(`registered vite route "%v" for "%v"`, frontendFile, mountPoint)
}
func RegisterDynamicRoute(l *sl.ServiceLocator, mountPoint, frontendFile string, handler Handler) {
dev, err := sl.Use(l, slot)
if err != nil {
Logger.Fatal(err)
}
dev.dynamicRoutes[mountPoint] = frontendFile
dev.dynamicRoutesHandlers[mountPoint] = handler
Logger.Printf(`registered vite route "%v" for "%v"`, frontendFile, mountPoint)
}
func GetArtifactPath(frontendFile string) string {
return path.Join("./out/frontend/", frontendFile)
}

@ -0,0 +1,35 @@
package listautenti
import (
"git.phc.dm.unipi.it/phc/website/services/database"
"git.phc.dm.unipi.it/phc/website/services/server/router"
"git.phc.dm.unipi.it/phc/website/services/server/routes"
"git.phc.dm.unipi.it/phc/website/sl"
"github.com/gofiber/fiber/v2"
)
func Configure(l *sl.ServiceLocator) error {
router.UseRoutePage(l, "/utenti", "pages/lista-utenti/index.html")
db, err := sl.Use(l, database.Slot)
if err != nil {
return err
}
r, err := sl.Use(l, routes.Root)
if err != nil {
return err
}
r.Get("/api/lista-utenti", func(c *fiber.Ctx) error {
users, err := db.ReadUsers()
if err != nil {
return err
}
return c.JSON(users)
})
return nil
}

@ -0,0 +1,78 @@
package listautenti_test
import (
"context"
"fmt"
"io"
"net"
"net/http"
"testing"
"git.phc.dm.unipi.it/phc/website/model"
"git.phc.dm.unipi.it/phc/website/services/database"
"git.phc.dm.unipi.it/phc/website/services/server/routes"
"git.phc.dm.unipi.it/phc/website/sl"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttputil"
)
func Test1(t *testing.T) {
l := sl.New()
sl.InjectValue[database.Database](l, database.Slot, &database.Memory{
Users: []model.User{
{
Id: "claire",
FullName: "Claire Doe",
Nickname: "claire-doe",
AuthSources: map[string]model.AuthSource{},
},
{
Id: "john",
FullName: "John Smith",
Nickname: "john-smith",
AuthSources: map[string]model.AuthSource{},
},
},
})
r := fiber.New()
sl.InjectValue(l, routes.Root, fiber.Router(r))
req, err := http.NewRequest("GET", "http://localhost:4000/api/lista-utenti", nil)
if err != nil {
t.Error(err)
}
ln := fasthttputil.NewInmemoryListener()
defer ln.Close()
go func() {
err := fasthttp.Serve(ln, r.Handler())
if err != nil {
panic(fmt.Errorf("failed to serve: %v", err))
}
}()
client := http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return ln.Dial()
},
},
}
res, err := client.Do(req)
if err != nil {
t.Error(err)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Error(err)
}
t.Log(string(body))
}

@ -0,0 +1,73 @@
package router
import (
"bytes"
"log"
"os"
"git.phc.dm.unipi.it/phc/website/services/server/dev"
"git.phc.dm.unipi.it/phc/website/services/server/routes"
"git.phc.dm.unipi.it/phc/website/sl"
"github.com/gofiber/fiber/v2"
)
// assert type of [ServerRequest] is [dev.Request]
var _ dev.Request = ServerRequest{}
// ServerRequest is used when the request is directly for the Go server
type ServerRequest struct {
page []byte
fiberContext *fiber.Ctx
}
func (r ServerRequest) Page() []byte {
return r.page
}
func (ctx ServerRequest) Param(key string) string {
return ctx.fiberContext.Params(key)
}
func (ctx ServerRequest) Query(key string) string {
return ctx.fiberContext.Query(key)
}
func UseRoutePage(l *sl.ServiceLocator, route, frontendFile string) {
root, err := sl.Use(l, routes.Root)
if err != nil {
log.Fatal(err)
}
dev.RegisterRoute(l, route, frontendFile)
root.Get(route, func(c *fiber.Ctx) error {
return c.SendFile(dev.GetArtifactPath(frontendFile))
})
}
func UseRouteTemplatedPage(l *sl.ServiceLocator, route, frontendFile string, handler dev.Handler) {
r, err := sl.Use(l, routes.Root)
if err != nil {
log.Fatal(err)
}
dev.RegisterDynamicRoute(l, route, frontendFile, handler)
r.Get(route, func(c *fiber.Ctx) error {
rawPage, err := os.ReadFile(dev.GetArtifactPath(frontendFile))
if err != nil {
return err
}
var buf bytes.Buffer
if err := handler(&buf, ServerRequest{
rawPage,
c,
}); err != nil {
return err
}
return c.Type(".html").Send(buf.Bytes())
})
}

@ -0,0 +1,9 @@
package routes
import (
"git.phc.dm.unipi.it/phc/website/sl"
"github.com/gofiber/fiber/v2"
)
var Root = sl.NewSlot[fiber.Router]()

@ -0,0 +1,30 @@
package server
import (
"git.phc.dm.unipi.it/phc/website/services/server/articles"
"git.phc.dm.unipi.it/phc/website/services/server/dev"
"git.phc.dm.unipi.it/phc/website/services/server/listautenti"
"git.phc.dm.unipi.it/phc/website/services/server/routes"
"git.phc.dm.unipi.it/phc/website/sl"
"github.com/gofiber/fiber/v2"
)
type Server struct{ Router *fiber.App }
func Configure(l *sl.ServiceLocator) (*Server, error) {
r := fiber.New(fiber.Config{})
r.Static("/assets", "./out/frontend/assets")
sl.InjectValue(l, routes.Root, fiber.Router(r))
dev.InjectInto(l)
if err := listautenti.Configure(l); err != nil {
return nil, err
}
if err := articles.Configure(l); err != nil {
return nil, err
}
return &Server{r}, nil
}

@ -0,0 +1,127 @@
// The [sl] package has two main concepts, the [ServiceLocator] itself is the main object that one should pass around through the application. A [ServiceLocator] has a list of slots that can be filled with [InjectLazy] and [InjectValue] and retrieved with [Use]. As slots should be unique they can only be created with the [NewSlot] function.
//
// The usual way to use this module is to make slots for go interfaces and then pass implementations using the [InjectValue] and [InjectLazy] functions.
//
// Services can be of various types:
// - a service with no dependencies can be directly injected inside a ServiceLocator using [InjectValue].
// - a service with dependencies on other service should use [InjectLazy]. This lets the service to initialize itself when required and makes the developer not think the topological sort to put onto the DAG of service dependencies.
// - a service can also be private, in this case the slot for a service should be a private field in the service package. This kind of services should also provide a way to inject them into a ServiceLocator.
// - a package also just provide a slot. This is useful for using the ServiceLocator to easily pass around values, effectively threating slots just as dynamically scoped variables.
package sl
import (
"fmt"
"log"
"os"
)
// Logger is the debug logger, in the future this will be disabled and discard by default.
//
// As this is the service locator module it was meaning less to pass this through the ServiceLocator itself (without making the whole module more complex)
var Logger *log.Logger = log.New(os.Stderr, "[sl] ", log.Lmsgprefix)
// slot is just a "typed" unique "symbol".
type slot[T any] *struct{}
// NewSlot is the only way to create instances of the slot type. Each instance is unique.
//
// This then lets you attach a service instance of type "T" to a [ServiceLocator] object.
func NewSlot[T any]() slot[T] {
return slot[T](new(struct{}))
}
// slotEntry represents a service that can lazily initialized (using "createFunc"). Once initialized the instance is kept in the "value" field. The field "typeName" just for debugging purposes.
type slotEntry struct {
createFunc func(*ServiceLocator) (any, error)
created bool
value any
typeName string
}
func (s *slotEntry) checkInitialized(l *ServiceLocator) error {
if !s.created {
v, err := s.createFunc(l)
if err != nil {
return err
}
Logger.Printf(`initialized lazy value of type %T for slot of type %s`, v, s.typeName)
s.created = true
s.value = v
}
return nil
}
// ServiceLocator is the main context passed around to retrive service instances, the interface uses generics so to inject and retrive service instances you should use the functions [InjectValue], [InjectLazy] and [Use].
type ServiceLocator struct {
providers map[any]*slotEntry
}
// New creates a new [ServiceLocator] context to pass around in the application.
func New() *ServiceLocator {
return &ServiceLocator{
providers: map[any]*slotEntry{},
}
}
// InjectValue will inject a concrete instance inside the ServiceLocator "l" for the given "slotKey". This should be used for injecting "static" services, for instances whose construction depend on other services you should use the [InjectLazy] function.
//
// This is generic over "T" to check that instances for the given slot type check as "T" can also be an interface.
func InjectValue[T any](l *ServiceLocator, slotKey slot[T], value T) T {
Logger.Printf(`injected value of type %T for slot of type %s`, value, getTypeName[T]())
l.providers[slotKey] = &slotEntry{
nil,
true,
value,
getTypeName[T](),
}
return value
}
// InjectLazy will inject an instance inside the given ServiceLocator and "slotKey" that is created only when requested with a call to the [Use] function.
//
// This is generic over "T" to check that instances for the given slot type check as "T" can also be an interface.
func InjectLazy[T any](l *ServiceLocator, slotKey slot[T], createFunc func(*ServiceLocator) (T, error)) {
Logger.Printf(`injected lazy for slot of type %s`, getTypeName[T]())
l.providers[slotKey] = &slotEntry{
createFunc: func(l *ServiceLocator) (any, error) {
return createFunc(l)
},
created: false,
value: nil,
typeName: getTypeName[T](),
}
}
// Use retrieves the value of type T associated with the given slot key from the provided ServiceLocator instance.
//
// If the ServiceLocator does not have a value for the slot key, or if the value wasn't correctly initialized (in the case of a lazy slot), an error is returned.
func Use[T any](l *ServiceLocator, slotKey slot[T]) (T, error) {
var zero T
slot, ok := l.providers[slotKey]
if !ok {
return zero, fmt.Errorf(`no injected value for type %s`, getTypeName[T]())
}
err := slot.checkInitialized(l)
if err != nil {
return zero, err
}
v := slot.value.(T)
Logger.Printf(`using slot of type %s with value of type %T`, getTypeName[T](), v)
return v, nil
}
// getTypeName is a trick to get the name of a type (even if it is an interface type)
func getTypeName[T any]() string {
var zero T
return fmt.Sprintf(`%T`, &zero)[1:]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

@ -1,30 +0,0 @@
/**
* @typedef {{
* image?: string,
* course?: string,
* title?: string,
* author: string,
* courseYear: string
* }} AppuntiCardProps
*/
/**
*
* @param {AppuntiCardProps} param0
* @returns
*/
export const AppuntiCard = ({ course, title, author, courseYear }) => {
return (
<div class="appunti-item">
<div class="thumbnail"></div>
{title && <div class="title">{title}</div>}
{course && <div class="course">{course}</div>}
<div class="author">@{author}</div>
<div class="course-year">{courseYear}</div>
</div>
)
}
export const AppuntiList = ({ children }) => {
return <div class="appunti-list">{children}</div>
}

@ -1,66 +0,0 @@
import { type ComponentChildren } from 'preact'
import { useState, useRef, useEffect } from 'preact/hooks'
import { clsx, isMobile } from './lib/util'
import { PhosphorIcon } from './Icon'
export const ComboBox = ({
value,
setValue,
children,
}: {
value: string
setValue: (s: string) => void
children: Record<string, ComponentChildren>
}) => {
const [cloak, setCloak] = useState(true)
const [open, setOpen] = useState(true)
const comboRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (comboRef.current && !comboRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
const [itemWidth, setItemWidth] = useState<number>(200)
useEffect(() => {
setOpen(false)
setCloak(false)
}, [])
return (
<div class="combobox" ref={comboRef} style={{ width: isMobile() ? undefined : itemWidth + 48 + 'px' }}>
<div class="selected" onClick={() => setOpen(!open)}>
<div class="content">{children[value]}</div>
{/* <span class="material-symbols-outlined">expand_more</span> */}
<PhosphorIcon name="caret-down" />
</div>
{open && (
<div
class={clsx('dropdown', cloak && 'invisible')}
ref={el => {
if (!el) return
setItemWidth(el.offsetWidth)
}}
>
{Object.keys(children).map(key => (
<div
class="option"
onClick={() => {
setValue(key)
setOpen(false)
}}
>
{children[key]}
</div>
))}
</div>
)}
</div>
)
}

@ -1,13 +0,0 @@
import { useState } from 'preact/hooks'
export const Counter = ({}) => {
const [count, setCount] = useState(0)
return (
<div class="counter">
<button onClick={() => setCount(value => value - 1)}>-</button>
<div class="value">{count}</div>
<button onClick={() => setCount(value => value + 1)}>+</button>
</div>
)
}

@ -1,119 +0,0 @@
import { useEffect, useState } from 'preact/hooks'
import { FunnelIcon } from '@phosphor-icons/react'
import { marked } from 'marked'
import extendedLatex from '@/client/lib/marked-latex'
marked.use(
extendedLatex({
lazy: false,
render: (formula: string, display: boolean) => {
return display ? '$$' + formula + '$$' : '$' + formula + '$'
},
}),
)
import type { Database } from '@/data/domande-esami.yaml'
const useRemoteValue = <T,>(url: string): T | null => {
const [value, setValue] = useState<T | null>(null)
useEffect(() => {
fetch(url)
.then(response => response.json())
.then(value => setValue(value))
.catch(error => console.error(error))
}, [url])
return value
}
type Props = {
course: string
}
export const DomandeEsamiCourse = ({ course }: Props) => {
const database = useRemoteValue<Database>(`/domande-esami/api/${course}.json`)
if (!database) {
return <>Loading...</>
}
if ('requestIdleCallback' in window) {
// @ts-ignore
requestIdleCallback(() => window.renderMath())
} else {
// @ts-ignore
setTimeout(() => window.renderMath(), 100)
}
const courseTags = [
...new Set(
database.questions.filter(question => question.course === course).flatMap(question => question.tags),
),
]
const [selectedTag, setSelectedTag] = useState<string | null>(null)
const filteredQuestions = database.questions
.filter(question => question.course === course)
.filter(question => (selectedTag ? question.tags.includes(selectedTag) : true))
return (
<>
<div class="grid-center text-center">
<h3>
<a href="/domande-esami">Domande Orali</a>
</h3>
<h1>{database.names[course]}</h1>
</div>
{courseTags.length > 1 && (
<div class="card filter">
<div class="grid-h">
<FunnelIcon />
<strong>Filtra Tag</strong>
</div>
<div class="flex-row-wrap">
{!selectedTag
? courseTags.map(tag => (
<div class="chip clickable" onClick={() => setSelectedTag(tag)}>
{tag}
</div>
))
: courseTags.map(tag => (
<div
class={tag === selectedTag ? 'chip clickable' : 'chip clickable disabled'}
onClick={() => setSelectedTag(tag === selectedTag ? null : tag)}
>
{tag}
</div>
))}
</div>
</div>
)}
<div class="wide-card-list" id="questions">
{filteredQuestions.length === 0 ? (
<div class="grid-center">
<em>No questions found</em>
</div>
) : (
filteredQuestions.map(question => (
<div class="card">
<div
class="text"
dangerouslySetInnerHTML={{
__html: marked(question.content, { async: false }),
}}
/>
<div class="metadata">
{question.tags.map(tag => (
<div class="chip small">{tag}</div>
))}
</div>
</div>
))
)}
</div>
</>
)
}

@ -1,21 +0,0 @@
const icons = Object.fromEntries(
Object.entries(
import.meta.glob<{ default: ImageMetadata }>(`node_modules/@phosphor-icons/core/assets/light/*.svg`, {
eager: true,
}),
).map(([path, module]) => [path.split('/').pop()!.split('.')[0].replace('-light', ''), module]),
)
type Props = {
name: string
}
export const PhosphorIcon = ({ name }: Props) => {
const icon = icons[name]
if (!icon) {
throw new Error(`Icon "${name}" not found`)
}
return <img class="phosphor-icon" src={icon.default.src} alt={name} />
}

@ -1,765 +0,0 @@
import { useState, useEffect } from 'preact/hooks'
// Tipi per la gestione dei dati
type TipoStudente = 'triennale' | 'magistrale'
interface Corso {
nome: string
anno: '1' | '2' | '3' | 'M' | 'istituzioni'
cfu: number
passFailOnly?: boolean // Per materie senza voto (pass/fail)
}
interface CorsoSelezionato {
id: string
nome: string
cfu: number
voto: number | null
lode: boolean
passFailOnly?: boolean // Per materie senza voto (pass/fail)
}
interface CorsoCustom {
nome: string
cfu: number
}
// Dati dei corsi aggiornati dalla tabella ufficiale
const CORSI_DISPONIBILI: Corso[] = [
// Primo Anno
{ nome: 'Analisi matematica 1', anno: '1', cfu: 15 },
{ nome: 'Aritmetica', anno: '1', cfu: 9 },
{ nome: 'Fisica I con laboratorio', anno: '1', cfu: 9 },
{ nome: 'Fondamenti di programmazione con laboratorio', anno: '1', cfu: 9 },
{ nome: 'Geometria 1', anno: '1', cfu: 15 },
{ nome: 'Laboratorio di introduzione alla matematica computazionale', anno: '1', cfu: 6, passFailOnly: true },
{ nome: 'Laboratorio di comunicazione mediante calcolatore', anno: '1', cfu: 3, passFailOnly: true },
// Secondo Anno
{ nome: 'Algebra 1', anno: '2', cfu: 6 },
{ nome: 'Algoritmi e strutture dati', anno: '2', cfu: 6 },
{ nome: 'Analisi matematica 2', anno: '2', cfu: 12 },
{ nome: 'Analisi numerica con laboratorio', anno: '2', cfu: 9 },
{ nome: 'Elementi di probabilità e statistica', anno: '2', cfu: 6 },
{ nome: 'Geometria 2', anno: '2', cfu: 12 },
{ nome: 'Inglese scientifico', anno: '2', cfu: 6, passFailOnly: true },
{ nome: 'Laboratorio didattico di matematica computazionale', anno: '2', cfu: 3, passFailOnly: true },
// Terzo Anno
{ nome: 'Algebra 2', anno: '3', cfu: 6 },
{ nome: 'Analisi matematica 3', anno: '3', cfu: 6 },
{ nome: 'Analisi reale', anno: '3', cfu: 6 },
{ nome: 'Calcolo scientifico', anno: '3', cfu: 6 },
{ nome: 'Elementi di analisi complessa', anno: '3', cfu: 6 },
{ nome: 'Elementi di calcolo delle variazioni', anno: '3', cfu: 6 },
{ nome: 'Elementi di geometria algebrica', anno: '3', cfu: 6 },
{ nome: 'Elementi di meccanica celeste', anno: '3', cfu: 6 },
{ nome: 'Elementi di teoria degli insiemi', anno: '3', cfu: 6 },
{ nome: 'Elementi di teoria delle rappresentazioni', anno: '3', cfu: 6 },
{ nome: 'Elementi di topologia algebrica', anno: '3', cfu: 6 },
{ nome: 'Equazioni alle derivate parziali', anno: '3', cfu: 6 },
{ nome: 'Fisica II', anno: '3', cfu: 9 },
{ nome: 'Fisica III', anno: '3', cfu: 6 },
{ nome: 'Geometria e topologia differenziale', anno: '3', cfu: 6 },
{ nome: 'Gruppi e rappresentazioni', anno: '3', cfu: 6 },
{ nome: 'Laboratorio computazionale', anno: '3', cfu: 6, passFailOnly: true },
{ nome: 'Laboratorio sperimentale di matematica computazionale', anno: '3', cfu: 6, passFailOnly: true },
{ nome: 'Linguaggi di programmazione con laboratorio', anno: '3', cfu: 9 },
{ nome: 'Logica matematica', anno: '3', cfu: 6 },
{ nome: 'Matematiche elementari da un punto di vista superiore: aritmetica', anno: '3', cfu: 6 },
{ nome: 'Matematiche elementari da un punto di vista superiore: geometria', anno: '3', cfu: 6 },
{ nome: 'Meccanica razionale', anno: '3', cfu: 6 },
{ nome: 'Metodi numerici per equazioni differenziali ordinarie', anno: '3', cfu: 6 },
{ nome: 'Metodi topologici in analisi globale', anno: '3', cfu: 6 },
{ nome: 'Ottimizzazione non lineare', anno: '3', cfu: 6 },
{ nome: 'Probabilità', anno: '3', cfu: 6 },
{ nome: 'Ricerca operativa', anno: '3', cfu: 6 },
{ nome: 'Sistemi dinamici', anno: '3', cfu: 6 },
{ nome: 'Spazi di Sobolev', anno: '3', cfu: 6 },
{ nome: 'Statistica matematica', anno: '3', cfu: 6 },
{ nome: 'Storia della matematica', anno: '3', cfu: 6 },
{ nome: 'Teoria algebrica dei numeri 1', anno: '3', cfu: 6 },
{ nome: 'Teoria dei campi e teoria di Galois', anno: '3', cfu: 6 },
{ nome: 'Teoria dei numeri elementare', anno: '3', cfu: 6 },
{ nome: 'Teoria della misura', anno: '3', cfu: 6 },
// Istituzioni (Magistrale)
{ nome: 'Istituzioni di algebra', anno: 'istituzioni', cfu: 11 },
{ nome: 'Istituzioni di analisi matematica', anno: 'istituzioni', cfu: 11 },
{ nome: 'Istituzioni di analisi numerica', anno: 'istituzioni', cfu: 11 },
{ nome: 'Istituzioni di didattica della matematica', anno: 'istituzioni', cfu: 11 },
{ nome: 'Istituzioni di fisica matematica', anno: 'istituzioni', cfu: 11 },
{ nome: 'Istituzioni di geometria', anno: 'istituzioni', cfu: 11 },
{ nome: 'Istituzioni di probabilità', anno: 'istituzioni', cfu: 11 },
// Materie a scelta (Magistrale)
{ nome: '4-varietà', anno: 'M', cfu: 6 },
{ nome: 'Algebra superiore A', anno: 'M', cfu: 6 },
{ nome: 'Algebre e gruppi di Lie', anno: 'M', cfu: 6 },
{ nome: 'Analisi armonica', anno: 'M', cfu: 6 },
{ nome: 'Analisi complessa A', anno: 'M', cfu: 6 },
{ nome: 'Analisi complessa B', anno: 'M', cfu: 6 },
{ nome: 'Analisi convessa', anno: 'M', cfu: 6 },
{ nome: 'Analisi dei dati', anno: 'M', cfu: 6 },
{ nome: 'Analisi non standard', anno: 'M', cfu: 6 },
{ nome: 'Analisi reale', anno: 'M', cfu: 6 },
{ nome: 'Analisi su spazi gaussiani', anno: 'M', cfu: 6 },
{ nome: 'Analisi superiore', anno: 'M', cfu: 6 },
{ nome: 'Analisi superiore A', anno: 'M', cfu: 6 },
{ nome: 'Analisi superiore B', anno: 'M', cfu: 6 },
{ nome: 'Aspetti matematici nella computazione quantistica', anno: 'M', cfu: 6 },
{ nome: 'Calcolo delle variazioni B', anno: 'M', cfu: 6 },
{ nome: 'Calcolo della variazioni A', anno: 'M', cfu: 6 }, // Variante nome
{ nome: 'Combinatoria algebrica', anno: 'M', cfu: 6 },
{ nome: 'Complementi di analisi funzionale', anno: 'M', cfu: 6 },
{ nome: 'Complementi di meccanica razionale', anno: 'M', cfu: 6 },
{ nome: 'Crittografia post-quantistica', anno: 'M', cfu: 6 },
{ nome: 'Curve ellittiche', anno: 'M', cfu: 6 },
{ nome: 'Determinazione orbitale', anno: 'M', cfu: 6 },
{ nome: 'Didattica della matematica e nuove tecnologie', anno: 'M', cfu: 6 },
{ nome: 'Dinamica del sistema solare', anno: 'M', cfu: 6 },
{ nome: 'Dinamica iperbolica', anno: 'M', cfu: 6 },
{ nome: 'Dinamica olomorfa', anno: 'M', cfu: 6 },
{ nome: 'Elementi di calcolo in gruppi omogenei', anno: 'M', cfu: 6 },
{ nome: 'Equazioni della fluidodinamica', anno: 'M', cfu: 6 },
{ nome: 'Equazioni differenziali stocastiche e applicazioni', anno: 'M', cfu: 6 },
{ nome: 'Equazioni ellittiche', anno: 'M', cfu: 6 },
{ nome: 'Finanza matematica', anno: 'M', cfu: 6 },
{ nome: 'Fisica matematica', anno: 'M', cfu: 6 },
{ nome: 'Forme modulari', anno: 'M', cfu: 6 },
{ nome: 'Geometria algebrica B', anno: 'M', cfu: 6 },
{ nome: 'Geometria algebrica C', anno: 'M', cfu: 6 },
{ nome: 'Geometria algebrica complessa', anno: 'M', cfu: 6 },
{ nome: 'Geometria algebrica D', anno: 'M', cfu: 6 },
{ nome: 'Geometria algebrica E', anno: 'M', cfu: 6 },
{ nome: 'Geometria algebrica F', anno: 'M', cfu: 6 },
{ nome: 'Geometria algebrica G', anno: 'M', cfu: 6 },
{ nome: 'Geometria e analisi complessa', anno: 'M', cfu: 6 },
{ nome: 'Geometria differenziale complessa', anno: 'M', cfu: 6 },
{ nome: 'Geometria iperbolica', anno: 'M', cfu: 6 },
{ nome: 'Geometria riemanniana', anno: 'M', cfu: 6 },
{ nome: 'Gruppi algebrici lineari', anno: 'M', cfu: 6 },
{ nome: 'Gruppi di Coxeter', anno: 'M', cfu: 6 },
{ nome: 'Gruppi di Galois e gruppi fondamentali', anno: 'M', cfu: 6 },
{ nome: 'Meccanica celeste', anno: 'M', cfu: 6 },
{ nome: 'Meccanica spaziale', anno: 'M', cfu: 6 },
{ nome: 'Meccanica superiore', anno: 'M', cfu: 6 },
{ nome: 'Metodi di analisi armonica in analisi non lineare', anno: 'M', cfu: 6 },
{ nome: 'Metodi di approssimazione', anno: 'M', cfu: 6 },
{ nome: 'Metodi matematici della crittografia', anno: 'M', cfu: 6 },
{ nome: 'Metodi matematici della meccanica quantistica', anno: 'M', cfu: 6 },
{ nome: 'Metodi numerici per catene di Markov', anno: 'M', cfu: 6 },
{ nome: 'Metodi numerici per equazioni alle derivate parziali', anno: 'M', cfu: 6 },
{ nome: 'Metodi numerici per il calcolo tensoriale', anno: 'M', cfu: 6 },
{ nome: 'Metodi numerici per il controllo ottimo', anno: 'M', cfu: 6 },
{ nome: 'Metodi numerici per la grafica', anno: 'M', cfu: 6 },
{ nome: 'Metodi numerici per problemi inversi', anno: 'M', cfu: 6 },
{ nome: "Metodi probabilistici per l'algebra lineare numerica", anno: 'M', cfu: 6 },
{ nome: 'Modelli matematici in biomedicina e fisica matematica', anno: 'M', cfu: 6 },
{ nome: 'Origini e sviluppo delle matematiche moderne', anno: 'M', cfu: 6 },
{ nome: 'Probabilità superiore', anno: 'M', cfu: 6 },
{ nome: 'Problemi e metodi della ricerca in didattica della matematica', anno: 'M', cfu: 6 },
{ nome: 'Problemi e metodi in storia della matematica', anno: 'M', cfu: 6 },
{ nome: 'Sistemi dinamici aleatori', anno: 'M', cfu: 6 },
{ nome: 'Statistica superiore', anno: 'M', cfu: 6 },
{ nome: 'Storia della matematica antica e della sua tradizione', anno: 'M', cfu: 6 },
{ nome: 'Superfici di Riemann e curve algebriche', anno: 'M', cfu: 6 },
{ nome: 'Tecnologie per la didattica', anno: 'M', cfu: 6 },
{ nome: 'Teoria algebrica dei numeri 2', anno: 'M', cfu: 6 },
{ nome: 'Teoria analitica dei numeri A', anno: 'M', cfu: 6 },
{ nome: 'Teoria dei giochi', anno: 'M', cfu: 6 },
{ nome: 'Teoria dei modelli', anno: 'M', cfu: 6 },
{ nome: 'Teoria dei nodi A', anno: 'M', cfu: 6 },
{ nome: 'Teoria degli insiemi', anno: 'M', cfu: 6 },
{ nome: 'Teoria degli insiemi A', anno: 'M', cfu: 6 },
{ nome: 'Teoria degli insiemi B', anno: 'M', cfu: 6 },
{ nome: 'Teoria delle categorie', anno: 'M', cfu: 6 },
{ nome: 'Teoria delle rappresentazioni A', anno: 'M', cfu: 6 },
{ nome: "Teoria e metodi dell'ottimizzazione", anno: 'M', cfu: 6 },
{ nome: 'Teoria ergodica', anno: 'M', cfu: 6 },
{ nome: 'Teoria geometrica della misura', anno: 'M', cfu: 6 },
{ nome: 'Topologia algebrica', anno: 'M', cfu: 6 },
{ nome: 'Topologia algebrica A', anno: 'M', cfu: 6 },
{ nome: 'Topologia algebrica B', anno: 'M', cfu: 6 },
{ nome: 'Topologia differenziale', anno: 'M', cfu: 6 },
{ nome: 'Topologia e geometria in bassa dimensione', anno: 'M', cfu: 6 },
{ nome: 'Ultrafiltri e metodi non-standard', anno: 'M', cfu: 6 },
]
export function MediaPesataApp() {
// Funzioni per localStorage
const loadFromStorage = () => {
try {
const savedData = localStorage.getItem('media-pesata-data')
if (savedData) {
const parsed = JSON.parse(savedData)
return {
tipoStudente: parsed.tipoStudente || 'triennale',
corsiSelezionati: parsed.corsiSelezionati || [],
sezioniAperte: parsed.sezioniAperte || {},
mostraRisultati: parsed.mostraRisultati || false,
}
}
} catch (error) {
console.warn('Errore nel caricamento dei dati salvati:', error)
}
return {
tipoStudente: 'triennale' as TipoStudente,
corsiSelezionati: [],
sezioniAperte: {},
mostraRisultati: false,
}
}
const saveToStorage = (data: any) => {
try {
localStorage.setItem('media-pesata-data', JSON.stringify(data))
} catch (error) {
console.warn('Errore nel salvataggio dei dati:', error)
}
}
// Inizializzazione con dati salvati
// const initialData = loadFromStorage()
const [tipoStudente, setTipoStudente] = useState<TipoStudente>('triennale')
const [corsiSelezionati, setCorsiSelezionati] = useState<CorsoSelezionato[]>([])
const [showCustomForm, setShowCustomForm] = useState(false)
const [customCorso, setCustomCorso] = useState<CorsoCustom>({ nome: '', cfu: 0 })
const [sezioniAperte, setSezioniAperte] = useState<Record<string, boolean>>({})
const [mostraRisultati, setMostraRisultati] = useState(false)
// Load data from localStorage on mount
useEffect(() => {
const initialData = loadFromStorage()
setTipoStudente(initialData.tipoStudente)
setCorsiSelezionati(initialData.corsiSelezionati)
setSezioniAperte(initialData.sezioniAperte)
setMostraRisultati(initialData.mostraRisultati)
}, [])
// Salva automaticamente quando cambiano i dati importanti
useEffect(() => {
const dataToSave = {
tipoStudente,
corsiSelezionati,
sezioniAperte,
mostraRisultati,
}
saveToStorage(dataToSave)
}, [tipoStudente, corsiSelezionati, sezioniAperte, mostraRisultati])
const toggleSezione = (nomeSezione: string) => {
setSezioniAperte(prev => ({
...prev,
[nomeSezione]: !prev[nomeSezione],
}))
}
const calcolaMedia = () => {
const corsiConVoto = corsiSelezionati.filter(corso => corso.voto !== null && !corso.passFailOnly)
if (corsiConVoto.length === 0) {
alert('Inserisci almeno un voto per calcolare la media!')
return
}
setMostraRisultati(true)
}
// Funzioni per la gestione dei corsi
const aggiungiCorso = (corso: Corso) => {
// Controlla se la materia esiste già
const materiaEsiste = corsiSelezionati.some(c => c.nome.toLowerCase() === corso.nome.toLowerCase())
if (materiaEsiste) {
alert('Questa materia è già stata aggiunta')
return
}
// Controllo per magistrali: massimo 3 istituzioni
if (tipoStudente === 'magistrale' && corso.anno === 'istituzioni') {
const istituzioniAttuali = corsiSelezionati.filter(c => c.nome.toLowerCase().includes('istituzioni')).length
if (istituzioniAttuali >= 3) {
alert('Puoi selezionare al massimo 3 istituzioni per la magistrale')
return
}
}
const id = Date.now().toString()
const nuovoCorso: CorsoSelezionato = {
id,
nome: corso.nome,
cfu: corso.cfu,
voto: null,
lode: false,
passFailOnly: corso.passFailOnly,
}
setCorsiSelezionati([...corsiSelezionati, nuovoCorso])
}
const aggiungiCorsoCustom = () => {
// Validazioni
if (!customCorso.nome.trim()) {
alert('Il nome della materia non può essere vuoto')
return
}
if (customCorso.cfu <= 0 || customCorso.cfu > 30) {
alert('I CFU devono essere tra 1 e 30')
return
}
if (!Number.isInteger(customCorso.cfu)) {
alert('I CFU devono essere un numero intero')
return
}
// Controlla se la materia esiste già
const materiaEsiste = corsiSelezionati.some(
corso => corso.nome.toLowerCase().trim() === customCorso.nome.toLowerCase().trim(),
)
if (materiaEsiste) {
alert('Questa materia è già stata aggiunta')
return
}
const id = Date.now().toString()
const nuovoCorso: CorsoSelezionato = {
id,
nome: customCorso.nome.trim(),
cfu: customCorso.cfu,
voto: null,
lode: false,
}
setCorsiSelezionati([...corsiSelezionati, nuovoCorso])
setCustomCorso({ nome: '', cfu: 0 })
setShowCustomForm(false)
}
const rimuoviCorso = (id: string) => {
setCorsiSelezionati(corsiSelezionati.filter(corso => corso.id !== id))
}
const aggiornaVoto = (id: string, voto: number | null) => {
// Validazione voto: deve essere tra 18 e 30 e intero
if (voto !== null && (voto < 18 || voto > 30 || !Number.isInteger(voto))) {
return // Ignora valori non validi
}
setCorsiSelezionati(
corsiSelezionati.map(corso =>
corso.id === id ? { ...corso, voto, lode: voto !== 30 ? false : corso.lode } : corso,
),
)
}
const aggiornaLode = (id: string, lode: boolean) => {
setCorsiSelezionati(corsiSelezionati.map(corso => (corso.id === id ? { ...corso, lode } : corso)))
}
const resetTutto = () => {
if (corsiSelezionati.length > 0) {
if (confirm('Sei sicuro di voler cancellare tutte le materie selezionate?')) {
setCorsiSelezionati([])
setCustomCorso({ nome: '', cfu: 0 })
setShowCustomForm(false)
setSezioniAperte({})
setMostraRisultati(false)
// Pulisce anche il localStorage
try {
localStorage.removeItem('media-pesata-data')
} catch (error) {
console.warn('Errore nella pulizia del localStorage:', error)
}
}
}
}
// Calcoli della media pesata
const calcolaMediaPesata = () => {
// Escludi le materie pass/fail dal calcolo
const corsiConVoto = corsiSelezionati.filter(corso => corso.voto !== null && !corso.passFailOnly)
if (corsiConVoto.length === 0) {
return {
mediaPesata: 0,
votoAmmissione: 0,
massimoVotoLaurea: 0,
conLode: false,
bonusLodi: 0,
errore: 'Nessun voto inserito per il calcolo della media',
}
}
// Calcola CFU totali con voto
const cfuTotaliConVoto = corsiConVoto.reduce((sum, corso) => sum + corso.cfu, 0)
const cfuDaEscludere = tipoStudente === 'triennale' ? 15 : 9
// Se i CFU sono insufficienti per l'esclusione, avvisa l'utente
if (cfuTotaliConVoto < cfuDaEscludere) {
return {
mediaPesata: 0,
votoAmmissione: 0,
massimoVotoLaurea: 0,
conLode: false,
bonusLodi: 0,
errore: `Hai inserito solo ${cfuTotaliConVoto} CFU con voto. Servono almeno ${cfuDaEscludere} CFU per applicare le regole di esclusione.`,
}
}
// Ordina per voto crescente
const corsiOrdinati = [...corsiConVoto].sort((a, b) => a.voto! - b.voto!)
let cfuEsclusi = 0
const corsiValidi: CorsoSelezionato[] = []
for (const corso of corsiOrdinati) {
if (cfuEsclusi < cfuDaEscludere) {
const cfuRimanentiDaEscludere = cfuDaEscludere - cfuEsclusi
if (corso.cfu <= cfuRimanentiDaEscludere) {
// Escludi tutto il corso
cfuEsclusi += corso.cfu
} else {
// Escludi solo una parte del corso
const cfuValidi = corso.cfu - cfuRimanentiDaEscludere
corsiValidi.push({ ...corso, cfu: cfuValidi })
cfuEsclusi = cfuDaEscludere
}
} else {
corsiValidi.push(corso)
}
}
// Calcola media pesata
const sommaPesata = corsiValidi.reduce((sum, corso) => sum + corso.voto! * corso.cfu, 0)
const sommaCfu = corsiValidi.reduce((sum, corso) => sum + corso.cfu, 0)
const mediaPesata = sommaCfu > 0 ? sommaPesata / sommaCfu : 0
// Calcola bonus lodi
const bonusLodi = corsiConVoto.reduce((bonus, corso) => {
if (corso.lode) {
return bonus + (corso.cfu > 6 ? 0.5 : 0.25)
}
return bonus
}, 0)
// Cap del bonus lodi basato sul tipo di studente
const capBonusLodi = tipoStudente === 'triennale' ? 1.5 : 2
const bonusLodiFinal = Math.min(bonusLodi, capBonusLodi)
// Voto di ammissione alla laurea (media pesata * 11/3)
const votoAmmissione = (mediaPesata * 11) / 3
// Voto di ammissione finale = voto ammissione + bonus lodi
const votoAmmissioneFinale = votoAmmissione + bonusLodiFinal
// Massimo voto di laurea possibile = voto ammissione + 10 (cappato a 110)
const massimoVotoLaurea = Math.min(votoAmmissioneFinale + 10, 110)
const conLode = massimoVotoLaurea === 110
return {
mediaPesata: Math.round(mediaPesata * 100) / 100,
votoAmmissione: Math.round(votoAmmissioneFinale * 100) / 100,
massimoVotoLaurea: Math.round(massimoVotoLaurea * 100) / 100,
conLode,
bonusLodi: Math.round(bonusLodiFinal * 100) / 100,
errore: null,
}
}
// Filtra corsi disponibili in base al tipo di studente
const getCorsiDisponibili = () => {
if (tipoStudente === 'triennale') {
return CORSI_DISPONIBILI.filter(corso => corso.anno !== 'istituzioni')
} else {
return CORSI_DISPONIBILI.filter(
corso => corso.anno === 'istituzioni' || corso.anno === '3' || corso.anno === 'M',
)
}
}
// Raggruppa corsi per categoria
const raggruppaCorsi = () => {
const corsi = getCorsiDisponibili()
const gruppi: Record<string, Corso[]> = {}
if (tipoStudente === 'triennale') {
gruppi['Primo Anno'] = corsi.filter(c => c.anno === '1')
gruppi['Secondo Anno'] = corsi.filter(c => c.anno === '2')
gruppi['Terzo Anno'] = corsi.filter(c => c.anno === '3')
gruppi['Materie a Scelta'] = corsi.filter(c => c.anno === 'M')
} else {
// Per magistrali: prima le istituzioni, poi tutto il resto come "Materie a Scelta"
gruppi['Istituzioni'] = corsi.filter(c => c.anno === 'istituzioni')
gruppi['Materie a Scelta'] = corsi.filter(c => c.anno === '3' || c.anno === 'M')
}
return gruppi
}
const cambiaTipoStudente = (nuovoTipo: TipoStudente) => {
setTipoStudente(nuovoTipo)
}
const gruppiCorsi = raggruppaCorsi()
const risultati = calcolaMediaPesata()
const totaleCfu = corsiSelezionati.reduce((sum, corso) => sum + corso.cfu, 0)
const maxCfu = tipoStudente === 'triennale' ? 171 : 93 // 180 9 e 120 27 per la tesi
const cfuError = totaleCfu > maxCfu
return (
<div class="media-pesata-app">
{/* Selezione tipo studente */}
<div class="card student-type-switcher wide">
<div class="grid-center">
<h2>Corso di Laurea</h2>
<div class="compound-button">
<button
class={tipoStudente === 'triennale' ? 'active' : ''}
onClick={() => cambiaTipoStudente('triennale')}
>
Triennale
</button>
<button
class={tipoStudente === 'magistrale' ? 'active' : ''}
onClick={() => cambiaTipoStudente('magistrale')}
>
Magistrale
</button>
</div>
</div>
</div>
{/* Counter CFU */}
<div class={`cfu-counter wide ${cfuError ? 'error' : ''}`}>
<h3>
CFU Totali: {totaleCfu}/{maxCfu}
{tipoStudente === 'triennale' ? ' (+9 tesi)' : ' (+27 tesi)'}
</h3>
{cfuError && <p class="error-text"> Hai superato il limite di CFU consentiti!</p>}
</div>
{/* Sezione selezione corsi */}
<div class="card">
<div class="title">
<h2>Seleziona Materie</h2>
</div>
{Object.entries(gruppiCorsi).map(([categoria, corsi]) => (
<div key={categoria} class="course-category">
<button onClick={() => toggleSezione(categoria)}>
<div class="h-flex">
{categoria}
<div class="spacer"></div>
<span class={`toggle-icon ${sezioniAperte[categoria] ? 'expanded' : ''}`}></span>
</div>
</button>
{sezioniAperte[categoria] && (
<div class="course-grid">
{corsi.map((corso, index) => (
<button
key={index}
class="course-button"
onClick={() => aggiungiCorso(corso)}
disabled={corsiSelezionati.some(c => c.nome === corso.nome)}
>
<span class="course-name">{corso.nome}</span>
<span class="course-cfu">{corso.cfu} CFU</span>
</button>
))}
</div>
)}
</div>
))}
{/* Form per materia custom */}
<div class="custom-course">
<h3>Materia Personalizzata</h3>
{!showCustomForm ? (
<button onClick={() => setShowCustomForm(true)}>+ Aggiungi Materia Personalizzata</button>
) : (
<div class="custom-form">
<input
type="text"
placeholder="Nome materia"
value={customCorso.nome}
onChange={e =>
setCustomCorso({ ...customCorso, nome: (e.target as HTMLInputElement).value })
}
/>
<input
type="number"
placeholder="CFU"
min="1"
max="30"
step="1"
value={customCorso.cfu || ''}
onChange={e =>
setCustomCorso({
...customCorso,
cfu: parseInt((e.target as HTMLInputElement).value) || 0,
})
}
/>
<button onClick={aggiungiCorsoCustom}>Aggiungi</button>
<button onClick={() => setShowCustomForm(false)}>Annulla</button>
</div>
)}
</div>
</div>
{/* Sezione lista corsi selezionati */}
<div class="card">
<div class="h-flex">
<div class="title">
<h2>Materie Selezionate</h2>
</div>
<div class="spacer"></div>
{corsiSelezionati.length > 0 && <button onClick={resetTutto}>🗑 Cancella Tutto</button>}
</div>
{corsiSelezionati.length === 0 ? (
<p>Nessuna materia selezionata</p>
) : (
<div class="courses-list">
{corsiSelezionati.map(corso => (
<div key={corso.id} class="course-item">
<span class="course-name">{corso.nome}</span>
<span class="course-cfu">{corso.cfu} CFU</span>
{!corso.passFailOnly && (
<div class="course-grade tall">
<input
type="number"
placeholder="Voto"
min="18"
max="30"
step="1"
value={corso.voto || ''}
onChange={e =>
aggiornaVoto(
corso.id,
parseInt((e.target as HTMLInputElement).value) || null,
)
}
/>
<label class={`lode-checkbox ${corso.voto !== 30 ? 'disabled' : ''}`}>
<input
class="star"
type="checkbox"
checked={corso.lode}
disabled={corso.voto !== 30}
onChange={e =>
aggiornaLode(corso.id, (e.target as HTMLInputElement).checked)
}
/>
Lode
</label>
</div>
)}
<div class="actions tall">
<button
class="icon remove-btn"
onClick={() => rimuoviCorso(corso.id)}
title="Rimuovi materia"
>
×
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Pulsante Calcola */}
{corsiSelezionati.length > 0 && (
<div class="calculate-section wide">
<button onClick={calcolaMedia}>🧮 Calcola Media e Voto di Laurea</button>
</div>
)}
{/* Risultati */}
{risultati && mostraRisultati && (
<div class="results wide">
<h2>Risultati</h2>
{risultati.errore ? (
<div class="error-message">
<p> {risultati.errore}</p>
</div>
) : (
<div class="results-grid">
<div class="result-item">
<span class="label">Media Pesata:</span>
<span class="value">{risultati.mediaPesata}</span>
</div>
<div class="result-item">
<span class="label">Bonus Lodi:</span>
<span class="value">+{risultati.bonusLodi}</span>
</div>
<div class="result-item highlight">
<span class="label">Voto di Ammissione:</span>
<span class="value">{risultati.votoAmmissione}</span>
</div>
<div class="result-item highlight">
<span class="label">Massimo Voto Di Laurea Possibile:</span>
<span class="value">
{risultati.massimoVotoLaurea}
{risultati.conLode && <span class="lode-badge">(+lode)</span>}
</span>
</div>
</div>
)}
</div>
)}
{/* Nota informativa */}
<div class="card wide">
<h3>📋 Come viene calcolata la media</h3>
<div class="info-content">
<h4>Regole di esclusione CFU:</h4>
<ul>
<li>
<strong>Triennale:</strong> I 15 CFU con i voti più bassi vengono esclusi dalla media
</li>
<li>
<strong>Magistrale:</strong> I 9 CFU con i voti più bassi vengono esclusi dalla media
</li>
<li>Se un corso ha più CFU di quelli da escludere, viene diviso proporzionalmente</li>
</ul>
<h4>Calcolo del voto finale:</h4>
<ul>
<li>
<strong>Media pesata:</strong> Somma dei (voto × CFU) diviso per i CFU totali
</li>
<li>
<strong>Bonus lodi:</strong> +0.5 per lodi in materie &gt; 6 CFU, +0.25 per lodi in materie
6 CFU (max +1.5 per triennale, max +2 per magistrale)
</li>
<li>
<strong>Voto di laurea:</strong> (Voto finale × 11) ÷ 3
</li>
</ul>
<h4>Note:</h4>
<ul>
<li>
Le materie <strong>Pass/Fail</strong> non contribuiscono al calcolo della media
</li>
<li>Il voto finale è limitato a 30</li>
<li>Per i magistrali: massimo 3 istituzioni selezionabili</li>
</ul>
</div>
</div>
</div>
)
}
// Funzione per inizializzare l'app
// export function initMediaPesataApp() {
// const container = document.getElementById('media-pesata-app')
// if (container) {
// render(<MediaPesataApp />, container)
// }
// }
// export default MediaPesataApp

@ -1,29 +0,0 @@
import { useComputed, useSignal, type ReadonlySignal } from '@preact/signals'
import type { JSX } from 'preact/jsx-runtime'
export const ShowMore = <T extends any>({
items,
pageSize,
children,
}: {
items: ReadonlySignal<T[]>
pageSize: number
children: (item: T) => JSX.Element
}) => {
const $shownItems = useSignal(pageSize)
const $paginatedItems = useComputed(() => {
return items.value.slice(0, $shownItems.value)
})
return (
<>
{$paginatedItems.value.map(children)}
<div class="show-more">
{$shownItems.value < items.value.length && (
<button onClick={() => ($shownItems.value += pageSize)}>Mostra altri</button>
)}
</div>
</>
)
}

@ -1,166 +0,0 @@
import { useComputed, useSignal } from '@preact/signals'
import Fuse from 'fuse.js'
import { useEffect } from 'preact/hooks'
import { ShowMore } from './Paginate'
import { ComboBox } from './ComboBox'
import { PhosphorIcon } from './Icon'
type User = {
uid: string
gecos: string
}
const FILTERS = {
utenti: {
icon: 'user',
label: 'Utenti',
},
macchinisti: {
icon: 'wrench',
label: 'Macchinisti',
},
rappstud: {
icon: 'bank',
label: 'Rappresentanti',
},
}
function applyPatches(users: User[]) {
users.forEach(user => {
// strip ",+" from the end of the gecos field
user.gecos = user.gecos.replace(/,+$/, '')
// capitalize the first letter of each word
user.gecos = user.gecos.replace(/\b\w/g, c => c.toUpperCase())
})
// reverse the order of the users
users.reverse()
}
const MACCHINISTI = ['delucreziis', 'minnocci', 'baldino', 'manicastri', 'llombardo', 'serdyuk']
const RAPPSTUD = [
'smannella',
'lotti',
'rotolo',
'saccani',
'carbone',
'mburatti',
'ppuddu',
'marinari',
'evsilvestri',
'tateo',
'graccione',
'dilella',
'rocca',
'odetti',
'borso',
'numero',
]
export const UtentiPage = () => {
const $utentiData = useSignal<User[]>([])
const $filter = useSignal('utenti')
const $filteredData = useComputed(() =>
$filter.value === 'macchinisti'
? $utentiData.value.filter(user => MACCHINISTI.includes(user.uid))
: $filter.value === 'rappstud'
? $utentiData.value.filter(user => RAPPSTUD.includes(user.uid))
: $utentiData.value,
)
const $fuse = useComputed(
() =>
new Fuse($filteredData.value, {
keys: ['gecos', 'uid'],
}),
)
const $searchText = useSignal('')
const $searchResults = useComputed(() =>
$searchText.value.trim().length > 0
? ($fuse.value?.search($searchText.value).map(result => result.item) ?? [])
: $filteredData.value,
)
useEffect(() => {
fetch('https://poisson.phc.dm.unipi.it/users.json')
.then(response => response.json())
.then(data => {
applyPatches(data)
$utentiData.value = data
$fuse.value.setCollection(data)
})
}, [])
return (
<>
<div class="search-bar">
<ComboBox value={$filter.value} setValue={s => ($filter.value = s)}>
{Object.fromEntries(
Object.entries(FILTERS).map(([k, v]) => [
k,
<>
{/* <span class="material-symbols-outlined">{v.icon}</span> {v.label} */}
<PhosphorIcon name={v.icon} />
{v.label}
</>,
]),
)}
</ComboBox>
<div class="search">
<input
type="text"
placeholder="Cerca un utente Poisson..."
onInput={e => ($searchText.value = e.currentTarget.value)}
value={$searchText.value}
/>
{/* <span class="material-symbols-outlined">search</span> */}
<PhosphorIcon name="magnifying-glass" />
</div>
</div>
<div class="search-results">
{$searchResults.value ? (
<ShowMore items={$searchResults} pageSize={100}>
{poissonUser => (
<div class="search-result">
<div class="icon">
{/* <span class="material-symbols-outlined">
{RAPPSTUD.includes(poissonUser.uid)
? 'account_balance'
: MACCHINISTI.includes(poissonUser.uid)
? 'construction'
: 'person'}
</span> */}
<PhosphorIcon
name={
RAPPSTUD.includes(poissonUser.uid)
? 'bank'
: MACCHINISTI.includes(poissonUser.uid)
? 'wrench'
: 'user'
}
/>
</div>
<div class="text">{poissonUser.gecos}</div>
<div class="right">
<a href={`https://poisson.phc.dm.unipi.it/~${poissonUser.uid}`} target="_blank">
{/* <span class="material-symbols-outlined">open_in_new</span> */}
<PhosphorIcon name="arrow-square-out" />
</a>
</div>
</div>
)}
</ShowMore>
) : (
<>Nessun risultato</>
)}
</div>
</>
)
}

@ -1,27 +0,0 @@
const $debugConsole = document.createElement('div')
$debugConsole.style.position = 'fixed'
$debugConsole.style.bottom = '0'
$debugConsole.style.left = '0'
$debugConsole.style.width = '100%'
$debugConsole.style.height = '25vh'
$debugConsole.style.backgroundColor = 'black'
$debugConsole.style.color = 'white'
$debugConsole.style.overflow = 'auto'
$debugConsole.style.padding = '10px'
$debugConsole.style.boxSizing = 'border-box'
$debugConsole.style.fontFamily = 'monospace'
$debugConsole.style.zIndex = '9999'
$debugConsole.style.fontSize = '15px'
$debugConsole.style.opacity = '0.8'
document.body.appendChild($debugConsole)
function logDebugConsole(...args) {
$debugConsole.innerHTML += args.join(' ') + '<br>'
}
console.error = logDebugConsole
console.warn = logDebugConsole
console.log = logDebugConsole
console.debug = logDebugConsole

@ -1,83 +0,0 @@
// took from: https://github.com/sxyazi/marked-extended-latex
// this has a peer dependency bug
const CLASS_NAME = 'latex-b172fea480b'
const extBlock = options => ({
name: 'latex-block',
level: 'block',
start(src) {
return src.match(/\$\$[^\$]/)?.index ?? -1
},
tokenizer(src, _tokens) {
const match = /^\$\$([^\$]+)\$\$/.exec(src)
return match ? { type: 'latex-block', raw: match[0], formula: match[1] } : undefined
},
renderer(token) {
if (!options.lazy) return options.render(token.formula, true)
return `<span class="${CLASS_NAME}" block>${token.formula}</span>`
},
})
const extInline = options => ({
name: 'latex',
level: 'inline',
start(src) {
return src.match(/\$[^\$]/)?.index ?? -1
},
tokenizer(src, _tokens) {
const match = /^\$([^\$]+)\$/.exec(src)
return match ? { type: 'latex', raw: match[0], formula: match[1] } : undefined
},
renderer(token) {
if (!options.lazy) return options.render(token.formula, false)
return `<span class="${CLASS_NAME}">${token.formula}</span>`
},
})
let observer
/* istanbul ignore next */
export default (options = {}) => {
/* istanbul ignore next */
if (options.lazy && options.env !== 'test') {
observer = new IntersectionObserver(
(entries, self) => {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue
}
const span = entry.target
self.unobserve(span)
Promise.resolve(options.render(span.innerText, span.hasAttribute('block'))).then(html => {
span.innerHTML = html
})
span.classList.add('latex-rendered')
}
},
{ threshold: 1.0 },
)
}
return {
extensions: [extBlock(options), extInline(options)],
}
}
/* istanbul ignore next */
export const observe = () => {
if (!observer) {
return
}
observer.disconnect()
document.querySelectorAll(`span.${CLASS_NAME}:not(.latex-rendered)`).forEach(span => {
observer.observe(span)
})
}
/* istanbul ignore next */
export const disconnect = () => {
observer?.disconnect()
}

@ -1,69 +0,0 @@
import { useEffect, useState } from 'preact/hooks'
export const trottleDebounce = <T extends any[], R>(
fn: (...args: T) => R,
delay: number,
options: { leading?: boolean; trailing?: boolean } = {},
): ((...args: T) => R | undefined) => {
let lastCall = 0
let lastResult: R | undefined
let lastArgs: T | undefined
let timeout: NodeJS.Timeout | undefined
const leading = options.leading ?? true
const trailing = options.trailing ?? true
return (...args: T): R | undefined => {
lastArgs = args
if (leading && Date.now() - lastCall >= delay) {
lastCall = Date.now()
lastResult = fn(...args)
} else {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
if (trailing && lastArgs) {
lastCall = Date.now()
lastResult = fn(...lastArgs)
}
}, delay)
}
return lastResult
}
}
export type ClassValue = string | ClassValue[] | Record<string, boolean> | false | undefined
export function clsx(...args: ClassValue[]): string {
return args
.flatMap(arg => {
if (typeof arg === 'string') {
return arg
} else if (Array.isArray(arg)) {
return clsx(...arg)
} else if (typeof arg === 'boolean') {
return []
} else if (typeof arg === 'object') {
return Object.entries(arg).flatMap(([key, value]) => (value ? key : []))
} else {
return []
}
})
.join(' ')
}
export const isMobile = () => {
const [windowWidth, setWindowWidth] = useState(0)
useEffect(() => {
setWindowWidth(window.innerWidth)
const handleResize = () => setWindowWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return windowWidth < 1024
}

@ -1,50 +0,0 @@
---
import PhosphorIcon from './PhosphorIcon.astro'
const ICONS_MAP: Record<string, string> = {
github: 'github-logo',
linkedin: 'linkedin-logo',
website: 'globe',
mail: 'mailbox',
}
type Props = {
fullName: string
description: string
image: ImageMetadata
entranceDate: number
exitDate?: number
founder?: boolean
social?: {
github?: string
linkedin?: string
website?: string
mail?: string
}
}
const { fullName, description, image, entranceDate, exitDate, founder, social } = Astro.props
---
<div class="bubble">
<img src={image.src} alt={fullName.toLowerCase()} />
<div class="title">{fullName}</div>
<div class="date">{entranceDate}&mdash;{exitDate ?? 'Presente'}</div>
{founder && <div class="founder">Fondatore</div>}
<div class="description">{description}</div>
{
social && (
<div class="social">
{Object.entries(social).map(([key, value]) => (
<a href={value} target="_blank" rel="noopener noreferrer">
<PhosphorIcon name={ICONS_MAP[key] ?? 'question-mark'} />
</a>
))}
</div>
)
}
</div>

@ -1,12 +0,0 @@
---
type Props = {
large?: boolean
style?: string
}
const { large, ...props } = Astro.props
---
<div class:list={['card', large && 'large']} {...props}>
<slot />
</div>

@ -1,9 +0,0 @@
<footer>
<div class="text">
<p>
&copy; PHC 2024 &nbsp;&bull;&nbsp; <a href="mailto:macchinisti@lists.dm.unipi.it"
>macchinisti@lists.dm.unipi.it</a
>
</p>
</div>
</footer>

@ -1,57 +0,0 @@
---
const links = [
{ href: '/utenti', text: 'Utenti' },
// { href: '/macchinisti', text: 'Macchinisti' },
// { href: '/appunti', text: 'Appunti' },
{ href: '/notizie', text: 'Notizie' },
{ href: '/guide', text: 'Guide' },
{ href: '/domande-esami', text: 'Domande Orali' },
{ href: '/media-pesata', text: 'Calcolo Media' }, // Beta testing - solo URL diretto
{ href: '/storia', text: 'Storia' },
// { href: '/login', text: 'Login' },
]
---
<header>
<!-- main logo on the left -->
<a href="/" class="logo">
<img src="/images/phc-logo-2024-11@x8.png" alt="phc logo" />
</a>
<!-- hidden checkbox for mobile js-less sidebar interaction -->
<input type="checkbox" id="header-menu-toggle" />
<!-- desktop navbar links -->
<div class="links desktop-only">
{
links.map(link => (
<a role="button" href={link.href}>
{link.text}
</a>
))
}
</div>
<!-- sidebar menu for mobile -->
<div class="mobile-only">
<label id="header-menu-toggle-menu" role="button" class="flat icon" for="header-menu-toggle">
<span class="material-symbols-outlined">menu</span>
</label>
<label id="header-menu-toggle-close" role="button" class="flat icon" for="header-menu-toggle">
<span class="material-symbols-outlined">close</span>
</label>
</div>
<!-- sidebar menu only visible on mobile when #header-menu-toggle is checked -->
<div class="side-menu">
<div class="links">
{
links.map(link => (
<a role="button" href={link.href}>
{link.text}
</a>
))
}
</div>
</div>
</header>

@ -1,26 +0,0 @@
---
type Props = {
color: string
}
const { color } = Astro.props
const patternId = 'zig-zag-' + color.slice(1)
---
<div class="zig-zag">
<svg
width="100%"
height="2rem"
viewBox="0 0 1 1"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMinYMid meet"
>
<defs>
<pattern id={patternId} x="0" y="0" width="2" height="1" patternUnits="userSpaceOnUse">
<path fill={color} d="M 0,1 L 1,0 L 2,1 L 0,1"></path>
</pattern>
</defs>
<rect fill={`url(#${patternId})`} x="0" y="0" width="1000" height="1"></rect>
</svg>
</div>

@ -1,21 +0,0 @@
---
import { Image } from 'astro:assets'
type Props = {
name: string
}
const { name } = Astro.props
const icons = Object.fromEntries(
Object.entries(
import.meta.glob<{ default: ImageMetadata }>(`node_modules/@phosphor-icons/core/assets/light/*.svg`),
).map(([path, module]) => [path.split('/').pop()!.split('.')[0].replace('-light', ''), module]),
)
if (!icons[name]) {
throw new Error(`Icon "${name}" not found`)
}
---
<Image class="phosphor-icon" src={icons[name]()} alt={name} />

@ -1,24 +0,0 @@
---
// Card.astro
export interface Props {
title: string
href: string
imgSrc?: string
style?: string
}
const { href, imgSrc, style, title } = Astro.props
---
<a target="_blank" href={href} style={style}>
<div class="project">
<div class="image">
{imgSrc ? <img src={imgSrc} alt={'logo for ' + title.toLowerCase()} /> : <div class="box" />}
</div>
<div class="title">{title}</div>
<div class="description text">
<slot />
</div>
</div>
</a>

@ -1,14 +0,0 @@
---
const { year, title } = Astro.props
---
<div class="timeline-item">
<div class="content">
<div class="card">
<div class="title">{year} &bull; {title}</div>
<div class="text">
<slot />
</div>
</div>
</div>
</div>

@ -1,14 +0,0 @@
---
import { JSDOM } from 'jsdom'
import Container from './Container.astro'
const language = Astro.props['data-language'] ?? 'text'
const html = await Astro.slots.render('default')
const rawCode = new JSDOM(html).window.document.body.textContent
---
<pre {...Astro.props}><slot /></pre>
{language === 'astro' && <Container set:html={rawCode} />}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save