From 4518a4a3636c1061fe50c6b463b7d503283a0ffd Mon Sep 17 00:00:00 2001 From: Nikola Petrov Date: Thu, 30 Jul 2026 00:30:27 +0200 Subject: [PATCH] Convert to go --- .gitignore | 6 +- README.md | 70 ++-- backend/app.ts | 24 -- backend/controllers/mediaController.ts | 252 --------------- backend/miscellaneous/checkAuthenticated.ts | 15 - backend/models/mediaModel.ts | 84 ----- backend/models/userModel.ts | 31 -- backend/routes/api/apiRouter.ts | 12 - backend/routes/api/mediaRouter.ts | 13 - build.sh | 5 - bun.lock | 190 ----------- controllers/mediaController.go | 277 ++++++++++++++++ backend/miscellaneous/db.ts => db/db.go | 79 +++-- frontend/elementcreate.tsx | 71 ---- frontend/list/elements.tsx | 35 -- frontend/list/functions.tsx | 25 -- frontend/list/list.tsx | 287 ---------------- frontend/list/types.d.ts | 7 - frontend/utils/attr.d.ts | 1 - frontend/utils/element-types.d.ts | 341 -------------------- frontend/utils/events.d.ts | 98 ------ frontend/utils/intrinsic-elements.d.ts | 118 ------- go.mod | 17 + go.sum | 51 +++ main.go | 25 ++ models/mediaModel.go | 92 ++++++ models/userModel.go | 31 ++ package.json | 13 - public/embed.go | 6 + public/index.html | 74 ----- routes/router.go | 35 ++ tsconfig.json | 34 -- web/page.go | 224 +++++++++++++ web/page.html | 105 ++++++ web/templates.go | 11 + 35 files changed, 962 insertions(+), 1797 deletions(-) delete mode 100644 backend/app.ts delete mode 100644 backend/controllers/mediaController.ts delete mode 100644 backend/miscellaneous/checkAuthenticated.ts delete mode 100644 backend/models/mediaModel.ts delete mode 100644 backend/models/userModel.ts delete mode 100644 backend/routes/api/apiRouter.ts delete mode 100644 backend/routes/api/mediaRouter.ts delete mode 100755 build.sh delete mode 100644 bun.lock create mode 100644 controllers/mediaController.go rename backend/miscellaneous/db.ts => db/db.go (50%) delete mode 100644 frontend/elementcreate.tsx delete mode 100644 frontend/list/elements.tsx delete mode 100644 frontend/list/functions.tsx delete mode 100644 frontend/list/list.tsx delete mode 100644 frontend/list/types.d.ts delete mode 100644 frontend/utils/attr.d.ts delete mode 100644 frontend/utils/element-types.d.ts delete mode 100644 frontend/utils/events.d.ts delete mode 100644 frontend/utils/intrinsic-elements.d.ts create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 models/mediaModel.go create mode 100644 models/userModel.go delete mode 100644 package.json create mode 100644 public/embed.go delete mode 100644 public/index.html create mode 100644 routes/router.go delete mode 100644 tsconfig.json create mode 100644 web/page.go create mode 100644 web/page.html create mode 100644 web/templates.go diff --git a/.gitignore b/.gitignore index 238970a..01b535c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules/ -output/ +posters/ *.sqlite -.vscode \ No newline at end of file +.vscode +main \ No newline at end of file diff --git a/README.md b/README.md index 3c0cac8..5c2634e 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # List App -A web application for managing and organizing lists of movies, series, and games. The app allows users to add, view, and delete media items, with support for sorting and filtering. +A web application for managing and organizing lists of movies, series, and games. The app allows users to add and view media items, with support for sorting and filtering. ## Features -- **Media Management**: Add, view, and delete movies, series, and games +- **Media Management**: Add and view movies, series, and games - **Sorting Options**: Sort by title, year, or date added - **Image Caching**: Automatically downloads and caches poster images -- **Authentication**: Password-protected actions for adding/deleting media +- **Authentication**: Password-protected for adding media - **Responsive Design**: Works on mobile and desktop devices +- **No client-side JavaScript**: Pages are fully rendered server-side; the only script on the page is Bootstrap's own bundle (for the collapsible nav/dropdown), not anything authored by this app ## Technologies -- **Backend**: TypeScript, Express, Bun runtime -- **Frontend**: TypeScript, Bootstrap 5 +- **Backend**: Go, standard library `net/http` and `html/template`, `modernc.org/sqlite` (pure Go, cgo-free) - **Database**: SQLite - **APIs**: OMDB API (movies/series), Twitch IGDB API (games) @@ -21,43 +21,39 @@ A web application for managing and organizing lists of movies, series, and games ``` list_app/ -├── backend/ # Backend server code -│ ├── controllers/ # Business logic -│ ├── models/ # Database models -│ ├── routes/ # API routes -│ ├── miscellaneous/ # Utilities and middleware -│ └── app.ts # Main server entry +├── controllers/ # Business logic (OMDB/IGDB lookups, poster downloads) +├── models/ # Database models +├── routes/ # Route registration +├── web/ # Server-rendered page + HTML form handling +│ ├── page.html # Go html/template for the page +│ ├── page.go # Grouping/sorting logic, GET / and POST /add handlers +│ └── templates.go # go:embed for page.html +├── db/ # DB connection and schema +├── main.go # Main server entry │ -├── frontend/ # Frontend code -│ ├── list/ # Main list components -│ ├── utils/ # Type definitions -│ └── elementcreate.tsx # Custom element creation +├── public/ # Static assets, embedded into the Go binary +│ ├── embed.go # go:embed directive +│ ├── logo.ico # Favicon +│ └── no_poster.jpg # Fallback poster image │ -├── public/ # Static files -│ ├── index.html # Main HTML file -│ ├── logo.ico # Favicon -│ └── no_poster.jpg # Fallback poster image -│ -├── package.json # Project dependencies -└── README.md # This file +├── go.mod # Go module +└── README.md # This file ``` ## Setup -1. Install Bun runtime: https://bun.sh/ -2. Install dependencies: - ```bash - bun install - ``` -3. Set up your API keys in the database: +1. Install Go: https://go.dev/ +2. Set up your API keys in the database: - OMDB API key for movies/series - Twitch client ID and secret for games -## API Endpoints +## Routes -- `GET /api/media/:mediaType` - List all media of a type (movies, series, games) -- `POST /api/media/:mediaType` - Add new media (requires password) -- `DELETE /api/media/:mediaType` - Delete media (requires password) +- `GET /` - Renders the media list page. Query params: `listType` (`movies`, `series`, `games`; defaults to `movies`) and `sortType` (`title`, `year`, `id`; defaults to `title`) +- `POST /add` - Plain HTML form submission to add media (`pass`, `code`, plus hidden `listType`/`sortType` to redirect back to the right view). Always redirects back to `/` with `?error=...` set on failure +- `GET /api/media/:mediaType` - JSON listing of all media of a type (movies, series, games); not used by the page itself, kept as a small read-only API + +There is no delete endpoint or UI — removing media isn't supported; use `sqlite3 mydb.sqlite` directly if you need to remove an entry. ## Configuration @@ -68,18 +64,18 @@ The application uses SQLite for data storage. The database file `mydb.sqlite` wi - `games` - Stores game information - `userData` - Stores configuration including API keys and password +The compiled binary is self-contained: `logo.ico` and `no_poster.jpg` are embedded into it via `public/embed.go`, and the page markup lives in a Go `html/template` embedded via `web/templates.go` — so only the binary (plus the SQLite file) needs to be deployed. Poster images are fetched from OMDB/IGDB at runtime and can't be embedded, so they're cached on disk under a `posters/` directory created next to wherever the binary runs, and served at `/poster/...`. Whether a poster has actually downloaded yet is checked server-side on each page render — if it's missing, the fallback image is used directly in the rendered ``, so no client-side JS is needed for that either. + ## Usage 1. Navigate to `http://localhost:4080` in your browser -2. Use the navigation buttons to switch between movies, series, and games +2. Use the navigation links to switch between movies, series, and games 3. Use the "Sort" dropdown to change sorting method -4. Enter your password and media ID in the form to add new items -5. Click "Edit" then use delete buttons to remove items +4. Enter your password and media ID in the form and submit to add new items ## Building -To build the frontend: +To build the binary at `output/app`: ```bash ./build.sh ``` - diff --git a/backend/app.ts b/backend/app.ts deleted file mode 100644 index 81ddcec..0000000 --- a/backend/app.ts +++ /dev/null @@ -1,24 +0,0 @@ -import express from "express"; - -const hostname = '127.0.0.1'; -const httpPort = 4080; - -const app = express(); - -// import morgan from 'morgan' -// app.use(morgan('dev')); -app.use(express.json()); -app.use(express.urlencoded({ extended: false })); -app.use(express.static('public')); - -import apiRouter from './routes/api/apiRouter'; - -app.use('/api', apiRouter); - -app.listen(httpPort, () => { - console.log(`Server running at http://${hostname}:${httpPort}/`); -}); - -import mediaController from "./controllers/mediaController"; - -await mediaController.checkImages(); \ No newline at end of file diff --git a/backend/controllers/mediaController.ts b/backend/controllers/mediaController.ts deleted file mode 100644 index 14a63b7..0000000 --- a/backend/controllers/mediaController.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { type Request, type Response } from "express"; -import UserModel, { values } from '../models/userModel'; -import MediaModel, { Table, Media } from '../models/mediaModel'; - -interface omdbRes { - Title: string, - Released: string, - Response: string, - Poster: string, - Type: string, - imdbID: string, - Year: string, -} - -function fromStringToTable(input: string | string[]): (Table | undefined) { - var value = ""; - if (Array.isArray(input)) { - if (input.length > 0) - value = input[0]; - } - else { - value = input; - } - if (value.localeCompare("games") == 0) return Table.games; - if (value.localeCompare("movies") == 0) return Table.movies; - if (value.localeCompare("series") == 0) return Table.series; - return; -} - -async function downloadImage(mData: Media, type: Table) { - // Specify the path where you want to save the image - const outputPath = '/poster/' + type + '/' + mData.code + '.jpg'; - - // Use Bun's built-in fetch to download the image - const response = await fetch(mData.poster); - - // Check if the request was successful - if (!response.ok) { - console.log("fetch image error"); - console.log(mData.title); - return; - } - - // Convert the response to a blob - const imageBlob = await response.blob(); - // Use Bun's write to save the image to a file - await Bun.write('./public/' + outputPath, await imageBlob.arrayBuffer()); - MediaModel.updateWebImg(type, mData.code, outputPath); - -} - -async function createMed(req: Request, res: Response) { - const mediaCode: string = req.body.code; - - const omdb_key = UserModel.getValue(values.omdb_key); - - if (!omdb_key) { - return res.status(500).json({ message: 'Error when creating media' }); - } - - try { - - const uri = `http://www.omdbapi.com/?i=${mediaCode}&apikey=${omdb_key}`; - const mJson = await fetch(uri); - const mData: omdbRes = await mJson.json(); - - if (mData.Response == 'False') { - return res.status(404).json({ message: 'wrong code' }); - } - - const media: Media = { - id: 0, - code: mData.imdbID, - title: mData.Title, - released: mData.Released, - webImg: "", - poster: mData.Poster, - year: mData.Year - }; - - var tableType = Table.series; - - if (mData.Type.localeCompare("movie") == 0) { - tableType = Table.movies; - } - - const found = MediaModel.findOne(tableType, mediaCode); - if (found.length != 0) { - res.status(409).json({ message: 'Media already exists' }); - await downloadImage(media, tableType); - return; - } - - - const savedMedia = MediaModel.save(tableType, mData.imdbID, mData.Title, mData.Released, "", mData.Poster, mData.Year); - await downloadImage(media, tableType); - - res.status(201).json(media); - } catch (err) { - return res.status(500).json({ message: 'Error when creating media' }); - } -} - -async function createGame(req: Request, res: Response) { - var gameCode = req.body.code; - - const twitch_client_id = UserModel.getValue(values.twitch_client_id); - const twitch_client_secret = UserModel.getValue(values.twitch_client_secret); - - if (!twitch_client_id || !twitch_client_secret) { - return res.status(500).json({ message: 'Error when creating game' }); - } - - try { - const gameFound = MediaModel.findOne(Table.games, gameCode); - if (gameFound) { - return res.status(409).json({ message: 'Game already exists' }); - } - - const uri = "https://id.twitch.tv/oauth2/token?client_id=" + twitch_client_id + "&client_secret=" + twitch_client_secret + "&grant_type=client_credentials"; - var response = await fetch(uri, { method: 'POST' }); - const mData = await response.json(); - - const mheaders: HeadersInit = { - 'Accept': 'application/json', - 'Client-ID': twitch_client_id, - 'Authorization': 'Bearer ' + mData.access_token - } - - gameCode = parseInt(gameCode) - - response = await fetch( - "https://api.igdb.com/v4/games", - { - method: 'POST', - headers: mheaders, - body: `fields name, first_release_date; where id = ${gameCode};` - } - ) - const gameData = await response.json() - if (gameData.length == 0) { - return res.status(404).json({ message: 'wrong code' }); - } - - const date = new Date(gameData[0].first_release_date * 1000); - const options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' } - const dateStr = date.toLocaleDateString(undefined, options); - - - response = await fetch( - "https://api.igdb.com/v4/covers", - { - method: 'POST', - headers: mheaders, - body: `fields image_id; where game = ${gameCode};` - } - ) - const coverData = await response.json() - const game: Media = { - id: 0, - code: gameCode, - title: gameData[0].name, - released: dateStr, - webImg: "", - poster: `https://images.igdb.com/igdb/image/upload/t_cover_big/${coverData[0].image_id}.jpg`, - year: date.getFullYear().toString(), - }; - - const savedGame = MediaModel.save(Table.games, game.code, game.title, game.released, game.webImg, game.poster, game.year); - await downloadImage(game, Table.games); - return res.status(201).json(game); - - } catch (error) { - - return res.status(500).json({ message: 'Error when creating game', error: error }); - } -} - -function list(req: Request, res: Response) { - const mediaTable = fromStringToTable(req.params.mediaType); - if (!mediaTable) { - return res.status(500).json({ - message: 'Error when getting media.' - }); - } - - const media = MediaModel.find(mediaTable); - return res.json(media); -} - -async function create(req: Request, res: Response) { - const mediaCode: string = req.body.code; - if (mediaCode.startsWith("tt")) { - return await createMed(req, res); - } else { - return await createGame(req, res); - } -} -function remove(req: Request, res: Response) { - const mediaTable = fromStringToTable(req.params.mediaType); - if (!mediaTable) { - return res.status(500).json({ - message: 'Error when deleting the media.' - }); - } - - const code = req.body.code; - - try { - const media = MediaModel.findOneAndDelete(mediaTable, code); - if (!media) { - return res.status(404).json({ message: 'No such media' }); - } - - return res.status(204).json(); - } - catch (err) { - return res.status(500).json({ message: 'Error when deleting the media.' }); - } -} - -async function checkImages() { - await checkTableImages(Table.games); - await checkTableImages(Table.movies); - await checkTableImages(Table.series); -} - -function delay(time: number) { - return new Promise(resolve => setTimeout(resolve, time)); -} - -async function checkTableImages(table: Table) { - const list = MediaModel.find(table); - - for (const element of list) { - const path = "./public/" + element.webImg; - const f = Bun.file(path); - const exists = await f.exists(); - if (!exists) { - console.log(element.title); - await downloadImage(element, table); - await delay(1000); - } - } -} - -export default { - list, - create, - remove, - checkImages -}; diff --git a/backend/miscellaneous/checkAuthenticated.ts b/backend/miscellaneous/checkAuthenticated.ts deleted file mode 100644 index 016a62f..0000000 --- a/backend/miscellaneous/checkAuthenticated.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { type NextFunction, type Request, type Response } from "express"; -import userModel, { values } from 'backend/models/userModel'; - -function checkAuthenticated(req: Request, res: Response, next: NextFunction) { - const pass = req.body.pass; - const password = userModel.getValue(values.pass); - if (pass && password) { - if (pass == password) { - return next(); - } - } - return res.status(500).json({ message: 'Error when getting transactions.' }); -} - -export default checkAuthenticated; \ No newline at end of file diff --git a/backend/models/mediaModel.ts b/backend/models/mediaModel.ts deleted file mode 100644 index ddf1f63..0000000 --- a/backend/models/mediaModel.ts +++ /dev/null @@ -1,84 +0,0 @@ -import pool from 'backend/miscellaneous/db' - - -export class Media { - id!: number; - code!: string - title!: string; - released!: string; - webImg!: string; - poster!: string; - year!: string; -} - -export enum Table { - movies = "movies", - series = "series", - games = "games", -} - -function save(table: Table, code: string, title: string, released:string, webImg:string, poster: string, year: string): number { - try { - const sql = "INSERT INTO " + table + " (code, title, released, webImg, poster, year) VALUES (?,?,?,?,?,?)"; - - const result = pool.query(sql).run(code, title, released, webImg, poster, year); - return result.changes; - } - catch (err) { - console.log(err); - } - return 0; -} - -function updateWebImg(table: Table, code: string, webImg: string): number { - try { - const sql = "UPDATE " + table + " SET webImg = ? WHERE code = ?;"; - const result = pool.query(sql).run(webImg, code); - return result.changes; - } - catch (err) { - console.log(err); - } - return 0; -} - -function findOneAndDelete(table: Table, code: string): number { - try { - const result = pool.query("DELETE FROM " + table + " WHERE code = ?;").run(code); - return result.changes; - } - catch (err) { - console.log(err); - } - return 0; -} - -function findOne(table: Table, code: string): Media[] { - try { - const rows = pool.query("SELECT * FROM " + table + " WHERE code = ?;").as(Media).all(code); - return rows; - } - catch (err) { - console.log(err); - } - return []; -} - -function find(table: Table): Media[] { - try { - const rows = pool.query("SELECT * FROM " + table + ";").as(Media).all(); - return rows; - } - catch (err) { - console.log(err); - } - return []; -} - -export default { - save, - updateWebImg, - findOneAndDelete, - findOne, - find -}; \ No newline at end of file diff --git a/backend/models/userModel.ts b/backend/models/userModel.ts deleted file mode 100644 index 70e181c..0000000 --- a/backend/models/userModel.ts +++ /dev/null @@ -1,31 +0,0 @@ -import pool from 'backend/miscellaneous/db' - -class UserD { - name?: string; - value?: string; -} - -export enum values { - pass = 1, - omdb_key, - twitch_client_id, - twitch_client_secret, -} - - -function getValue(name: values): string | undefined { - try { - const rows = pool.query("SELECT name, value FROM userData where id = ?;").as(UserD).all(name); - if (rows.length > 0) - return rows[0].value; - } - catch (err) { - console.log(err); - } - return; -} - - -export default { - getValue -}; diff --git a/backend/routes/api/apiRouter.ts b/backend/routes/api/apiRouter.ts deleted file mode 100644 index b9144a9..0000000 --- a/backend/routes/api/apiRouter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import express, { type Request, type Response } from "express"; -import mediaRouter from './mediaRouter'; - -const router = express.Router(); - -router.use('/media', mediaRouter); - -router.get('/', function (req: Request, res: Response) { - res.status(200).json({ message: 'API is working' }); -}); - -export default router; \ No newline at end of file diff --git a/backend/routes/api/mediaRouter.ts b/backend/routes/api/mediaRouter.ts deleted file mode 100644 index a69bc68..0000000 --- a/backend/routes/api/mediaRouter.ts +++ /dev/null @@ -1,13 +0,0 @@ -import express from "express"; -import mediaController from '../../controllers/mediaController.js'; -import checkAuthenticated from '../../miscellaneous/checkAuthenticated.js'; - -const router = express.Router(); - -router.get('/:mediaType', mediaController.list); - -router.post('/:mediaType', checkAuthenticated, mediaController.create); - -router.delete('/:mediaType', checkAuthenticated, mediaController.remove); - -export default router; \ No newline at end of file diff --git a/build.sh b/build.sh deleted file mode 100755 index 0be3725..0000000 --- a/build.sh +++ /dev/null @@ -1,5 +0,0 @@ -bun build ./backend/app.ts --outfile=output/app --target=bun --minify --compile -bun build ./frontend/list/list.tsx --outfile=output/public/list.js --minify - -cp -r public/* output/public/ -cp mydb.sqlite output/ \ No newline at end of file diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 645d917..0000000 --- a/bun.lock +++ /dev/null @@ -1,190 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "web", - "dependencies": { - "@types/express": "^5.0.3", - "@types/morgan": "^1.9.10", - "bun-types": "^1.2.22", - "express": "^5.1.0", - "morgan": "~1.10.1", - "typescript": "^5.9.2", - }, - }, - }, - "packages": { - "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], - - "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], - - "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], - - "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="], - - "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], - - "@types/morgan": ["@types/morgan@1.9.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA=="], - - "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - - "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="], - - "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], - - "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], - - "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], - - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], - - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], - - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - - "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - - "morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - - "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], - - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - - "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], - - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - - "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "morgan/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], - - "morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - } -} diff --git a/controllers/mediaController.go b/controllers/mediaController.go new file mode 100644 index 0000000..09bd0d7 --- /dev/null +++ b/controllers/mediaController.go @@ -0,0 +1,277 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "list_app/models" +) + +// PostersDir is where dynamically fetched poster images are cached on disk. +// Unlike the static frontend assets, these are downloaded at runtime and +// can't be embedded into the binary, so they live in a real directory next +// to it, served separately (see routes.New). +const PostersDir = "posters" + +func PosterDiskPath(webImg string) string { + rel := strings.TrimPrefix(webImg, "/poster/") + return filepath.Join(PostersDir, rel) +} + +type omdbRes struct { + Title string `json:"Title"` + Released string `json:"Released"` + Response string `json:"Response"` + Poster string `json:"Poster"` + Type string `json:"Type"` + ImdbID string `json:"imdbID"` + Year string `json:"Year"` +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(body) +} + +func fromStringToTable(input string) (models.Table, bool) { + switch input { + case "games": + return models.Games, true + case "movies": + return models.Movies, true + case "series": + return models.Series, true + } + return "", false +} + +func downloadImage(media models.Media, table models.Table) { + outputPath := "/poster/" + string(table) + "/" + media.Code + ".jpg" + + response, err := http.Get(media.Poster) + if err != nil || !(response.StatusCode >= 200 && response.StatusCode < 300) { + log.Println("fetch image error") + log.Println(media.Title) + return + } + defer response.Body.Close() + + imageBytes, err := io.ReadAll(response.Body) + if err != nil { + log.Println("fetch image error") + log.Println(media.Title) + return + } + + destPath := PosterDiskPath(outputPath) + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + log.Println(err) + return + } + if err := os.WriteFile(destPath, imageBytes, 0644); err != nil { + log.Println(err) + return + } + + models.UpdateWebImg(table, media.Code, outputPath) +} + +func createMed(mediaCode string) (models.Media, error) { + omdbKey, ok := models.GetValue(models.OmdbKey) + if !ok || omdbKey == "" { + return models.Media{}, fmt.Errorf("error when creating media") + } + + uri := "http://www.omdbapi.com/?i=" + url.QueryEscape(mediaCode) + "&apikey=" + url.QueryEscape(omdbKey) + resp, err := http.Get(uri) + if err != nil { + return models.Media{}, fmt.Errorf("error when creating media") + } + defer resp.Body.Close() + + var mData omdbRes + if err := json.NewDecoder(resp.Body).Decode(&mData); err != nil { + return models.Media{}, fmt.Errorf("error when creating media") + } + + if mData.Response == "False" { + return models.Media{}, fmt.Errorf("wrong code") + } + + media := models.Media{ + ID: 0, + Code: mData.ImdbID, + Title: mData.Title, + Released: mData.Released, + WebImg: "", + Poster: mData.Poster, + Year: mData.Year, + } + + tableType := models.Series + if strings.EqualFold(mData.Type, "movie") { + tableType = models.Movies + } + + found := models.FindOne(tableType, mediaCode) + if len(found) != 0 { + downloadImage(media, tableType) + return models.Media{}, fmt.Errorf("media already exists") + } + + models.Save(tableType, mData.ImdbID, mData.Title, mData.Released, "", mData.Poster, mData.Year) + downloadImage(media, tableType) + + return media, nil +} + +func createGame(gameCodeStr string) (models.Media, error) { + twitchClientID, ok1 := models.GetValue(models.TwitchClientID) + twitchClientSecret, ok2 := models.GetValue(models.TwitchClientSecret) + if !ok1 || !ok2 || twitchClientID == "" || twitchClientSecret == "" { + return models.Media{}, fmt.Errorf("error when creating game") + } + + gameFound := models.FindOne(models.Games, gameCodeStr) + if len(gameFound) != 0 { + return models.Media{}, fmt.Errorf("game already exists") + } + + tokenURI := "https://id.twitch.tv/oauth2/token?client_id=" + url.QueryEscape(twitchClientID) + + "&client_secret=" + url.QueryEscape(twitchClientSecret) + "&grant_type=client_credentials" + tokenResp, err := http.Post(tokenURI, "application/x-www-form-urlencoded", nil) + if err != nil { + return models.Media{}, fmt.Errorf("error when creating game: %w", err) + } + defer tokenResp.Body.Close() + + var tokenData struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(tokenResp.Body).Decode(&tokenData); err != nil { + return models.Media{}, fmt.Errorf("error when creating game: %w", err) + } + + gameCode, err := strconv.Atoi(gameCodeStr) + if err != nil { + return models.Media{}, fmt.Errorf("wrong code") + } + + doIgdbRequest := func(path, body string) ([]byte, error) { + req, err := http.NewRequest(http.MethodPost, "https://api.igdb.com/v4/"+path, strings.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Client-ID", twitchClientID) + req.Header.Set("Authorization", "Bearer "+tokenData.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) + } + + gamesBody, err := doIgdbRequest("games", fmt.Sprintf("fields name, first_release_date; where id = %d;", gameCode)) + if err != nil { + return models.Media{}, fmt.Errorf("error when creating game: %w", err) + } + + var gameData []struct { + Name string `json:"name"` + FirstReleaseDate int64 `json:"first_release_date"` + } + if err := json.Unmarshal(gamesBody, &gameData); err != nil { + return models.Media{}, fmt.Errorf("error when creating game: %w", err) + } + if len(gameData) == 0 { + return models.Media{}, fmt.Errorf("wrong code") + } + + releaseDate := time.Unix(gameData[0].FirstReleaseDate, 0).UTC() + dateStr := releaseDate.Format("2 Jan 2006") + + coversBody, err := doIgdbRequest("covers", fmt.Sprintf("fields image_id; where game = %d;", gameCode)) + if err != nil { + return models.Media{}, fmt.Errorf("error when creating game: %w", err) + } + + var coverData []struct { + ImageID string `json:"image_id"` + } + if err := json.Unmarshal(coversBody, &coverData); err != nil { + return models.Media{}, fmt.Errorf("error when creating game: %w", err) + } + + imageID := "" + if len(coverData) > 0 { + imageID = coverData[0].ImageID + } + + game := models.Media{ + ID: 0, + Code: gameCodeStr, + Title: gameData[0].Name, + Released: dateStr, + WebImg: "", + Poster: fmt.Sprintf("https://images.igdb.com/igdb/image/upload/t_cover_big/%s.jpg", imageID), + Year: strconv.Itoa(releaseDate.Year()), + } + + models.Save(models.Games, game.Code, game.Title, game.Released, game.WebImg, game.Poster, game.Year) + downloadImage(game, models.Games) + + return game, nil +} + +// Create adds a movie, series, or game by its OMDB (imdb "tt...") or IGDB +// (numeric) code, dispatching to the right lookup based on the code's shape. +func Create(code string) (models.Media, error) { + if strings.HasPrefix(code, "tt") { + return createMed(code) + } + return createGame(code) +} + +func List(w http.ResponseWriter, r *http.Request) { + mediaTable, ok := fromStringToTable(r.PathValue("mediaType")) + if !ok { + writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "Error when getting media."}) + return + } + + media := models.Find(mediaTable) + writeJSON(w, http.StatusOK, media) +} + +func CheckImages() { + checkTableImages(models.Games) + checkTableImages(models.Movies) + checkTableImages(models.Series) +} + +func checkTableImages(table models.Table) { + list := models.Find(table) + + for _, element := range list { + path := PosterDiskPath(element.WebImg) + if _, err := os.Stat(path); err == nil { + continue + } + log.Println(element.Title) + downloadImage(element, table) + time.Sleep(1 * time.Second) + } +} diff --git a/backend/miscellaneous/db.ts b/db/db.go similarity index 50% rename from backend/miscellaneous/db.ts rename to db/db.go index 3c79a63..5dfd4b1 100644 --- a/backend/miscellaneous/db.ts +++ b/db/db.go @@ -1,8 +1,22 @@ -import { Database } from "bun:sqlite"; +package db -const pool = new Database("mydb.sqlite", { strict: true }); +import ( + "database/sql" + "log" -pool.run(` + _ "modernc.org/sqlite" +) + +var Pool *sql.DB + +func init() { + pool, err := sql.Open("sqlite", "mydb.sqlite") + if err != nil { + log.Fatal(err) + } + Pool = pool + + mustExec(` CREATE TABLE IF NOT EXISTS series ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL, @@ -12,9 +26,9 @@ CREATE TABLE IF NOT EXISTS series ( poster TEXT NOT NULL, year TEXT NOT NULL ); -`); +`) -pool.run(` + mustExec(` CREATE TABLE IF NOT EXISTS movies ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL, @@ -24,9 +38,9 @@ CREATE TABLE IF NOT EXISTS movies ( poster TEXT NOT NULL, year TEXT NOT NULL ); -`); +`) -pool.run(` + mustExec(` CREATE TABLE IF NOT EXISTS games ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL, @@ -36,33 +50,44 @@ CREATE TABLE IF NOT EXISTS games ( poster TEXT NOT NULL, year TEXT NOT NULL ); -`); +`) -pool.run(` + mustExec(` CREATE TABLE IF NOT EXISTS userData ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, value TEXT NOT NULL ); -`); +`) -function inset_keys() { - class co { - count!: number; - } - const result = pool.query("SELECT count(*) as count FROM userData;").as(co).get(); - if(result && result.count >= 4){ - return; - } - - const stmt = pool.prepare("INSERT INTO userData (name, value) VALUES (?, ?)"); - stmt.run("pass", ""); - stmt.run("omdb_key", ""); - stmt.run("twitch_client_id", ""); - stmt.run("twitch_client_secret", ""); - stmt.finalize(); + insertKeys() } -inset_keys(); +func mustExec(query string) { + if _, err := Pool.Exec(query); err != nil { + log.Fatal(err) + } +} -export default pool; \ No newline at end of file +func insertKeys() { + var count int + if err := Pool.QueryRow("SELECT count(*) FROM userData;").Scan(&count); err != nil { + log.Println(err) + return + } + if count >= 4 { + return + } + + stmt, err := Pool.Prepare("INSERT INTO userData (name, value) VALUES (?, ?)") + if err != nil { + log.Println(err) + return + } + defer stmt.Close() + + stmt.Exec("pass", "") + stmt.Exec("omdb_key", "") + stmt.Exec("twitch_client_id", "") + stmt.Exec("twitch_client_secret", "") +} diff --git a/frontend/elementcreate.tsx b/frontend/elementcreate.tsx deleted file mode 100644 index a59a7f0..0000000 --- a/frontend/elementcreate.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/// -/// -/// - -export interface Children { - children?: AttributeValue; -} - -export interface CustomElementHandler { - (attributes: Attributes, contents: (string | HTMLElement)[]): HTMLElement; -} - -export interface Attributes { - [key: string]: AttributeValue; -} - - -export function createElement( - tag: string | CustomElementHandler, - attrs: Attributes & Children | undefined = {}, - ...children: (string | HTMLElement)[] -): HTMLElement { - - if (typeof tag === "function") { - if (attrs == null) { - attrs = { num: 0 }; - } - if (children == null) { - children = [""]; - } - return tag(attrs, children); - } - - const retElement = document.createElement(tag); - - for (let name in attrs) { - if (name && attrs.hasOwnProperty(name)) { - - let value = attrs[name]; - if (typeof value === "number") { - retElement.setAttribute(name, value.toString()); - } else if (typeof value === "function") { - retElement.addEventListener(name.slice(2), value); - } - else { - retElement.setAttribute(name, value); - } - } - } - - for (let i = 2; i < arguments.length; i++) { - let child = arguments[i]; - - // check if child is a HTMLElement - if (child.nodeType != undefined) { - retElement.appendChild(child); - continue; - } - - if (child instanceof Array) { - for (let j = 0; j < child.length; j++) { - if (child[j].nodeType != undefined) retElement.appendChild(child[j]); - else retElement.appendChild(document.createTextNode(child[j].toString())); - } - continue; - } - // child is a string - retElement.appendChild(document.createTextNode(child.toString())); - } - return retElement; -} \ No newline at end of file diff --git a/frontend/list/elements.tsx b/frontend/list/elements.tsx deleted file mode 100644 index 8e9b06f..0000000 --- a/frontend/list/elements.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { Attributes } from "frontend/elementcreate"; -import * as elements from "frontend/elementcreate"; - -function MediaElement(attributes: Attributes, contents: string[]) { - const ret =
-
- -
-
{attributes['title']}
-

{attributes['released']}

-
-
- -
-
-
-
-
; - return ret; -} - - -function MyHeader(attributes: Attributes, contents: string[]) { - return
-
-

{attributes['title']} {attributes['num'] ? ": " + attributes['num'] : ""}

-
-
; -} - -function MediaContainer(attributes: Attributes, contents: string[]) { - return
{contents[0]}
; -} - -export { MediaElement, MyHeader, MediaContainer } \ No newline at end of file diff --git a/frontend/list/functions.tsx b/frontend/list/functions.tsx deleted file mode 100644 index 4f82060..0000000 --- a/frontend/list/functions.tsx +++ /dev/null @@ -1,25 +0,0 @@ - -function splitByTitle(movies: Array): { [s: string]: Movie[]; } { - const result = movies.reduce((r, a) => { - var letter = a.title[0].toUpperCase(); - if (!isNaN(parseInt(letter))) letter = "#"; - r[letter] = r[letter] || []; - r[letter].push(a); - return r; - }, Object.create(null)); - - return result; -} - -function splitByYear(movies: Array): { [s: string]: Movie[]; } { - const result = movies.reduce((r, a) => { - const year = new Date(a.released).getFullYear(); - r[year] = r[year] || []; - r[year].push(a); - return r; - }, Object.create(null)); - - return result; -} - -export { splitByTitle, splitByYear }; \ No newline at end of file diff --git a/frontend/list/list.tsx b/frontend/list/list.tsx deleted file mode 100644 index 01ec4d9..0000000 --- a/frontend/list/list.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import { MediaElement, MyHeader, MediaContainer } from "frontend/list/elements"; -import { splitByTitle, splitByYear } from "frontend/list/functions"; - -import * as elements from "frontend/elementcreate"; - -var sortType = 0; -var listType = 0; - -const sortTypeTitle = 0; -const sortTypeYear = 1; -const sortTypeId = 2; - -const moviesType = 0; -const gamesType = 1; -const seriesType = 2; - -var listButtons: Array = []; -var sortButtons: Array = []; - -var root: HTMLElement | null; -var editButton: HTMLElement | null; -var movieElements: HTMLElement[] = []; - -function getLink(): string { - switch (listType) { - case moviesType: - return "/api/media/movies"; - case gamesType: - return "/api/media/games"; - case seriesType: - return "/api/media/series"; - } - return "/api/media/movies"; -} - -async function reload() { - try { - const response = await fetch(getLink()); - const movies = await response.json(); - renderMedias(movies); - } catch (err) { - console.log(err); - } -} - -function submitMedia(event: SubmitEvent) { - event.preventDefault(); - - const pass = document.getElementById("pass") as HTMLInputElement | null; - if (!pass) return; - - const input_id = document.getElementById("input_id") as HTMLInputElement | null; - if (!input_id) return; - - - if (pass.value == "" || input_id.value == "") return; - - - fetch(getLink(), { - body: JSON.stringify({ pass: pass.value, code: input_id.value }), - headers: { "Content-Type": "application/json" }, - method: "POST" - }) - .then(async (response) => { - if (response.status != 201) { - const json = await response.json(); - console.log(json); - alert(json.message); - return; - } - - await reload(); - }) - .catch(err => { - console.log(err); - }); - - input_id.value = ""; -} - -function loadState() { - const searchParams = new URLSearchParams(window.location.search); - if (searchParams.has("listType")) { - switch (searchParams.get("listType")) { - case "movies": - listType = moviesType; - break; - case "series": - listType = seriesType; - break; - case "games": - listType = gamesType; - break; - default: - listType = moviesType; - break; - } - } - - if (searchParams.has("sortType")) { - switch (searchParams.get("sortType")) { - case "title": - sortType = sortTypeTitle; - break; - case "year": - sortType = sortTypeYear; - break; - case "id": - sortType = sortTypeId; - break; - default: - sortType = sortTypeTitle; - break; - } - } -} - -function changeType(type: number) { - listType = type; - loadPage(); - const searchParams = new URLSearchParams(window.location.search); - switch (listType) { - case moviesType: - searchParams.set("listType", "movies"); - break; - case gamesType: - searchParams.set("listType", "games"); - break; - case seriesType: - searchParams.set("listType", "series"); - break; - } - history.replaceState({}, '', window.location.pathname + '?' + searchParams.toString()); -} - -function changeSort(type: number) { - sortType = type; - loadPage(); - const searchParams = new URLSearchParams(window.location.search); - switch (type) { - case sortTypeTitle: - searchParams.set("sortType", "title"); - break; - case sortTypeYear: - searchParams.set("sortType", "year"); - break; - case sortTypeId: - searchParams.set("sortType", "id"); - break; - } - history.replaceState({}, '', window.location.pathname + '?' + searchParams.toString()); -} - - -function splitBySort(movies: Array): { [s: string]: Movie[]; } { - switch (sortType) { - case sortTypeYear: - const sorted = movies.sort((a, b) => { - const ay = Date.parse(a.released); - const by = Date.parse(b.released); - return ay - by; - }); - return splitByYear(sorted); - case sortTypeId: - movies.sort((a, b) => a.id < b.id ? 1 : -1); - return { "added": movies }; - default: - return splitByTitle(movies.sort((a, b) => a.title.localeCompare(b.title))); - } -} - -function toggleEdit() { - movieElements.forEach(element => { - const div = element.querySelector(".d-none"); - if (!div) return; - div.classList.remove("d-none"); - div.classList.add("d-flex"); - }); -} - -document.addEventListener('DOMContentLoaded', async () => { - document.getElementById("myform")?.addEventListener("submit", submitMedia); - - listButtons.push(document.getElementById("movieButton")); - listButtons.push(document.getElementById("gameButton")); - listButtons.push(document.getElementById("seriesButton")); - listButtons.forEach((button, index) => button?.addEventListener("click", () => changeType(index))); - - sortButtons.push(document.getElementById("titleButton")); - sortButtons.push(document.getElementById("yearButton")); - sortButtons.push(document.getElementById("idButton")); - sortButtons.forEach((button, index) => button?.addEventListener("click", () => changeSort(index))); - - editButton = document.getElementById("editButton"); - editButton?.addEventListener("click", () => toggleEdit()); - - loadState(); - loadPage(); -}); - -async function loadPage() { - - listButtons.forEach(button => button?.classList.remove("active")); - listButtons[listType]?.classList.add("active"); - - await reload(); -} - - -function removeMedia(evt: Event) { - const password = document.getElementById("pass") as HTMLInputElement | null; - if (!password) return; - if (password.value == "") return; - - let elem = evt.target as HTMLElement | null; - - while (elem && !elem.classList.contains('media-element')) { - elem = elem.parentElement; - } - - if (!elem) return; - const id = elem.id; - - fetch(getLink(), { - body: JSON.stringify({ pass: password.value, code: id }), - headers: { "Content-Type": "application/json" }, - method: "DELETE" - }) - .then(async (response) => { - - if (response.status != 204) { - console.log("error"); - console.log(response.body); - return; - } - document.getElementById(id)?.remove(); - }) - .catch(err => { - console.log(err); - }); - password.value = ""; -} - -function onImgError(evt: Event) { - const imgT = evt.target as HTMLImageElement; - imgT.src = "/no_poster.jpg"; - console.log(imgT.parentElement?.parentElement?.id); -} - -function renderMedias(unsorted_movies: Array) { - root = document.getElementById('root'); - if (!root) return; - - root.innerHTML = ""; - movieElements = []; - - const splitMovies = splitBySort(unsorted_movies); - - let years; - if (sortType == sortTypeTitle) { - years = Object.keys(splitMovies).sort((a, b) => -b.localeCompare(a)); - } else { - years = Object.keys(splitMovies).sort((a, b) => b.localeCompare(a)); - } - - - root.appendChild(); - - for (const letter of years) { - - const movies = splitMovies[letter]; - - const header = ; - root.appendChild(header); - - const row = - - {movies.map(movie => { - const med = ; - movieElements.push(med); - return med; - })} - ; - - root.appendChild(row); - } -} \ No newline at end of file diff --git a/frontend/list/types.d.ts b/frontend/list/types.d.ts deleted file mode 100644 index 241d6b8..0000000 --- a/frontend/list/types.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -interface Movie { - title: string; - released: string; - code: string; - webImg: string; - id: string; -} \ No newline at end of file diff --git a/frontend/utils/attr.d.ts b/frontend/utils/attr.d.ts deleted file mode 100644 index 3d06107..0000000 --- a/frontend/utils/attr.d.ts +++ /dev/null @@ -1 +0,0 @@ -type AttributeValue = number | string | EventListener; \ No newline at end of file diff --git a/frontend/utils/element-types.d.ts b/frontend/utils/element-types.d.ts deleted file mode 100644 index 932fc8c..0000000 --- a/frontend/utils/element-types.d.ts +++ /dev/null @@ -1,341 +0,0 @@ -declare namespace JSX { - interface HtmlTag { - accesskey?: string; - class?: string; - contenteditable?: string; - dir?: string; - hidden?: string | boolean; - id?: AttributeValue; - role?: string; - lang?: string; - draggable?: string | boolean; - spellcheck?: string | boolean; - style?: string; - tabindex?: string; - title?: string; - translate?: string | boolean; - } - interface HtmlAnchorTag extends HtmlTag { - href?: string; - target?: string; - download?: string; - ping?: string; - rel?: string; - media?: string; - hreflang?: string; - type?: string; - } - interface HtmlAreaTag extends HtmlTag { - alt?: string; - coords?: string; - shape?: string; - href?: string; - target?: string; - ping?: string; - rel?: string; - media?: string; - hreflang?: string; - type?: string; - } - interface HtmlAudioTag extends HtmlTag { - src?: string; - autobuffer?: string; - autoplay?: string; - loop?: string; - controls?: string; - } - interface BaseTag extends HtmlTag { - href?: string; - target?: string; - } - interface HtmlQuoteTag extends HtmlTag { - cite?: string; - } - interface HtmlBodyTag extends HtmlTag { - } - interface HtmlButtonTag extends HtmlTag { - action?: string; - autofocus?: string; - disabled?: string; - enctype?: string; - form?: string; - method?: string; - name?: string; - novalidate?: string | boolean; - target?: string; - type?: string; - value?: string; - onClick?: Function; - } - interface HtmlDataListTag extends HtmlTag { - } - interface HtmlCanvasTag extends HtmlTag { - width?: string; - height?: string; - } - interface HtmlTableColTag extends HtmlTag { - span?: string; - } - interface HtmlTableSectionTag extends HtmlTag { - } - interface HtmlTableRowTag extends HtmlTag { - } - interface DataTag extends HtmlTag { - value?: string; - } - interface HtmlEmbedTag extends HtmlTag { - src?: string; - type?: string; - width?: string; - height?: string; - } - interface HtmlFieldSetTag extends HtmlTag { - disabled?: string; - form?: string; - name?: string; - } - interface HtmlFormTag extends HtmlTag { - acceptCharset?: string; - action?: string; - autocomplete?: string; - enctype?: string; - method?: string; - name?: string; - novalidate?: string | boolean; - target?: string; - } - interface HtmlHtmlTag extends HtmlTag { - manifest?: string; - } - interface HtmlIFrameTag extends HtmlTag { - src?: string; - srcdoc?: string; - name?: string; - sandbox?: string; - seamless?: string; - width?: string; - height?: string; - } - interface HtmlImageTag extends HtmlTag { - alt?: string; - src?: AttributeValue; - crossorigin?: string; - usemap?: string; - ismap?: string; - width?: string; - height?: string; - } - interface HtmlInputTag extends HtmlTag { - accept?: string; - action?: string; - alt?: string; - autocomplete?: string; - autofocus?: string; - checked?: string | boolean; - disabled?: string | boolean; - enctype?: string; - form?: string; - height?: string; - list?: string; - max?: string; - maxlength?: string; - method?: string; - min?: string; - multiple?: string; - name?: string; - novalidate?: string | boolean; - pattern?: string; - placeholder?: string; - readonly?: string; - required?: string; - size?: string; - src?: string; - step?: string; - target?: string; - type?: string; - value?: string; - width?: string; - } - interface HtmlModTag extends HtmlTag { - cite?: string; - datetime?: string | Date; - } - interface KeygenTag extends HtmlTag { - autofocus?: string; - challenge?: string; - disabled?: string; - form?: string; - keytype?: string; - name?: string; - } - interface HtmlLabelTag extends HtmlTag { - form?: string; - for?: string; - } - interface HtmlLITag extends HtmlTag { - value?: string | number; - } - interface HtmlLinkTag extends HtmlTag { - href?: string; - crossorigin?: string; - rel?: string; - media?: string; - hreflang?: string; - type?: string; - sizes?: string; - integrity?: string; - } - interface HtmlMapTag extends HtmlTag { - name?: string; - } - interface HtmlMetaTag extends HtmlTag { - name?: string; - httpEquiv?: string; - content?: string; - charset?: string; - } - interface HtmlMeterTag extends HtmlTag { - value?: string | number; - min?: string | number; - max?: string | number; - low?: string | number; - high?: string | number; - optimum?: string | number; - } - interface HtmlObjectTag extends HtmlTag { - data?: string; - type?: string; - name?: string; - usemap?: string; - form?: string; - width?: string; - height?: string; - } - interface HtmlOListTag extends HtmlTag { - reversed?: string; - start?: string | number; - } - interface HtmlOptgroupTag extends HtmlTag { - disabled?: string; - label?: string; - } - interface HtmlOptionTag extends HtmlTag { - disabled?: string; - label?: string; - selected?: string; - value?: string; - } - interface HtmlOutputTag extends HtmlTag { - for?: string; - form?: string; - name?: string; - } - interface HtmlParamTag extends HtmlTag { - name?: string; - value?: string; - } - interface HtmlProgressTag extends HtmlTag { - value?: string | number; - max?: string | number; - } - interface HtmlCommandTag extends HtmlTag { - type?: string; - label?: string; - icon?: string; - disabled?: string; - checked?: string; - radiogroup?: string; - default?: string; - } - interface HtmlLegendTag extends HtmlTag { - } - interface HtmlBrowserButtonTag extends HtmlTag { - type?: string; - } - interface HtmlMenuTag extends HtmlTag { - type?: string; - label?: string; - } - interface HtmlScriptTag extends HtmlTag { - src?: string; - type?: string; - charset?: string; - async?: string; - defer?: string; - crossorigin?: string; - integrity?: string; - text?: string; - } - interface HtmlDetailsTag extends HtmlTag { - open?: string; - } - interface HtmlSelectTag extends HtmlTag { - autofocus?: string; - disabled?: string; - form?: string; - multiple?: string; - name?: string; - required?: string; - size?: string; - } - interface HtmlSourceTag extends HtmlTag { - src?: string; - type?: string; - media?: string; - } - interface HtmlStyleTag extends HtmlTag { - media?: string; - type?: string; - disabled?: string; - scoped?: string; - } - interface HtmlTableTag extends HtmlTag { - } - interface HtmlTableDataCellTag extends HtmlTag { - colspan?: string | number; - rowspan?: string | number; - headers?: string; - } - interface HtmlTextAreaTag extends HtmlTag { - autofocus?: string; - cols?: string; - dirname?: string; - disabled?: string; - form?: string; - maxlength?: string; - minlength?: string; - name?: string; - placeholder?: string; - readonly?: string; - required?: string; - rows?: string; - wrap?: string; - } - interface HtmlTableHeaderCellTag extends HtmlTag { - colspan?: string | number; - rowspan?: string | number; - headers?: string; - scope?: string; - } - interface HtmlTimeTag extends HtmlTag { - datetime?: string | Date; - } - interface HtmlTrackTag extends HtmlTag { - default?: string; - kind?: string; - label?: string; - src?: string; - srclang?: string; - } - interface HtmlVideoTag extends HtmlTag { - src?: string; - poster?: string; - autobuffer?: string; - autoplay?: string; - loop?: string; - controls?: string; - width?: string; - height?: string; - } -} -//# sourceMappingURL=element-types.d.ts.map \ No newline at end of file diff --git a/frontend/utils/events.d.ts b/frontend/utils/events.d.ts deleted file mode 100644 index ef4ad00..0000000 --- a/frontend/utils/events.d.ts +++ /dev/null @@ -1,98 +0,0 @@ -declare namespace JSX { - interface HtmlBodyTag { - onafterprint?: string; - onbeforeprint?: string; - onbeforeonload?: string; - onblur?: string; - onerror?: string; - onfocus?: string; - onhaschange?: string; - onload?: string; - onmessage?: string; - onoffline?: string; - ononline?: string; - onpagehide?: string; - onpageshow?: string; - onpopstate?: string; - onredo?: string; - onresize?: string; - onstorage?: string; - onundo?: string; - onunload?: string; - } - interface HtmlTag { - oncontextmenu?: string; - onkeydown?: string; - onkeypress?: string; - onkeyup?: string; - onclick?: AttributeValue; - ondblclick?: string; - ondrag?: string; - ondragend?: string; - ondragenter?: string; - ondragleave?: string; - ondragover?: string; - ondragstart?: string; - ondrop?: string; - onmousedown?: string; - onmousemove?: string; - onmouseout?: string; - onmouseover?: string; - onmouseup?: string; - onmousewheel?: string; - onscroll?: string; - } - interface FormEvents { - onblur?: string; - onchange?: string; - onfocus?: string; - onformchange?: string; - onforminput?: string; - oninput?: string; - oninvalid?: string; - onselect?: string; - onsubmit?: string; - } - interface HtmlInputTag extends FormEvents { - } - interface HtmlFieldSetTag extends FormEvents { - } - interface HtmlFormTag extends FormEvents { - } - interface MediaEvents { - onabort?: string; - oncanplay?: string; - oncanplaythrough?: string; - ondurationchange?: string; - onemptied?: string; - onended?: string; - onerror?: AttributeValue; - onloadeddata?: string; - onloadedmetadata?: string; - onloadstart?: string; - onpause?: string; - onplay?: string; - onplaying?: string; - onprogress?: string; - onratechange?: string; - onreadystatechange?: string; - onseeked?: string; - onseeking?: string; - onstalled?: string; - onsuspend?: string; - ontimeupdate?: string; - onvolumechange?: string; - onwaiting?: string; - } - interface HtmlAudioTag extends MediaEvents { - } - interface HtmlEmbedTag extends MediaEvents { - } - interface HtmlImageTag extends MediaEvents { - } - interface HtmlObjectTag extends MediaEvents { - } - interface HtmlVideoTag extends MediaEvents { - } -} -//# sourceMappingURL=events.d.ts.map \ No newline at end of file diff --git a/frontend/utils/intrinsic-elements.d.ts b/frontend/utils/intrinsic-elements.d.ts deleted file mode 100644 index 0e66229..0000000 --- a/frontend/utils/intrinsic-elements.d.ts +++ /dev/null @@ -1,118 +0,0 @@ -declare namespace JSX { - type Element = HTMLElement; - interface IntrinsicElements { - a: HtmlAnchorTag; - abbr: HtmlTag; - address: HtmlTag; - area: HtmlAreaTag; - article: HtmlTag; - aside: HtmlTag; - audio: HtmlAudioTag; - b: HtmlTag; - bb: HtmlBrowserButtonTag; - base: BaseTag; - bdi: HtmlTag; - bdo: HtmlTag; - blockquote: HtmlQuoteTag; - body: HtmlBodyTag; - br: HtmlTag; - button: HtmlButtonTag; - canvas: HtmlCanvasTag; - caption: HtmlTag; - cite: HtmlTag; - code: HtmlTag; - col: HtmlTableColTag; - colgroup: HtmlTableColTag; - commands: HtmlCommandTag; - data: DataTag; - datalist: HtmlDataListTag; - dd: HtmlTag; - del: HtmlModTag; - details: HtmlDetailsTag; - dfn: HtmlTag; - div: HtmlTag; - dl: HtmlTag; - dt: HtmlTag; - em: HtmlTag; - embed: HtmlEmbedTag; - fieldset: HtmlFieldSetTag; - figcaption: HtmlTag; - figure: HtmlTag; - footer: HtmlTag; - form: HtmlFormTag; - h1: HtmlTag; - h2: HtmlTag; - h3: HtmlTag; - h4: HtmlTag; - h5: HtmlTag; - h6: HtmlTag; - head: HtmlTag; - header: HtmlTag; - hr: HtmlTag; - html: HtmlHtmlTag; - i: HtmlTag; - iframe: HtmlIFrameTag; - img: HtmlImageTag; - input: HtmlInputTag; - ins: HtmlModTag; - kbd: HtmlTag; - keygen: KeygenTag; - label: HtmlLabelTag; - legend: HtmlLegendTag; - li: HtmlLITag; - link: HtmlLinkTag; - main: HtmlTag; - map: HtmlMapTag; - mark: HtmlTag; - menu: HtmlMenuTag; - meta: HtmlMetaTag; - meter: HtmlMeterTag; - nav: HtmlTag; - noscript: HtmlTag; - object: HtmlObjectTag; - ol: HtmlOListTag; - optgroup: HtmlOptgroupTag; - option: HtmlOptionTag; - output: HtmlOutputTag; - p: HtmlTag; - param: HtmlParamTag; - pre: HtmlTag; - progress: HtmlProgressTag; - q: HtmlQuoteTag; - rb: HtmlTag; - rp: HtmlTag; - rt: HtmlTag; - rtc: HtmlTag; - ruby: HtmlTag; - s: HtmlTag; - samp: HtmlTag; - script: HtmlScriptTag; - section: HtmlTag; - select: HtmlSelectTag; - small: HtmlTag; - source: HtmlSourceTag; - span: HtmlTag; - strong: HtmlTag; - style: HtmlStyleTag; - sub: HtmlTag; - sup: HtmlTag; - table: HtmlTableTag; - tbody: HtmlTag; - td: HtmlTableDataCellTag; - template: HtmlTag; - textarea: HtmlTextAreaTag; - tfoot: HtmlTableSectionTag; - th: HtmlTableHeaderCellTag; - thead: HtmlTableSectionTag; - time: HtmlTimeTag; - title: HtmlTag; - tr: HtmlTableRowTag; - track: HtmlTrackTag; - u: HtmlTag; - ul: HtmlTag; - var: HtmlTag; - video: HtmlVideoTag; - wbr: HtmlTag; - } -} -//# sourceMappingURL=intrinsic-elements.d.ts.map \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7ebd7c6 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module list_app + +go 1.25.6 + +require modernc.org/sqlite v1.55.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.46.0 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8a1f4b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..039b925 --- /dev/null +++ b/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "log" + "net/http" + + _ "list_app/db" + + "list_app/controllers" + "list_app/routes" +) + +const hostname = "127.0.0.1" +const httpPort = "4080" + +func main() { + mux := routes.New() + + go controllers.CheckImages() + + log.Printf("Server running at http://%s:%s/\n", hostname, httpPort) + if err := http.ListenAndServe(hostname+":"+httpPort, mux); err != nil { + log.Fatal(err) + } +} diff --git a/models/mediaModel.go b/models/mediaModel.go new file mode 100644 index 0000000..2f841d2 --- /dev/null +++ b/models/mediaModel.go @@ -0,0 +1,92 @@ +package models + +import ( + "database/sql" + "log" + + "list_app/db" +) + +type Table string + +const ( + Movies Table = "movies" + Series Table = "series" + Games Table = "games" +) + +type Media struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Released string `json:"released"` + WebImg string `json:"webImg"` + Poster string `json:"poster"` + Year string `json:"year"` +} + +func Save(table Table, code, title, released, webImg, poster, year string) int64 { + sql := "INSERT INTO " + string(table) + " (code, title, released, webImg, poster, year) VALUES (?,?,?,?,?,?)" + + result, err := db.Pool.Exec(sql, code, title, released, webImg, poster, year) + if err != nil { + log.Println(err) + return 0 + } + changes, _ := result.RowsAffected() + return changes +} + +func UpdateWebImg(table Table, code, webImg string) int64 { + sql := "UPDATE " + string(table) + " SET webImg = ? WHERE code = ?;" + + result, err := db.Pool.Exec(sql, webImg, code) + if err != nil { + log.Println(err) + return 0 + } + changes, _ := result.RowsAffected() + return changes +} + +func FindOneAndDelete(table Table, code string) int64 { + result, err := db.Pool.Exec("DELETE FROM "+string(table)+" WHERE code = ?;", code) + if err != nil { + log.Println(err) + return 0 + } + changes, _ := result.RowsAffected() + return changes +} + +func FindOne(table Table, code string) []Media { + rows, err := db.Pool.Query("SELECT id, code, title, released, webImg, poster, year FROM "+string(table)+" WHERE code = ?;", code) + if err != nil { + log.Println(err) + return []Media{} + } + return scanMedia(rows) +} + +func Find(table Table) []Media { + rows, err := db.Pool.Query("SELECT id, code, title, released, webImg, poster, year FROM " + string(table) + ";") + if err != nil { + log.Println(err) + return []Media{} + } + return scanMedia(rows) +} + +func scanMedia(rows *sql.Rows) []Media { + defer rows.Close() + result := []Media{} + for rows.Next() { + var m Media + if err := rows.Scan(&m.ID, &m.Code, &m.Title, &m.Released, &m.WebImg, &m.Poster, &m.Year); err != nil { + log.Println(err) + continue + } + result = append(result, m) + } + return result +} diff --git a/models/userModel.go b/models/userModel.go new file mode 100644 index 0000000..b9893e9 --- /dev/null +++ b/models/userModel.go @@ -0,0 +1,31 @@ +package models + +import ( + "log" + + "list_app/db" +) + +type UserValue int + +const ( + Pass UserValue = iota + 1 + OmdbKey + TwitchClientID + TwitchClientSecret +) + +func GetValue(id UserValue) (string, bool) { + var value string + err := db.Pool.QueryRow("SELECT value FROM userData where id = ?;", int(id)).Scan(&value) + if err != nil { + log.Println(err) + return "", false + } + return value, true +} + +func CheckPassword(pass string) bool { + password, ok := GetValue(Pass) + return ok && password != "" && pass == password +} diff --git a/package.json b/package.json deleted file mode 100644 index 57c5a17..0000000 --- a/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "web", - "version": "0.0.0", - "private": true, - "dependencies": { - "@types/express": "^5.0.3", - "@types/morgan": "^1.9.10", - "express": "^5.1.0", - "morgan": "~1.10.1", - "bun-types": "^1.2.22", - "typescript": "^5.9.2" - } -} \ No newline at end of file diff --git a/public/embed.go b/public/embed.go new file mode 100644 index 0000000..4655536 --- /dev/null +++ b/public/embed.go @@ -0,0 +1,6 @@ +package public + +import "embed" + +//go:embed logo.ico no_poster.jpg +var FS embed.FS diff --git a/public/index.html b/public/index.html deleted file mode 100644 index 0718338..0000000 --- a/public/index.html +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - List - - - - -
- -
- -
-
-
- -
-
-
- - - - \ No newline at end of file diff --git a/routes/router.go b/routes/router.go new file mode 100644 index 0000000..74c72ea --- /dev/null +++ b/routes/router.go @@ -0,0 +1,35 @@ +package routes + +import ( + "encoding/json" + "net/http" + + "list_app/controllers" + "list_app/public" + "list_app/web" +) + +func New() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /{$}", web.Index) + mux.HandleFunc("POST /add", web.Add) + + mux.HandleFunc("GET /api", apiIndex) + mux.HandleFunc("GET /api/{$}", apiIndex) + mux.HandleFunc("GET /api/media/{mediaType}", controllers.List) + + // Poster images are fetched at runtime, so they're served from a real + // directory on disk rather than the embedded static assets below. + mux.Handle("/poster/", http.StripPrefix("/poster/", http.FileServer(http.Dir(controllers.PostersDir)))) + + mux.Handle("/", http.FileServerFS(public.FS)) + + return mux +} + +func apiIndex(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "API is working"}) +} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 437d3b8..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "compilerOptions": { - // Enable latest features - "lib": [ - "ESNext", - "DOM" - ], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react", - "jsxFactory": "elements.createElement", - "allowJs": true, - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - "resolveJsonModule": true, - "esModuleInterop": true, - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false, - "types": [ - "bun-types" - ], - "baseUrl": "./" - } -} \ No newline at end of file diff --git a/web/page.go b/web/page.go new file mode 100644 index 0000000..375f423 --- /dev/null +++ b/web/page.go @@ -0,0 +1,224 @@ +package web + +import ( + "log" + "net/http" + "net/url" + "os" + "sort" + "strconv" + "strings" + "time" + "unicode" + + "list_app/controllers" + "list_app/models" +) + +type mediaView struct { + Code string + Title string + Released string + WebImg string +} + +type group struct { + Label string + Items []mediaView +} + +type pageData struct { + ListType string + Sort string + Total int + Groups []group + Error string +} + +func tableFor(listType string) (models.Table, bool) { + switch listType { + case "movies": + return models.Movies, true + case "series": + return models.Series, true + case "games": + return models.Games, true + } + return "", false +} + +// resolveWebImg is decided at render time rather than falling back via +// client-side JS: if the poster hasn't been downloaded yet (or the file's +// missing), the fallback image is used directly in the rendered `src`. +func resolveWebImg(m models.Media) string { + if m.WebImg == "" { + return "/no_poster.jpg" + } + if _, err := os.Stat(controllers.PosterDiskPath(m.WebImg)); err != nil { + return "/no_poster.jpg" + } + return m.WebImg +} + +func toView(m models.Media) mediaView { + return mediaView{ + Code: m.Code, + Title: m.Title, + Released: m.Released, + WebImg: resolveWebImg(m), + } +} + +func toViews(media []models.Media) []mediaView { + views := make([]mediaView, len(media)) + for i, m := range media { + views[i] = toView(m) + } + return views +} + +func parseReleased(released string) time.Time { + t, err := time.Parse("2 Jan 2006", released) + if err != nil { + return time.Time{} + } + return t +} + +func groupLetter(title string) string { + if title == "" { + return "#" + } + r := []rune(strings.ToUpper(title))[0] + if unicode.IsDigit(r) { + return "#" + } + return string(r) +} + +func buildGroups(sortType string, media []models.Media) []group { + switch sortType { + case "year": + sort.SliceStable(media, func(i, j int) bool { + return parseReleased(media[i].Released).Before(parseReleased(media[j].Released)) + }) + + byYear := map[string][]models.Media{} + for _, m := range media { + key := strconv.Itoa(parseReleased(m.Released).Year()) + byYear[key] = append(byYear[key], m) + } + + keys := make([]string, 0, len(byYear)) + for k := range byYear { + keys = append(keys, k) + } + sort.Sort(sort.Reverse(sort.StringSlice(keys))) + + groups := make([]group, 0, len(keys)) + for _, k := range keys { + groups = append(groups, group{Label: k, Items: toViews(byYear[k])}) + } + return groups + + case "id": + sort.SliceStable(media, func(i, j int) bool { return media[i].ID > media[j].ID }) + return []group{{Label: "added", Items: toViews(media)}} + + default: // title + sort.SliceStable(media, func(i, j int) bool { + return strings.ToLower(media[i].Title) < strings.ToLower(media[j].Title) + }) + + byLetter := map[string][]models.Media{} + for _, m := range media { + key := groupLetter(m.Title) + byLetter[key] = append(byLetter[key], m) + } + + keys := make([]string, 0, len(byLetter)) + for k := range byLetter { + keys = append(keys, k) + } + sort.Strings(keys) + + groups := make([]group, 0, len(keys)) + for _, k := range keys { + groups = append(groups, group{Label: k, Items: toViews(byLetter[k])}) + } + return groups + } +} + +func Index(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + listType := q.Get("listType") + table, ok := tableFor(listType) + if !ok { + listType = "movies" + table = models.Movies + } + + sortType := q.Get("sortType") + if sortType != "title" && sortType != "year" && sortType != "id" { + sortType = "title" + } + + media := models.Find(table) + + data := pageData{ + ListType: listType, + Sort: sortType, + Total: len(media), + Groups: buildGroups(sortType, media), + Error: q.Get("error"), + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pageTemplate.Execute(w, data); err != nil { + log.Println(err) + } +} + +func redirectToList(w http.ResponseWriter, r *http.Request, listType, sortType, errMsg string) { + vals := url.Values{} + vals.Set("listType", listType) + vals.Set("sortType", sortType) + if errMsg != "" { + vals.Set("error", errMsg) + } + http.Redirect(w, r, "/?"+vals.Encode(), http.StatusSeeOther) +} + +// Add handles the plain HTML form submission for adding new media: no JS, +// so it always ends in a redirect back to the list, with an `error` query +// param set for the page to render as a flash message on failure. +func Add(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + + pass := r.FormValue("pass") + code := r.FormValue("code") + listType := r.FormValue("listType") + sortType := r.FormValue("sortType") + + if pass == "" || code == "" { + redirectToList(w, r, listType, sortType, "") + return + } + + if !models.CheckPassword(pass) { + redirectToList(w, r, listType, sortType, "Incorrect password") + return + } + + if _, err := controllers.Create(code); err != nil { + redirectToList(w, r, listType, sortType, err.Error()) + return + } + + redirectToList(w, r, listType, sortType, "") +} diff --git a/web/page.html b/web/page.html new file mode 100644 index 0000000..a76c9a4 --- /dev/null +++ b/web/page.html @@ -0,0 +1,105 @@ + + + + + + + + + + + + + List + + + +
+ +
+ +
+
+
+ {{if .Error}} +
+
+ +
+
+ {{end}} + +
+
+

{{.Total}}

+
+
+ + {{range .Groups}} +
+
+

{{.Label}}: {{len .Items}}

+
+
+
+ {{range .Items}} +
+
+ +
+
{{.Title}}
+

{{.Released}}

+
+
+
+ {{end}} +
+ {{end}} +
+
+
+ + + + diff --git a/web/templates.go b/web/templates.go new file mode 100644 index 0000000..0ace52f --- /dev/null +++ b/web/templates.go @@ -0,0 +1,11 @@ +package web + +import ( + "embed" + "html/template" +) + +//go:embed page.html +var templateFS embed.FS + +var pageTemplate = template.Must(template.ParseFS(templateFS, "page.html"))