Convert to go

This commit is contained in:
2026-07-30 00:30:27 +02:00
parent 42fb4e90b4
commit 4518a4a363
35 changed files with 962 additions and 1797 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
node_modules/
output/
posters/
*.sqlite
.vscode
main
+30 -34
View File
@@ -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 files
│ ├── index.html # Main HTML file
├── public/ # Static assets, embedded into the Go binary
│ ├── embed.go # go:embed directive
│ ├── logo.ico # Favicon
│ └── no_poster.jpg # Fallback poster image
├── package.json # Project dependencies
├── 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 `<img src>`, 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
```
-24
View File
@@ -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();
-252
View File
@@ -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
};
@@ -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;
-84
View File
@@ -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
};
-31
View File
@@ -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
};
-12
View File
@@ -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;
-13
View File
@@ -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;
-5
View File
@@ -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/
-190
View File
@@ -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=="],
}
}
+277
View File
@@ -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)
}
}
+52 -27
View File
@@ -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;
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", "")
}
-71
View File
@@ -1,71 +0,0 @@
/// <reference path="./utils/element-types.d.ts" />
/// <reference path="./utils/events.d.ts" />
/// <reference path="./utils/intrinsic-elements.d.ts" />
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;
}
-35
View File
@@ -1,35 +0,0 @@
import type { Attributes } from "frontend/elementcreate";
import * as elements from "frontend/elementcreate";
function MediaElement(attributes: Attributes, contents: string[]) {
const ret = <div class="col media-element" id={attributes['id']}>
<div class='card shadow-sm'>
<img class='card-img-top' src={attributes['webImg']} width='100%' onerror={attributes['imageError']} loading="lazy" style="min-height: 100px;"></img>
<div class='card-body'>
<h5 class='card-title'>{attributes['title']}</h5>
<p class='card-text'>{attributes['released']}</p>
<div class="d-none justify-content-between align-items-center">
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick={attributes['fun']}>delete</button>
</div>
</div>
</div>
</div>
</div>;
return ret;
}
function MyHeader(attributes: Attributes, contents: string[]) {
return <div class="row">
<div class='col'>
<h2 class='text-center'>{attributes['title']} {attributes['num'] ? ": " + attributes['num'] : ""}</h2>
</div>
</div>;
}
function MediaContainer(attributes: Attributes, contents: string[]) {
return <div id={attributes['id']} class="row row-cols-2 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 g-3">{contents[0]}</div>;
}
export { MediaElement, MyHeader, MediaContainer }
-25
View File
@@ -1,25 +0,0 @@
function splitByTitle(movies: Array<Movie>): { [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<Movie>): { [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 };
-287
View File
@@ -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<HTMLElement | null> = [];
var sortButtons: Array<HTMLElement | null> = [];
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<Movie>): { [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<Movie>) {
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(<MyHeader title={unsorted_movies.length} />);
for (const letter of years) {
const movies = splitMovies[letter];
const header = <MyHeader title={letter} num={movies.length} />;
root.appendChild(header);
const row =
<MediaContainer id={letter}>
{movies.map(movie => {
const med = <MediaElement webImg={movie.webImg} title={movie.title} released={movie.released} id={movie.code} fun={removeMedia} imageError={onImgError}></MediaElement>;
movieElements.push(med);
return med;
})}
</MediaContainer>;
root.appendChild(row);
}
}
-7
View File
@@ -1,7 +0,0 @@
interface Movie {
title: string;
released: string;
code: string;
webImg: string;
id: string;
}
-1
View File
@@ -1 +0,0 @@
type AttributeValue = number | string | EventListener;
-341
View File
@@ -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
-98
View File
@@ -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
-118
View File
@@ -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
+17
View File
@@ -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
)
+51
View File
@@ -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=
+25
View File
@@ -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)
}
}
+92
View File
@@ -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
}
+31
View File
@@ -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
}
-13
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
package public
import "embed"
//go:embed logo.ico no_poster.jpg
var FS embed.FS
-74
View File
@@ -1,74 +0,0 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/logo.ico" type="image/x-icon">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous"></script>
<title>List</title>
<script src="/list.js"></script>
</head>
<body>
<header>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<button class="nav-link active" id="movieButton">Movies</button>
</li>
<li class="nav-item">
<button class="nav-link" id="seriesButton">Series</button>
</li>
<li class="nav-item">
<button class="nav-link" id="gameButton">Games</button>
</li>
<li class="nav-item dropdown">
<button class="nav-link dropdown-toggle" id="navbarDropdown" role="button" data-bs-toggle="dropdown"
aria-expanded="false">
Sort
</button>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><button class="dropdown-item" id="titleButton">Title</button></li>
<li><button class="dropdown-item" id="yearButton">Year</button></li>
<li><button class="dropdown-item" id="idButton">Added</button></li>
</ul>
</li>
<li class="nav-item">
<button class="nav-link" id="editButton">Edit</button>
</li>
</ul>
<form class="d-flex" action="" id="myform">
<input class="form-control me-2" type="password" name="password" id="pass" placeholder="password">
<input class="form-control me-2" type="text" name="input_id" id="input_id" placeholder="id">
<input class="btn btn-outline-success" type="submit" value="Submit">
</form>
</div>
</div>
</nav>
</header>
<main>
<div class="py-5 bg-body-tertiary mt-3">
<div class="container" id="root">
</div>
</div>
</main>
</body>
</html>
+35
View File
@@ -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"})
}
-34
View File
@@ -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": "./"
}
}
+224
View File
@@ -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, "")
}
+105
View File
@@ -0,0 +1,105 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/logo.ico" type="image/x-icon">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous"></script>
<title>List</title>
</head>
<body>
<header>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link{{if eq .ListType "movies"}} active{{end}}" href="/?listType=movies&sortType={{.Sort}}">Movies</a>
</li>
<li class="nav-item">
<a class="nav-link{{if eq .ListType "series"}} active{{end}}" href="/?listType=series&sortType={{.Sort}}">Series</a>
</li>
<li class="nav-item">
<a class="nav-link{{if eq .ListType "games"}} active{{end}}" href="/?listType=games&sortType={{.Sort}}">Games</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
aria-expanded="false">
Sort
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/?listType={{.ListType}}&sortType=title">Title</a></li>
<li><a class="dropdown-item" href="/?listType={{.ListType}}&sortType=year">Year</a></li>
<li><a class="dropdown-item" href="/?listType={{.ListType}}&sortType=id">Added</a></li>
</ul>
</li>
</ul>
<form class="d-flex" method="POST" action="/add">
<input type="hidden" name="listType" value="{{.ListType}}">
<input type="hidden" name="sortType" value="{{.Sort}}">
<input class="form-control me-2" type="password" name="pass" placeholder="password">
<input class="form-control me-2" type="text" name="code" placeholder="id">
<input class="btn btn-outline-success" type="submit" value="Submit">
</form>
</div>
</div>
</nav>
</header>
<main>
<div class="py-5 bg-body-tertiary mt-3">
<div class="container" id="root">
{{if .Error}}
<div class="row">
<div class="col">
<div class="alert alert-danger" role="alert">{{.Error}}</div>
</div>
</div>
{{end}}
<div class="row">
<div class="col">
<h2 class="text-center">{{.Total}}</h2>
</div>
</div>
{{range .Groups}}
<div class="row">
<div class="col">
<h2 class="text-center">{{.Label}}: {{len .Items}}</h2>
</div>
</div>
<div class="row row-cols-2 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 g-3">
{{range .Items}}
<div class="col media-element" id="{{.Code}}">
<div class="card shadow-sm">
<img class="card-img-top" src="{{.WebImg}}" width="100%" loading="lazy" style="min-height: 100px;">
<div class="card-body">
<h5 class="card-title">{{.Title}}</h5>
<p class="card-text">{{.Released}}</p>
</div>
</div>
</div>
{{end}}
</div>
{{end}}
</div>
</div>
</main>
</body>
</html>
+11
View File
@@ -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"))