Compare commits

164 Commits

Author SHA1 Message Date
5999eccc21 Merge branch 'main' of ssh://adam-french.co.uk:2222/adamf/web_server
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 59s
2026-03-16 16:47:22 +00:00
7155255733 Add rowing to store 2026-03-16 16:44:02 +00:00
6ff30a37f7 Add rowing to store 2026-03-16 16:41:30 +00:00
a4514ad98d Upgrade go version
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 11s
2026-03-16 16:22:11 +00:00
84e18dddfa Update go version to 1.25 2026-03-16 15:45:34 +00:00
b4ddb4d402 Stop tracking app.ini to prevent runtime secrets from blocking git pull
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
Gitea populates secret fields (LFS_JWT_SECRET, SECRET_KEY, etc.) at
startup, causing app.ini to always show as modified. Since secrets are
already passed via environment variables, the tracked file is replaced
with an ignored app.ini and a tracked app.ini.template for reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:35:43 +00:00
0360b1f7f1 Consolidate frontend REST calls with GraphQL
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
Replace 5 separate REST calls on home page load with a single GraphQL
query. Add homeData store that fetches posts, favorites, activities,
spotify, and auth in one request. Convert all admin mutations and
auth flows to use GraphQL. Add album images to Spotify GraphQL schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:29:22 +00:00
36817277f9 Revert "Don't use internal tokens"
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 0s
This reverts commit a03ce26824.
2026-03-16 09:54:25 +00:00
a03ce26824 Don't use internal tokens
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
2026-03-16 09:39:48 +00:00
a10706506e Make navbar sticky
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m5s
2026-03-13 17:58:52 +00:00
f29e937307 Fix layout so content divs fill remaining space and scroll on overflow
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:52:27 +00:00
81cb2bc4b5 Move Gitea secrets to environment variables
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 41s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:46:18 +00:00
8b5ed9abec Don't make header scroll
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m34s
2026-03-13 17:37:03 +00:00
8cdab593ae Okay this is the fix, please don't judge me for how long this took for me too look at, I think I really should just set a fixed height for the parent container.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m43s
2026-03-13 16:39:12 +00:00
b63cc911a7 Should defo be the fix please god
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m24s
2026-03-13 16:35:02 +00:00
e1fe281586 Should defo be the fix please god
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m42s
2026-03-13 16:31:47 +00:00
887d23af5b Make only messages scroll
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m54s
2026-03-13 16:25:34 +00:00
36aa7ed907 Fix overextending flexbox & abandon ship on flex-1 approach, and probably should just use fixed height for parent container
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m22s
2026-03-13 15:47:43 +00:00
d5065d19e0 Make sidebars take up full space
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m2s
2026-03-13 15:30:34 +00:00
15c721ea56 Make commit scroll
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
2026-03-13 15:12:59 +00:00
b47d1a3df3 Fix checkStream function and useTemplateRef instead of implicit
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m35s
2026-03-10 22:40:15 +00:00
5b3cd267b6 Clear messages on reconnect
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m48s
2026-03-10 22:28:13 +00:00
6033a952af Automatically reconnect to websocket
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m27s
2026-03-10 22:12:42 +00:00
0ad7f4e009 Radio is too damn loud, change frontend instead of server
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m27s
2026-03-10 21:41:45 +00:00
6bf773487a Radio is too damn loud
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19s
2026-03-10 21:29:44 +00:00
2916afe206 Add fallback music directory to repo
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m30s
2026-03-10 14:02:23 +00:00
17deec23ba Remove music from git
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-10 13:59:07 +00:00
ad4d02228d Add prune to deploy
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-10 13:56:57 +00:00
d5fbc0ee74 Hide overflow
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m6s
2026-03-10 13:44:02 +00:00
857f66cb37 Make radio check the stream endpoint
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m22s
2026-03-10 13:40:51 +00:00
5b041d7364 Add fallible
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19s
2026-03-10 13:33:54 +00:00
4be7e60394 syntax for setting of liquidsoap changed
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7s
2026-03-10 13:26:19 +00:00
27f74f6c2a /etc/ permissions for radio user
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 34s
2026-03-10 13:23:59 +00:00
5a19f09e17 Add fallback music to repository
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m54s
2026-03-10 12:59:27 +00:00
469a225860 Add fallback music to icecast server
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-10 12:58:53 +00:00
cd1bcc7f39 Make chatbox scroll to bottom once all images have loaded
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m44s
2026-03-10 12:43:38 +00:00
14cacec1f5 Correct styles on admin panels and enter triggers submission
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m34s
2026-03-10 12:41:45 +00:00
7991c80176 Allow admin to create user
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m22s
2026-03-10 12:32:59 +00:00
bad44a6ddd Separate admin protected endpoints from non-admin endpoints 2026-03-10 12:32:47 +00:00
0b256863d6 Merge branch 'main' of ssh://adam-french.co.uk:2222/adamf/web_server
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m57s
2026-03-10 12:20:00 +00:00
cb326ff8bf Add promote / demote user to admin and reintroduce create user dashboard 2026-03-10 12:18:24 +00:00
78d6c3d4f0 Add promote / demote user to admin and reintroduce create user dashboard
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 24s
2026-03-10 12:17:07 +00:00
c7dbf5b778 Include make to dependencies
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m17s
2026-03-10 12:13:42 +00:00
a8d1b879be Changes to docker configuration to decrease build time
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1m35s
2026-03-10 12:07:22 +00:00
f82389225c Changes to docker configuration to decrease build time 2026-03-10 12:07:13 +00:00
165852e738 Remove extra attach and rename bottom button
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m18s
2026-03-10 12:02:14 +00:00
c58c19cc1e Make links clickable
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m49s
2026-03-10 11:58:06 +00:00
26ea0108e0 Add scroll to bottom on chat
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-10 11:56:52 +00:00
604576b46a Only show attach button if user is admin
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m4s
2026-03-09 18:11:03 +00:00
33d72fd20a Fix side scrolling for iphones
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m1s
2026-03-09 18:03:19 +00:00
d3cbc687d5 Fix sidebar on first breakpoint
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m55s
2026-03-09 18:00:51 +00:00
d7b76e4742 open port 3000 for gitea runner
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2m36s
2026-03-09 17:55:10 +00:00
64c2ba5562 Fix page height on first breakpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:50:49 +00:00
6796367dbe Fix page height on first breakpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:40:00 +00:00
c2580c984d Allow chat to get videos
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:30:54 +00:00
68db930049 Don't use SaveUploadedFile (causing permission issues)
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:21:26 +00:00
63da086da2 Removed setting own permissions, let dockerfile entryhost do it
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:10:24 +00:00
6326a438dc Halftone + mask reduces performance alot, change background
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:56:20 +00:00
7c980f1b1f Fix file permissions, still
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:53:45 +00:00
141ceab7e6 Reduce performance lost on large screens
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:41:55 +00:00
d03f9668ad Add error handling 2026-03-09 16:41:38 +00:00
41d6cf0dac omg fix undefined variable
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:29:56 +00:00
1e3c6adf5e Fix file permissions on image upload
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:23:44 +00:00
99ddd7d494 Fix file permissions
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:20:47 +00:00
8e50537333 Get AI to fix vunerabilities in site
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 14:12:29 +00:00
85a2325683 change file permissions to /uploads
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m0s
2026-03-09 13:59:59 +00:00
0a8a752433 Add file upload to website and integrate into chat
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m12s
2026-03-09 13:47:45 +00:00
4c396ef30f Add file upload to website and integrate into chat
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 13:47:38 +00:00
77e2c272cb Update mobile layout adjustments
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m53s
2026-03-09 13:06:17 +00:00
1578a05762 Remove extra whitespace on CV.vue and stopped height becoming 0 on image transitions
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-03-09 12:07:05 +00:00
a6bc1d5126 Add transcript to site
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-03-09 11:58:44 +00:00
2737b4f0d0 Avoid panic on spotify if not authed
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m35s
2026-03-07 17:46:55 +00:00
9fa953c969 Add local dev mode with HTTP-only nginx and DB seeding)
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m11s
2026-03-07 17:36:54 +00:00
5a45f1f427 Revert "Update notes component to reflect obsidian notes"
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 28s
This reverts commit 4458844029.
2026-03-07 17:07:21 +00:00
4458844029 Update notes component to reflect obsidian notes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m54s
2026-03-07 17:04:23 +00:00
3200ef5bee make text size even larger
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m42s
2026-03-07 16:57:46 +00:00
0da6d3f0ed check duplicates before making claude request
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m31s
2026-03-07 16:51:11 +00:00
88ce32abeb make popup text size larger
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m45s
2026-03-07 16:46:03 +00:00
adcf1bda48 Check that paces are reasonable
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-07 16:43:08 +00:00
7450b5a624 Add gym chart to show rowing results
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m45s
2026-03-07 16:31:15 +00:00
ab2b0a1e3d Add padding to chat
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
2026-03-06 16:09:14 +00:00
ff82b8bdf9 Change author ID colour
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m48s
2026-03-06 16:02:37 +00:00
1429a6a5cb Change author ID colour
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-06 16:01:44 +00:00
7a71484ecc Show author id in chat log
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m57s
2026-03-06 15:56:12 +00:00
e1563b55f4 remove todo make persistent chatlog from readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8s
2026-03-06 13:29:59 +00:00
4fbeabc3ae Make chat longer
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m43s
2026-03-05 21:57:54 +00:00
a83b98eb2b Make chat persistent across reboot
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m25s
2026-03-05 21:43:04 +00:00
5346b24999 Make chat component autoscroll
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-03-05 21:31:00 +00:00
3779a1cbcc Add important todo to site
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4s
2026-03-05 20:38:24 +00:00
3f39f6327c Lookmaxing
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m54s
2026-03-05 20:32:14 +00:00
9dc9a3a063 Pose max message limit on chat function so no crash ^_^
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m30s
2026-03-05 20:07:08 +00:00
a6b543cf65 Make chat component look nicer
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m36s
2026-03-05 19:58:07 +00:00
4a65836210 Make chat component look nicer and upgrade websocket connection
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
2026-03-05 19:51:33 +00:00
95635c86b3 Fix up live chat
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m26s
2026-03-05 19:14:05 +00:00
3056b23b50 nicer name
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-03-05 19:04:50 +00:00
72013f5cdd update readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-03-05 19:03:40 +00:00
7aa62659e5 remove silly background
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m35s
2026-03-05 17:35:25 +00:00
aa3f0a189d refactor slideshow code to its component and added Miku & miku background
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m42s
2026-03-05 13:31:28 +00:00
646f93136d update rowing information to non fricken nanoseconds who though time.Durations should be nanoseconds
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m45s
2026-03-04 16:48:21 +00:00
54852eba82 Update CV
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
2026-03-04 16:21:30 +00:00
e43c07b30a more verbose error response
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m17s
2026-03-04 16:10:52 +00:00
190bc6076b remove json boilerplate, log error and return response
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m45s
2026-03-04 15:58:14 +00:00
88884121ab allow multiple files to be uploaded to rowing endpoint
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m51s
2026-03-04 15:26:17 +00:00
e25fc5f1d1 implement create rowing component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m39s
2026-03-04 14:45:50 +00:00
5bcc65668e move admin component files and add rowing component 2026-03-04 14:40:56 +00:00
2c1ecce99a Merge remote-tracking branch 'gitea/rowing'
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m48s
2026-03-04 14:32:34 +00:00
f2ba3494b1 remove no slip
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m35s
2026-03-03 10:32:38 +00:00
d56bd5783d eliminate the wretched hiding console.log, filthy log......
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m20s
2026-02-24 22:48:13 +00:00
f60636942f eliminate the wretched hiding console.log, filthy log......
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m31s
2026-02-24 22:40:55 +00:00
b087172bb1 slowed down the whole animation industry
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-02-24 22:31:54 +00:00
0c93c6bc27 make colors match
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m41s
2026-02-23 16:16:09 +00:00
48ae2f59ea adding important context
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m37s
2026-02-23 16:06:05 +00:00
c9faa90abd adding important context
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m43s
2026-02-23 16:03:52 +00:00
ef78974744 stamps width fix, also screw mobile even more
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m39s
2026-02-23 15:56:52 +00:00
49499052b0 add link to feed
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m49s
2026-02-23 15:51:56 +00:00
dbb4914745 fix mobile, nobody likes mobile......... hate mobile
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-02-23 15:49:04 +00:00
34fa96ddab adding git feed
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
2026-02-23 15:45:46 +00:00
8a9f3c373d Update links
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m27s
2026-02-23 14:23:43 +00:00
dc05ade798 update readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m29s
2026-02-23 14:15:32 +00:00
1e47919a40 update readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-23 13:23:50 +00:00
8e9734fca7 update cv
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m27s
2026-02-23 13:14:41 +00:00
da9a083f2d eddy fix
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m34s
2026-02-21 20:06:59 +00:00
3c40eb9f08 I should seriously look at what I'm commiting before I commit it.... >_< dw I won't do this in professional context
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m26s
2026-02-20 16:28:16 +00:00
e016e3af46 fix infinite loop :( shouldn't have existed, silly programmer
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 50s
2026-02-20 16:26:22 +00:00
0c91f512b4 adding more detail to cv
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m23s
2026-02-20 16:24:15 +00:00
f63b61431b maybe bedroom is not professional
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
2026-02-20 16:22:39 +00:00
f3ea83c477 changed cv
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-02-20 16:13:01 +00:00
4b5ed4787a center text and fullwidth button
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-02-19 21:41:49 +00:00
747a403bcb idk what this changes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-18 21:45:17 +00:00
fe16ccab97 add ignore to webpack future content
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10s
2026-02-18 21:43:51 +00:00
7bcb485fc6 move radio and add to homepage
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-02-18 21:42:37 +00:00
a3d73b12f4 Adding gitea link
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-02-18 21:29:30 +00:00
47a8e6c35e Adding gitea link
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m22s
2026-02-18 21:27:43 +00:00
f885ff9175 Adding gitea link
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m25s
2026-02-18 21:22:41 +00:00
d574fa7692 change intro
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m20s
2026-02-18 21:13:32 +00:00
ac171f7846 yes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
Merge branch 'main' of ssh://adam-french.co.uk:2222/adamf/web_server
2026-02-18 21:09:20 +00:00
b5b86a2a37 revert to old website intro 2026-02-18 21:08:33 +00:00
cfdb5b4d50 Update .gitea/workflows/deploy.yaml
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-18 16:38:06 +00:00
37580cdc42 Stop da haxxers
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-18 16:26:52 +00:00
711236b776 fix actions
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m35s
2026-02-18 16:21:52 +00:00
75454c2ed8 fix actions
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 5s
2026-02-18 16:19:58 +00:00
78c824c4c8 remove mountpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2m56s
2026-02-17 18:37:46 +00:00
ba3b933068 remove mountpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 5s
2026-02-17 18:36:23 +00:00
14c430bbad adding gitea actions
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2s
2026-02-17 18:34:55 +00:00
26c7422e34 adding gitea-runner service 2026-02-17 18:10:19 +00:00
470b1c79d8 fix ssh domain for gitea 2026-02-16 16:57:20 +00:00
d849b606ec fix certbot env vars 2026-02-16 15:07:44 +00:00
46a9da4c90 gitea runner 2026-02-16 14:52:33 +00:00
398a610cb2 fix root of gitea 2026-02-16 13:58:09 +00:00
b506bae515 fix gitea 2026-02-16 13:57:28 +00:00
11ad0b5a83 reverse proxy to gitea 2026-02-16 13:21:11 +00:00
d7393e1419 Revert "Revert "idk what I changed""
This reverts commit 0d32333c0c.
2026-02-16 13:03:13 +00:00
0d32333c0c Revert "idk what I changed"
This reverts commit 7dc3f49273.
2026-02-16 13:02:31 +00:00
050a38a76f Merge branch 'main' of /home/adamf/repos/web_server 2026-02-16 12:56:23 +00:00
bc43e9ed02 add gitea 2026-02-16 12:52:08 +00:00
75b8b02825 ignore gitea volume 2026-02-16 12:51:48 +00:00
5c69a1d0a7 adding gitea 2026-02-16 11:46:49 +00:00
aa915e1071 Merge branch 'main' of 192.168.178.24:~/repos/web_server
ye
2026-02-14 22:03:47 +00:00
7dc3f49273 idk what I changed 2026-02-14 22:03:32 +00:00
21d3997a16 remove ts 2026-02-10 17:04:52 +00:00
c56ba217dd remove tsconfig 2026-02-10 17:01:26 +00:00
91804f1fe7 adding vueuse 2026-02-10 17:00:59 +00:00
7e74ce5a2a adding typescript 2026-02-10 16:57:00 +00:00
e92ac49140 new components 2026-02-10 16:46:49 +00:00
111 changed files with 13058 additions and 1516 deletions

View File

@@ -0,0 +1,22 @@
name: Deploy with Docker Compose
on:
push:
branches: [main]
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Pull changes
working-directory: /home/adamf/deploy/web_server
run: git pull gitea main
- name: Run docker compose up
working-directory: /home/adamf/deploy/web_server
env:
DOCKER_API_VERSION: "1.41"
run: docker compose up -d --build --remove-orphans
- name: Prune unused Docker resources
run: docker image prune -f

10
.gitignore vendored
View File

@@ -1,8 +1,17 @@
icecast2/fallback_music/*
!icecast2/fallback_music/.gitkeep
certbot/conf certbot/conf
certbot/www certbot/www
backend/token/ backend/token/
.env .env
gitea/config/app.ini
gitea/data/*
gitea-runner/data/*
# Will add in future (webpack)
nginx/vue/crates/
# Logs # Logs
logs logs
*.log *.log
@@ -41,5 +50,6 @@ coverage
# Vitest # Vitest
__screenshots__/ __screenshots__/
.deploy .deploy
*.xcf *.xcf

View File

@@ -1,4 +1,4 @@
FROM golang:1.24 FROM golang:1.25
WORKDIR /backend WORKDIR /backend

View File

@@ -1,19 +1,24 @@
module adam-french.co.uk/backend module adam-french.co.uk/backend
go 1.24.0 go 1.25
require ( require (
github.com/99designs/gqlgen v0.17.88
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
github.com/vektah/gqlparser/v2 v2.5.32
github.com/zmb3/spotify/v2 v2.4.3 github.com/zmb3/spotify/v2 v2.4.3
golang.org/x/crypto v0.43.0 golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.30.0 golang.org/x/oauth2 v0.30.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
require ( require (
github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
@@ -22,10 +27,11 @@ require (
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -41,7 +47,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect github.com/quic-go/quic-go v0.54.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect github.com/sosodev/duration v1.4.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
@@ -50,12 +56,11 @@ require (
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.46.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.42.0 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.36.11 // indirect
google.golang.org/protobuf v1.36.10 // indirect
) )

View File

@@ -31,10 +31,22 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
@@ -50,6 +62,10 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -71,10 +87,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -102,10 +120,7 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -129,12 +144,16 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
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/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
@@ -178,6 +197,10 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -203,6 +226,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -226,8 +251,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -260,8 +285,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -294,14 +319,13 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 h1:Ati8dO7+U7mxpkPSxBZQEvzHVUYB/MqCklCN8ig5w/o=
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
@@ -315,8 +339,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -351,8 +375,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -368,8 +392,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -415,8 +439,8 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -443,7 +467,6 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -499,12 +522,14 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

59
backend/gqlgen.yml Normal file
View File

@@ -0,0 +1,59 @@
schema:
- graph/schema/*.graphql
exec:
filename: graph/generated.go
package: graph
model:
filename: graph/model/models_gen.go
package: model
resolver:
layout: follow-schema
dir: graph
package: graph
filename_template: "{name}.resolvers.go"
models:
ID:
model:
- github.com/99designs/gqlgen/graphql.IntID
User:
model:
- adam-french.co.uk/backend/models.User
fields:
password:
resolver: false
deletedAt:
resolver: false
Post:
model:
- adam-french.co.uk/backend/models.Post
fields:
deletedAt:
resolver: false
Activity:
model:
- adam-french.co.uk/backend/models.Activity
fields:
deletedAt:
resolver: false
Favorite:
model:
- adam-french.co.uk/backend/models.Favorite
fields:
deletedAt:
resolver: false
Rowing:
model:
- adam-french.co.uk/backend/models.Rowing
fields:
deletedAt:
resolver: false
Message:
model:
- adam-french.co.uk/backend/models.Message
fields:
deletedAt:
resolver: false

View File

@@ -0,0 +1,22 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"adam-french.co.uk/backend/models"
)
// ID is the resolver for the id field.
func (r *activityResolver) ID(ctx context.Context, obj *models.Activity) (int, error) {
return int(obj.ID), nil
}
// Activity returns ActivityResolver implementation.
func (r *Resolver) Activity() ActivityResolver { return &activityResolver{r} }
type activityResolver struct{ *Resolver }

52
backend/graph/context.go Normal file
View File

@@ -0,0 +1,52 @@
package graph
import (
"context"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const (
userClaimsKey contextKey = "userClaims"
ginContextKey contextKey = "ginContext"
)
func UserClaimsFromCtx(ctx context.Context) *jwt.MapClaims {
claims, ok := ctx.Value(userClaimsKey).(*jwt.MapClaims)
if !ok {
return nil
}
return claims
}
func UserIDFromCtx(ctx context.Context) (uint, bool) {
claims := UserClaimsFromCtx(ctx)
if claims == nil {
return 0, false
}
idF, ok := (*claims)["id"].(float64)
if !ok {
return 0, false
}
return uint(idF), true
}
func IsAdminFromCtx(ctx context.Context) bool {
claims := UserClaimsFromCtx(ctx)
if claims == nil {
return false
}
admin, ok := (*claims)["admin"].(bool)
return ok && admin
}
func GinContextFromCtx(ctx context.Context) *gin.Context {
gc, ok := ctx.Value(ginContextKey).(*gin.Context)
if !ok {
return nil
}
return gc
}

View File

@@ -0,0 +1,22 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"adam-french.co.uk/backend/models"
)
// ID is the resolver for the id field.
func (r *favoriteResolver) ID(ctx context.Context, obj *models.Favorite) (int, error) {
return int(obj.ID), nil
}
// Favorite returns FavoriteResolver implementation.
func (r *Resolver) Favorite() FavoriteResolver { return &favoriteResolver{r} }
type favoriteResolver struct{ *Resolver }

7656
backend/graph/generated.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"adam-french.co.uk/backend/models"
)
// ID is the resolver for the id field.
func (r *messageResolver) ID(ctx context.Context, obj *models.Message) (int, error) {
return int(obj.ID), nil
}
// AuthorID is the resolver for the authorId field.
func (r *messageResolver) AuthorID(ctx context.Context, obj *models.Message) (int, error) {
return int(obj.AuthorID), nil
}
// Message returns MessageResolver implementation.
func (r *Resolver) Message() MessageResolver { return &messageResolver{r} }
type messageResolver struct{ *Resolver }

View File

@@ -0,0 +1,25 @@
package graph
import (
"context"
"adam-french.co.uk/backend/services"
"github.com/gin-gonic/gin"
)
func AuthContextMiddleware(auth *services.Auth) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), ginContextKey, c)
accessToken, err := c.Cookie("access_token")
if err == nil {
claims, err := auth.VerifyJWT(accessToken)
if err == nil {
ctx = context.WithValue(ctx, userClaimsKey, claims)
}
}
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}

View File

@@ -0,0 +1,80 @@
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package model
import (
"time"
"adam-french.co.uk/backend/models"
)
type AuthPayload struct {
User *models.User `json:"user"`
}
type CreateActivityInput struct {
Type string `json:"type"`
Name string `json:"name"`
Link *string `json:"link,omitempty"`
}
type CreateFavoriteInput struct {
Type string `json:"type"`
Name string `json:"name"`
Link *string `json:"link,omitempty"`
}
type CreatePostInput struct {
Title string `json:"title"`
Content string `json:"content"`
}
type CreateUserInput struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginInput struct {
Username string `json:"username"`
Password string `json:"password"`
}
type Mutation struct {
}
type Query struct {
}
type SpotifyAlbum struct {
Name string `json:"name"`
Images []*SpotifyImage `json:"images"`
}
type SpotifyArtist struct {
Name string `json:"name"`
}
type SpotifyImage struct {
URL string `json:"url"`
}
type SpotifyPlaying struct {
Playing bool `json:"playing"`
Track *SpotifyTrack `json:"track,omitempty"`
}
type SpotifyRecentItem struct {
Track *SpotifyTrack `json:"track"`
PlayedAt time.Time `json:"playedAt"`
}
type SpotifyTrack struct {
Name string `json:"name"`
Artists []*SpotifyArtist `json:"artists"`
Album *SpotifyAlbum `json:"album"`
}
type UpdatePostInput struct {
Title string `json:"title"`
Content string `json:"content"`
}

View File

@@ -0,0 +1,22 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"adam-french.co.uk/backend/models"
)
// ID is the resolver for the id field.
func (r *postResolver) ID(ctx context.Context, obj *models.Post) (int, error) {
return int(obj.ID), nil
}
// Post returns PostResolver implementation.
func (r *Resolver) Post() PostResolver { return &postResolver{r} }
type postResolver struct{ *Resolver }

12
backend/graph/resolver.go Normal file
View File

@@ -0,0 +1,12 @@
package graph
import "adam-french.co.uk/backend/handlers"
// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require
// here.
type Resolver struct {
Store *handlers.Store
}

View File

@@ -0,0 +1,32 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"adam-french.co.uk/backend/models"
)
// ID is the resolver for the id field.
func (r *rowingResolver) ID(ctx context.Context, obj *models.Rowing) (int, error) {
return int(obj.ID), nil
}
// Time is the resolver for the time field.
func (r *rowingResolver) Time(ctx context.Context, obj *models.Rowing) (int, error) {
return int(obj.Time), nil
}
// Distance is the resolver for the distance field.
func (r *rowingResolver) Distance(ctx context.Context, obj *models.Rowing) (int, error) {
return int(obj.Distance), nil
}
// Rowing returns RowingResolver implementation.
func (r *Resolver) Rowing() RowingResolver { return &rowingResolver{r} }
type rowingResolver struct{ *Resolver }

View File

@@ -0,0 +1,456 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"fmt"
"net/http"
"time"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models"
spotify "github.com/zmb3/spotify/v2"
"golang.org/x/crypto/bcrypt"
)
// Login is the resolver for the login field.
func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*model.AuthPayload, error) {
gc := GinContextFromCtx(ctx)
if gc == nil {
return nil, fmt.Errorf("could not get gin context")
}
var user models.User
if err := r.Store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
return nil, fmt.Errorf("invalid credentials")
}
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
return nil, fmt.Errorf("invalid credentials")
}
tokens, err := r.Store.Auth.GenerateJWT(&user)
if err != nil {
return nil, fmt.Errorf("failed to generate tokens")
}
gc.SetSameSite(http.SameSiteLaxMode)
gc.SetCookie(
"access_token",
tokens.AccessToken,
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
r.Store.Auth.Config.Endpoint,
r.Store.Auth.Config.Domain,
true, true,
)
gc.SetCookie(
"refresh_token",
tokens.RefreshToken,
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
r.Store.Auth.Config.Endpoint,
r.Store.Auth.Config.Domain,
true, true,
)
return &model.AuthPayload{User: &user}, nil
}
// Logout is the resolver for the logout field.
func (r *mutationResolver) Logout(ctx context.Context) (bool, error) {
gc := GinContextFromCtx(ctx)
if gc == nil {
return false, fmt.Errorf("could not get gin context")
}
gc.SetSameSite(http.SameSiteLaxMode)
gc.SetCookie("access_token", "", -1, "", "", true, true)
gc.SetCookie("refresh_token", "", -1, "", "", true, true)
return true, nil
}
// RefreshToken is the resolver for the refreshToken field.
func (r *mutationResolver) RefreshToken(ctx context.Context) (*model.AuthPayload, error) {
gc := GinContextFromCtx(ctx)
if gc == nil {
return nil, fmt.Errorf("could not get gin context")
}
refreshToken, err := gc.Cookie("refresh_token")
if err != nil {
return nil, fmt.Errorf("unauthorized")
}
claims, err := r.Store.Auth.VerifyJWT(refreshToken)
if err != nil {
return nil, fmt.Errorf("unauthorized")
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
return nil, fmt.Errorf("invalid token claims")
}
var user models.User
user.ID = uint(userIDF)
if err := r.Store.DB.First(&user).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
tokens, err := r.Store.Auth.GenerateJWT(&user)
if err != nil {
return nil, fmt.Errorf("failed to generate tokens")
}
gc.SetSameSite(http.SameSiteLaxMode)
gc.SetCookie(
"access_token",
tokens.AccessToken,
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
r.Store.Auth.Config.Endpoint,
r.Store.Auth.Config.Domain,
true, true,
)
gc.SetCookie(
"refresh_token",
tokens.RefreshToken,
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
r.Store.Auth.Config.Endpoint,
r.Store.Auth.Config.Domain,
true, true,
)
return &model.AuthPayload{User: &user}, nil
}
// CreatePost is the resolver for the createPost field.
func (r *mutationResolver) CreatePost(ctx context.Context, input model.CreatePostInput) (*models.Post, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
if err := r.Store.DB.Create(&post).Error; err != nil {
return nil, err
}
return &post, nil
}
// UpdatePost is the resolver for the updatePost field.
func (r *mutationResolver) UpdatePost(ctx context.Context, id int, input model.UpdatePostInput) (*models.Post, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
var post models.Post
if err := r.Store.DB.First(&post, id).Error; err != nil {
return nil, fmt.Errorf("post not found")
}
if post.AuthorID != userID {
return nil, fmt.Errorf("you can only update your own posts")
}
post.Title = input.Title
post.Content = input.Content
if err := r.Store.DB.Save(&post).Error; err != nil {
return nil, err
}
return &post, nil
}
// DeletePost is the resolver for the deletePost field.
func (r *mutationResolver) DeletePost(ctx context.Context, id int) (*models.Post, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
var post models.Post
if err := r.Store.DB.First(&post, id).Error; err != nil {
return nil, fmt.Errorf("post not found")
}
if post.AuthorID != userID {
return nil, fmt.Errorf("you can only delete your own posts")
}
r.Store.DB.Delete(&post)
return &post, nil
}
// CreateUser is the resolver for the createUser field.
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*models.User, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := models.User{Username: input.Username, Password: hashedPassword}
if err := r.Store.DB.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// DeleteUser is the resolver for the deleteUser field.
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
var user models.User
if err := r.Store.DB.First(&user, id).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
if err := r.Store.DB.Delete(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// SetUserAdmin is the resolver for the setUserAdmin field.
func (r *mutationResolver) SetUserAdmin(ctx context.Context, id int, admin bool) (*models.User, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
callerID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
if uint(id) == callerID {
return nil, fmt.Errorf("cannot change your own admin status")
}
var user models.User
if err := r.Store.DB.First(&user, id).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
user.Admin = admin
if err := r.Store.DB.Save(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// CreateFavorite is the resolver for the createFavorite field.
func (r *mutationResolver) CreateFavorite(ctx context.Context, input model.CreateFavoriteInput) (*models.Favorite, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
if err := r.Store.DB.Create(&favorite).Error; err != nil {
return nil, err
}
return &favorite, nil
}
// CreateActivity is the resolver for the createActivity field.
func (r *mutationResolver) CreateActivity(ctx context.Context, input model.CreateActivityInput) (*models.Activity, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
if err := r.Store.DB.Create(&activity).Error; err != nil {
return nil, err
}
return &activity, nil
}
// Users is the resolver for the users field.
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
var users []models.User
if err := r.Store.DB.Find(&users).Error; err != nil {
return nil, err
}
result := make([]*models.User, len(users))
for i := range users {
result[i] = &users[i]
}
return result, nil
}
// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, id int) (*models.User, error) {
var user models.User
if err := r.Store.DB.First(&user, id).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
return &user, nil
}
// Posts is the resolver for the posts field.
func (r *queryResolver) Posts(ctx context.Context) ([]*models.Post, error) {
var posts []models.Post
if err := r.Store.DB.Preload("Author").Order("created_at DESC").Find(&posts).Error; err != nil {
return nil, err
}
result := make([]*models.Post, len(posts))
for i := range posts {
result[i] = &posts[i]
}
return result, nil
}
// Post is the resolver for the post field.
func (r *queryResolver) Post(ctx context.Context, id int) (*models.Post, error) {
var post models.Post
if err := r.Store.DB.Preload("Author").First(&post, id).Error; err != nil {
return nil, fmt.Errorf("post not found")
}
return &post, nil
}
// Activities is the resolver for the activities field.
func (r *queryResolver) Activities(ctx context.Context) ([]*models.Activity, error) {
var activities []models.Activity
if err := r.Store.DB.Order("created_at DESC").Find(&activities).Error; err != nil {
return nil, err
}
result := make([]*models.Activity, len(activities))
for i := range activities {
result[i] = &activities[i]
}
return result, nil
}
// Favorites is the resolver for the favorites field.
func (r *queryResolver) Favorites(ctx context.Context) ([]*models.Favorite, error) {
var favorites []models.Favorite
if err := r.Store.DB.Order("created_at DESC").Find(&favorites).Error; err != nil {
return nil, err
}
result := make([]*models.Favorite, len(favorites))
for i := range favorites {
result[i] = &favorites[i]
}
return result, nil
}
// RowingSessions is the resolver for the rowingSessions field.
func (r *queryResolver) RowingSessions(ctx context.Context) ([]*models.Rowing, error) {
var rows []models.Rowing
if err := r.Store.DB.Order("created_at DESC").Find(&rows).Error; err != nil {
return nil, err
}
result := make([]*models.Rowing, len(rows))
for i := range rows {
result[i] = &rows[i]
}
return result, nil
}
// Messages is the resolver for the messages field.
func (r *queryResolver) Messages(ctx context.Context) ([]*models.Message, error) {
var messages []models.Message
if err := r.Store.DB.Order("created_at DESC").Find(&messages).Error; err != nil {
return nil, err
}
result := make([]*models.Message, len(messages))
for i := range messages {
result[i] = &messages[i]
}
return result, nil
}
// SpotifyListening is the resolver for the spotifyListening field.
func (r *queryResolver) SpotifyListening(ctx context.Context) (*model.SpotifyPlaying, error) {
if r.Store.SpotifyClient == nil {
return nil, fmt.Errorf("Spotify not authenticated")
}
playing, err := r.Store.SpotifyClient.PlayerCurrentlyPlaying(ctx)
if err != nil {
return nil, err
}
result := &model.SpotifyPlaying{Playing: playing.Playing}
if playing.Item != nil {
result.Track = mapSpotifyTrack(playing.Item)
}
return result, nil
}
// SpotifyRecent is the resolver for the spotifyRecent field.
func (r *queryResolver) SpotifyRecent(ctx context.Context) ([]*model.SpotifyRecentItem, error) {
if r.Store.SpotifyClient == nil {
return nil, fmt.Errorf("Spotify not authenticated")
}
if r.Store.RecentSongsFresh() {
return mapRecentItems(*r.Store.RecentSongs), nil
}
opts := spotify.RecentlyPlayedOptions{Limit: 3}
played, err := r.Store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
if err != nil {
return nil, err
}
r.Store.RecentSongs = &played
r.Store.RecentSongsFetchedAt = time.Now()
return mapRecentItems(played), nil
}
// Me is the resolver for the me field.
func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
var user models.User
user.ID = userID
if err := r.Store.DB.First(&user).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
return &user, nil
}
// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

View File

@@ -0,0 +1,14 @@
type Activity {
id: ID!
createdAt: Time!
updatedAt: Time!
type: String!
name: String!
link: String
}
input CreateActivityInput {
type: String!
name: String!
link: String
}

View File

@@ -0,0 +1,8 @@
input LoginInput {
username: String!
password: String!
}
type AuthPayload {
user: User!
}

View File

@@ -0,0 +1,14 @@
type Favorite {
id: ID!
createdAt: Time!
updatedAt: Time!
type: String!
name: String!
link: String
}
input CreateFavoriteInput {
type: String!
name: String!
link: String
}

View File

@@ -0,0 +1,7 @@
type Message {
id: ID!
content: String!
authorId: Int!
fileUrl: String
createdAt: Time!
}

View File

@@ -0,0 +1,18 @@
type Post {
id: ID!
createdAt: Time!
updatedAt: Time!
title: String!
author: User
content: String!
}
input CreatePostInput {
title: String!
content: String!
}
input UpdatePostInput {
title: String!
content: String!
}

View File

@@ -0,0 +1,9 @@
type Rowing {
id: ID!
createdAt: Time!
date: Time!
time: Int!
distance: Int!
timePer500m: Float!
calories: Float!
}

View File

@@ -0,0 +1,29 @@
scalar Time
type Query {
users: [User!]!
user(id: ID!): User
posts: [Post!]!
post(id: ID!): Post
activities: [Activity!]!
favorites: [Favorite!]!
rowingSessions: [Rowing!]!
messages: [Message!]!
spotifyListening: SpotifyPlaying
spotifyRecent: [SpotifyRecentItem!]!
me: User
}
type Mutation {
login(input: LoginInput!): AuthPayload!
logout: Boolean!
refreshToken: AuthPayload!
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Post!
createUser(input: CreateUserInput!): User!
deleteUser(id: ID!): User!
setUserAdmin(id: ID!, admin: Boolean!): User!
createFavorite(input: CreateFavoriteInput!): Favorite!
createActivity(input: CreateActivityInput!): Activity!
}

View File

@@ -0,0 +1,28 @@
type SpotifyArtist {
name: String!
}
type SpotifyImage {
url: String!
}
type SpotifyAlbum {
name: String!
images: [SpotifyImage!]!
}
type SpotifyTrack {
name: String!
artists: [SpotifyArtist!]!
album: SpotifyAlbum!
}
type SpotifyPlaying {
playing: Boolean!
track: SpotifyTrack
}
type SpotifyRecentItem {
track: SpotifyTrack!
playedAt: Time!
}

View File

@@ -0,0 +1,12 @@
type User {
id: ID!
createdAt: Time!
updatedAt: Time!
username: String!
admin: Boolean!
}
input CreateUserInput {
username: String!
password: String!
}

View File

@@ -0,0 +1,51 @@
package graph
import (
"adam-french.co.uk/backend/graph/model"
"github.com/zmb3/spotify/v2"
)
func mapSpotifyImages(images []spotify.Image) []*model.SpotifyImage {
result := make([]*model.SpotifyImage, len(images))
for i, img := range images {
result[i] = &model.SpotifyImage{URL: img.URL}
}
return result
}
func mapSpotifyTrack(track *spotify.FullTrack) *model.SpotifyTrack {
artists := make([]*model.SpotifyArtist, len(track.Artists))
for i, a := range track.Artists {
artists[i] = &model.SpotifyArtist{Name: a.Name}
}
return &model.SpotifyTrack{
Name: track.Name,
Artists: artists,
Album: &model.SpotifyAlbum{
Name: track.Album.Name,
Images: mapSpotifyImages(track.Album.Images),
},
}
}
func mapRecentItems(items []spotify.RecentlyPlayedItem) []*model.SpotifyRecentItem {
result := make([]*model.SpotifyRecentItem, len(items))
for i, item := range items {
artists := make([]*model.SpotifyArtist, len(item.Track.Artists))
for j, a := range item.Track.Artists {
artists[j] = &model.SpotifyArtist{Name: a.Name}
}
result[i] = &model.SpotifyRecentItem{
PlayedAt: item.PlayedAt,
Track: &model.SpotifyTrack{
Name: item.Track.Name,
Artists: artists,
Album: &model.SpotifyAlbum{
Name: item.Track.Album.Name,
Images: mapSpotifyImages(item.Track.Album.Images),
},
},
}
}
return result
}

View File

@@ -0,0 +1,22 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"adam-french.co.uk/backend/models"
)
// ID is the resolver for the id field.
func (r *userResolver) ID(ctx context.Context, obj *models.User) (int, error) {
return int(obj.ID), nil
}
// User returns UserResolver implementation.
func (r *Resolver) User() UserResolver { return &userResolver{r} }
type userResolver struct{ *Resolver }

View File

@@ -1,11 +1,11 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -27,6 +27,28 @@ func (store *Store) AuthMiddlewear(ctx *gin.Context) {
ctx.Next() ctx.Next()
} }
func (store *Store) AdminMiddleware(ctx *gin.Context) {
claims, exists := ctx.Get("userClaims")
if !exists {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
mapClaims, ok := claims.(*jwt.MapClaims)
if !ok {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid claims"})
return
}
admin, ok := (*mapClaims)["admin"].(bool)
if !ok || !admin {
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
return
}
ctx.Next()
}
func (store *Store) CheckToken(ctx *gin.Context) { func (store *Store) CheckToken(ctx *gin.Context) {
access_token, err := ctx.Cookie("access_token") access_token, err := ctx.Cookie("access_token")
if err != nil { if err != nil {
@@ -68,10 +90,9 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
claims, err := store.Auth.VerifyJWT(refreshToken) claims, err := store.Auth.VerifyJWT(refreshToken)
if err != nil { if err != nil {
ctx.JSON(http.StatusUnauthorized, err.Error()) ctx.JSON(http.StatusUnauthorized, err.Error())
return
} }
fmt.Printf("claims: %v\n", claims)
userIDF, ok := (*claims)["id"].(float64) userIDF, ok := (*claims)["id"].(float64)
if !ok { if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
@@ -93,6 +114,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
return return
} }
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
tokens.AccessToken, tokens.AccessToken,
@@ -122,12 +144,12 @@ func (store *Store) Login(ctx *gin.Context) {
user := models.User{} user := models.User{}
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil { if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
ctx.JSON(http.StatusNotFound, err.Error()) ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return return
} }
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil { if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
ctx.JSON(http.StatusUnauthorized, err.Error()) ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return return
} }
@@ -137,6 +159,7 @@ func (store *Store) Login(ctx *gin.Context) {
return return
} }
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
tokens.AccessToken, tokens.AccessToken,
@@ -164,6 +187,7 @@ func (store *Store) Logout(ctx *gin.Context) {
} }
func removeCookies(ctx *gin.Context) { func removeCookies(ctx *gin.Context) {
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
"", "",

View File

@@ -0,0 +1,97 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
var allowedExtensions = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
".mp4": true, ".webm": true, ".mp3": true, ".ogg": true,
".pdf": true, ".txt": true,
}
var extensionToMIMEPrefix = map[string]string{
".jpg": "image/", ".jpeg": "image/", ".png": "image/", ".gif": "image/", ".webp": "image/",
".mp4": "video/", ".webm": "video/",
".pdf": "application/pdf", ".txt": "text/",
}
func (store *Store) UploadMessageFile(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
const maxSize = 10 << 20 // 10MB
if file.Size > maxSize {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file too large"})
return
}
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedExtensions[ext] {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
return
}
// Validate actual content type matches extension
f, err := file.Open()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
buf := make([]byte, 512)
n, err := f.Read(buf)
f.Close()
if err != nil && n == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "failed to read file content"})
return
}
detectedType := http.DetectContentType(buf[:n])
expectedPrefix, ok := extensionToMIMEPrefix[ext]
if ok && !strings.HasPrefix(detectedType, expectedPrefix) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match extension"})
return
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
return
}
filename := hex.EncodeToString(b) + ext
uploadDir := "/backend/uploads/"
dest := filepath.Join(uploadDir, filename)
src, err := file.Open()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
defer src.Close()
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
defer out.Close()
if _, err := io.Copy(out, src); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
ctx.JSON(http.StatusOK, gin.H{"url": "/uploads/" + filename})
}

View File

@@ -67,11 +67,6 @@ func (store *Store) CreatePost(ctx *gin.Context) {
} }
userID := uint(userIDF) userID := uint(userIDF)
if !(*claims)["admin"].(bool) {
ctx.JSON(http.StatusForbidden, gin.H{"error": "you are not admin :("})
return
}
// Create post // Create post
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID} post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
tx := store.DB.Create(&post) tx := store.DB.Create(&post)

View File

@@ -6,7 +6,7 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"time" "strings"
"github.com/rwcarlsen/goexif/exif" "github.com/rwcarlsen/goexif/exif"
@@ -16,9 +16,9 @@ import (
) )
type ExtractedRowingData struct { type ExtractedRowingData struct {
TimeMinutes float64 `json:"timeMinutes"` TimeMinutes uint64 `json:"timeMinutes"`
TimeSeconds float64 `json:"timeSeconds"` TimeSeconds uint64 `json:"timeSeconds"`
Distance float64 `json:"distance"` Distance uint64 `json:"distance"`
} }
func (store *Store) GetRowing(ctx *gin.Context) { func (store *Store) GetRowing(ctx *gin.Context) {
@@ -82,6 +82,13 @@ func (store *Store) CreateRowing(ctx *gin.Context) {
} }
encoded := base64.StdEncoding.EncodeToString(data) encoded := base64.StdEncoding.EncodeToString(data)
// Reject duplicates: same EXIF datetime already recorded
var existing models.Rowing
if err := store.DB.Where("date = ?", dateTaken).First(&existing).Error; err == nil {
ctx.JSON(http.StatusConflict, gin.H{"error": "duplicate entry for this date"})
return
}
// Build the message with an image + text prompt // Build the message with an image + text prompt
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{ message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
Model: anthropic.ModelClaudeHaiku4_5, Model: anthropic.ModelClaudeHaiku4_5,
@@ -110,21 +117,38 @@ No text, no markdown, no explanation. Just the JSON object.`),
}, },
}, },
}) })
if err != nil { if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"}) ctx.JSON(http.StatusInternalServerError, gin.H{
"raw": message.Content[0].Text,
"error": err.Error(),
})
return return
} }
if len(message.Content) == 0 { if len(message.Content) == 0 {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"}) ctx.JSON(http.StatusInternalServerError, gin.H{
"raw": message.Content[0].Text,
"error": "empty response from Claude",
})
return return
} }
extractedData := ExtractedRowingData{} extractedData := ExtractedRowingData{}
err = json.Unmarshal([]byte(message.Content[0].Text), &extractedData) raw := message.Content[0].Text
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
err = json.Unmarshal([]byte(raw), &extractedData)
if err != nil { if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse JSON response"}) ctx.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to parse JSON response",
"detail": err.Error(),
"raw": raw,
})
return return
} }
@@ -134,15 +158,39 @@ No text, no markdown, no explanation. Just the JSON object.`),
} }
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
totalDuration := time.Duration(totalSeconds * float64(time.Second))
per500m := time.Duration(totalSeconds / extractedData.Distance * 500 * float64(time.Second)) // Validate for anomalous values
const (
minDistance = 100 // metres
maxDistance = 100000 // metres
minTotalSecs = 30 // 30 seconds
maxTotalSecs = 7200 // 2 hours
minPacePer500m = 80 // ~1:20 /500m (faster than any human)
maxPacePer500m = 150 // ~2:30 /500m (slow, not important)
)
if extractedData.Distance < minDistance || extractedData.Distance > maxDistance {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous distance value"})
return
}
if totalSeconds < minTotalSecs || totalSeconds > maxTotalSecs {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous time value"})
return
}
per500m := float64(totalSeconds) / float64(extractedData.Distance) * 500.0
if per500m < minPacePer500m || per500m > maxPacePer500m {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous pace value"})
return
}
calories := float64(extractedData.Distance) / 7500.0 * 500.0
rowing := models.Rowing{ rowing := models.Rowing{
Date: dateTaken, Date: dateTaken,
Time: totalDuration, Time: totalSeconds,
TimePer500m: per500m, TimePer500m: per500m,
Distance: extractedData.Distance, Distance: extractedData.Distance,
Calories: extractedData.Distance / 7500.0 * 500.0, Calories: calories,
} }
if err := store.DB.Create(&rowing).Error; err != nil { if err := store.DB.Create(&rowing).Error; err != nil {

View File

@@ -53,10 +53,16 @@ func (store *Store) ListeningTo(ctx *gin.Context) {
} }
func (store *Store) RecentlyPlayed(ctx *gin.Context) { func (store *Store) RecentlyPlayed(ctx *gin.Context) {
if store.SpotifyClient == nil {
ctx.JSON(500, gin.H{"error": "Spotify not authenticated"})
return
}
opts := spotify.RecentlyPlayedOptions{Limit: 3} opts := spotify.RecentlyPlayedOptions{Limit: 3}
if store.RecentSongsFresh() { if store.RecentSongsFresh() {
ctx.JSON(200, *store.RecentSongs) ctx.JSON(200, *store.RecentSongs)
return
} }
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts) played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)

View File

@@ -14,6 +14,10 @@ type UserCredentials struct {
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
} }
type SetAdminInput struct {
Admin *bool `json:"admin" binding:"required"`
}
func (store *Store) CreateUser(ctx *gin.Context) { func (store *Store) CreateUser(ctx *gin.Context) {
var input UserCredentials var input UserCredentials
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
@@ -31,32 +35,9 @@ func (store *Store) CreateUser(ctx *gin.Context) {
tx := store.DB.Create(&user) tx := store.DB.Create(&user)
if tx.Error != nil { if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
}
// Generate JWT token
tokens, err := store.Auth.GenerateJWT(&user)
if err != nil {
ctx.JSON(http.StatusInternalServerError, err.Error())
return return
} }
ctx.SetCookie(
"access_token",
tokens.AccessToken,
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
store.Auth.Config.Endpoint,
store.Auth.Config.Domain,
true, true,
)
ctx.SetCookie(
"refresh_token",
tokens.RefreshToken,
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
store.Auth.Config.Endpoint,
store.Auth.Config.Domain,
true, true,
)
ctx.JSON(http.StatusOK, user) ctx.JSON(http.StatusOK, user)
} }
@@ -109,6 +90,52 @@ func (store *Store) UpdateUser(ctx *gin.Context) {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "will be implemented"}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "will be implemented"})
} }
func (store *Store) SetUserAdmin(ctx *gin.Context) {
claimsVal, ok := ctx.Get("userClaims")
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
return
}
callerIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
return
}
callerID := uint(callerIDF)
targetID := ctx.Param("id")
var input SetAdminInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if err := store.DB.First(&user, targetID).Error; err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if user.ID == callerID {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot change your own admin status"})
return
}
user.Admin = *input.Admin
if err := store.DB.Save(&user).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, user)
}
func (store *Store) DeleteUser(ctx *gin.Context) { func (store *Store) DeleteUser(ctx *gin.Context) {
claimsVal, ok := ctx.Get("userClaims") claimsVal, ok := ctx.Get("userClaims")
if !ok { if !ok {
@@ -141,6 +168,7 @@ func (store *Store) DeleteUser(ctx *gin.Context) {
return return
} }
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
"", "",

View File

@@ -7,16 +7,18 @@ import (
"os" "os"
"time" "time"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"adam-french.co.uk/backend/graph"
"adam-french.co.uk/backend/handlers" "adam-french.co.uk/backend/handlers"
"adam-french.co.uk/backend/services" "adam-french.co.uk/backend/services"
) )
func main() { func main() {
logsDir := "/backend/logs" logsDir := "/backend/logs"
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -39,6 +41,11 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
if os.Getenv("SEED_DB") == "true" {
services.SeedDatabase(db)
}
domainName := os.Getenv("DOMAIN")
services.InitWebSocket(db, domainName)
// SPOTIFY // SPOTIFY
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE") spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
@@ -54,7 +61,6 @@ func main() {
claudeClient := services.InitClaude(&claudeConfig) claudeClient := services.InitClaude(&claudeConfig)
authSecret := os.Getenv("BACKEND_SECRET") authSecret := os.Getenv("BACKEND_SECRET")
domainName := os.Getenv("DOMAIN")
backendEndpoint := os.Getenv("BACKEND_ENDPOINT") backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
accessTokenLifetime := 24 * time.Hour accessTokenLifetime := 24 * time.Hour
refreshTokenLifetime := 365 * 24 * time.Hour refreshTokenLifetime := 365 * 24 * time.Hour
@@ -68,32 +74,34 @@ func main() {
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes} store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes}
protected := r.Group("/", store.AuthMiddlewear) protected := r.Group("/", store.AuthMiddlewear)
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)
// FAVORITES // FAVORITES
r.GET("/favorites", store.GetFavorites) r.GET("/favorites", store.GetFavorites)
protected.POST("/favorites", store.CreateFavorite) admin.POST("/favorites", store.CreateFavorite)
// ROWING // ROWING
r.GET("/rowing", store.GetRowing) r.GET("/rowing", store.GetRowing)
protected.POST("/rowing", store.CreateRowing) admin.POST("/rowing", store.CreateRowing)
// ACTIVITIES // ACTIVITIES
r.GET("/activity", store.GetActivity) r.GET("/activity", store.GetActivity)
protected.POST("/activity", store.CreateActivity) admin.POST("/activity", store.CreateActivity)
// POSTS // POSTS
r.GET("/posts", store.GetPosts) r.GET("/posts", store.GetPosts)
protected.POST("/posts", store.CreatePost) admin.POST("/posts", store.CreatePost)
r.GET("/posts/:id", store.GetPost) r.GET("/posts/:id", store.GetPost)
protected.PUT("/posts/:id", store.UpdatePost) admin.PUT("/posts/:id", store.UpdatePost)
protected.DELETE("/posts/:id", store.DeletePost) admin.DELETE("/posts/:id", store.DeletePost)
// USERS // USERS
r.GET("/user/:id", store.GetUser) r.GET("/user/:id", store.GetUser)
protected.PUT("/user/:id", store.UpdateUser) admin.PUT("/user/:id", store.UpdateUser)
protected.DELETE("/user/:id", store.DeleteUser) admin.DELETE("/user/:id", store.DeleteUser)
r.GET("/user", store.GetUsers) r.GET("/user", store.GetUsers)
r.POST("/user", store.CreateUser) admin.POST("/user", store.CreateUser)
admin.PATCH("/user/:id/admin", store.SetUserAdmin)
// AUTH // AUTH
r.POST("/auth/login", store.Login) r.POST("/auth/login", store.Login)
@@ -109,10 +117,22 @@ func main() {
// MESSAGES // MESSAGES
r.GET("/ws", store.ConnectWebSocket) r.GET("/ws", store.ConnectWebSocket)
protected.POST("/messages/upload", store.UploadMessageFile)
// NOTES // NOTES
r.GET("/notes/*path", store.GetNoteFile) r.GET("/notes/*path", store.GetNoteFile)
// GRAPHQL
gqlSrv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
Resolvers: &graph.Resolver{Store: &store},
}))
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
gqlSrv.ServeHTTP(c.Writer, c.Request)
})
r.GET("/graphql", func(c *gin.Context) {
playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request)
})
// HELLO WORLD // HELLO WORLD
r.GET("/", func(c *gin.Context) { r.GET("/", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "Hello World"}) c.JSON(200, gin.H{"message": "Hello World"})

View File

@@ -30,8 +30,8 @@ type Post struct {
type Message struct { type Message struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Content string `json:"text"` Content string `json:"text"`
AuthorID uint `json:"-"` AuthorID uint `json:"authorId"`
Author *User `gorm:"foreignKey:AuthorID" json:"author"` FileURL string `json:"fileUrl,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
} }
@@ -61,8 +61,8 @@ type Rowing struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
Time time.Duration `json:"time"` Time uint64 `json:"time"`
TimePer500m time.Duration `json:"timePer500m"` Distance uint64 `json:"distance"`
Distance float64 `json:"distance"` TimePer500m float64 `json:"timePer500m"`
Calories float64 `json:"calories"` Calories float64 `json:"calories"`
} }

View File

@@ -37,6 +37,7 @@ func migrateDatabase(db *gorm.DB) error {
&models.Activity{}, &models.Activity{},
&models.Favorite{}, &models.Favorite{},
&models.Rowing{}, &models.Rowing{},
&models.Message{},
) )
if err != nil { if err != nil {
return err return err

65
backend/services/seed.go Normal file
View File

@@ -0,0 +1,65 @@
package services
import (
"log"
"time"
"adam-french.co.uk/backend/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func SeedDatabase(db *gorm.DB) {
var user models.User
if db.First(&user).Error == nil {
log.Println("Database already has data, skipping seed")
return
}
log.Println("Seeding database with test data...")
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
if err != nil {
log.Fatal("Failed to hash seed password:", err)
}
testUser := models.User{
Username: "testuser",
Password: hashedPassword,
Admin: true,
}
db.Create(&testUser)
posts := []models.Post{
{Title: "Welcome to my blog", Content: "This is the first test post with some example content.", AuthorID: testUser.ID},
{Title: "Learning Go", Content: "Go is a great language for building web servers and APIs.", AuthorID: testUser.ID},
{Title: "Vue 3 Tips", Content: "The composition API makes Vue components much more flexible.", AuthorID: testUser.ID},
}
db.Create(&posts)
link1 := "https://example.com/project"
link2 := "https://example.com/book"
activities := []models.Activity{
{Type: "project", Name: "coding"},
{Type: "hobby", Name: "reading", Link: &link1},
{Type: "fitness", Name: "exercise"},
}
db.Create(&activities)
favorites := []models.Favorite{
{Type: "language", Name: "Go"},
{Type: "book", Name: "Designing Data-Intensive Applications", Link: &link2},
{Type: "framework", Name: "Vue"},
}
db.Create(&favorites)
now := time.Now()
rowingEntries := []models.Rowing{
{Date: now.AddDate(0, 0, -14), Time: 1800, Distance: 5000, TimePer500m: 120.0, Calories: 300},
{Date: now.AddDate(0, 0, -7), Time: 1750, Distance: 5200, TimePer500m: 118.5, Calories: 315},
{Date: now, Time: 1700, Distance: 5400, TimePer500m: 116.2, Calories: 330},
}
db.Create(&rowingEntries)
log.Println("Database seeded successfully")
}

View File

@@ -1,33 +1,64 @@
package services package services
import ( import (
"net/http"
"strings"
"sync" "sync"
"time" "time"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
"gorm.io/gorm"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
const maxMessages = 50
var allowedDomain string
var Upgrader = websocket.Upgrader{ var Upgrader = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 1024, WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
origin = strings.TrimPrefix(origin, "https://")
origin = strings.TrimPrefix(origin, "http://")
return origin == allowedDomain || origin == "www."+allowedDomain
},
} }
var ( var (
clients = make(map[*websocket.Conn]bool) clients = make(map[*websocket.Conn]bool)
messages = make([]models.Message, 0) mu sync.Mutex
mu sync.Mutex wsDB *gorm.DB
nextAuthorID uint
) )
const (
rateLimitWindow = time.Second
rateLimitMaxMsgs = 10
)
func InitWebSocket(database *gorm.DB, domain string) {
wsDB = database
allowedDomain = domain
}
func HandleWebSocket(conn *websocket.Conn) { func HandleWebSocket(conn *websocket.Conn) {
defer conn.Close() defer conn.Close()
mu.Lock() mu.Lock()
clients[conn] = true clients[conn] = true
nextAuthorID++
authorID := nextAuthorID
// Send existing message history to new client var history []models.Message
for _, msg := range messages { wsDB.Order("created_at ASC").Limit(maxMessages).Find(&history)
for _, msg := range history {
if err := conn.WriteJSON(msg); err != nil { if err := conn.WriteJSON(msg); err != nil {
mu.Unlock() mu.Unlock()
return return
@@ -35,17 +66,32 @@ func HandleWebSocket(conn *websocket.Conn) {
} }
mu.Unlock() mu.Unlock()
msgCount := 0
windowStart := time.Now()
for { for {
var incoming models.Message var incoming models.Message
if err := conn.ReadJSON(&incoming); err != nil { if err := conn.ReadJSON(&incoming); err != nil {
break break
} }
incoming.CreatedAt = time.Now() now := time.Now()
if now.Sub(windowStart) > rateLimitWindow {
msgCount = 0
windowStart = now
}
msgCount++
if msgCount > rateLimitMaxMsgs {
continue
}
incoming.AuthorID = authorID
// Store and broadcast
mu.Lock() mu.Lock()
messages = append(messages, incoming) wsDB.Create(&incoming)
wsDB.Where("id NOT IN (?)",
wsDB.Model(&models.Message{}).Select("id").Order("created_at DESC").Limit(maxMessages),
).Delete(&models.Message{})
for client := range clients { for client := range clients {
if err := client.WriteJSON(incoming); err != nil { if err := client.WriteJSON(incoming); err != nil {
@@ -56,7 +102,6 @@ func HandleWebSocket(conn *websocket.Conn) {
mu.Unlock() mu.Unlock()
} }
// Cleanup on disconnect
mu.Lock() mu.Lock()
delete(clients, conn) delete(clients, conn)
mu.Unlock() mu.Unlock()

10
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
nginx:
environment:
- DEV_MODE=true
- SEED_DB=true
ports:
- 80:80
certbot:
profiles:
- disabled

View File

@@ -1,78 +1,133 @@
networks: networks:
app-network: app-network:
driver: bridge driver: bridge
volumes: volumes:
dbdata: dbdata:
uploads:
services: services:
nginx: nginx:
build: build:
context: ./nginx context: ./nginx
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: nginx container_name: nginx
env_file: ./.env env_file: ./.env
restart: unless-stopped restart: always
depends_on: depends_on:
- backend - backend
- icecast2 - icecast2
networks: - gitea
- app-network networks:
ports: - app-network
- 80:80 ports:
- 443:443 - 80:80
volumes: - 443:443
- ./certbot/conf:/etc/letsencrypt volumes:
- ./certbot/www:/var/www/certbot - ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
- uploads:/uploads
certbot: certbot:
image: certbot/certbot image: certbot/certbot
container_name: certbot container_name: certbot
volumes: volumes:
- ./certbot/entrypoint.sh:/entrypoint.sh - ./certbot/entrypoint.sh:/entrypoint.sh
- ./certbot/conf:/etc/letsencrypt - ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot - ./certbot/www:/var/www/certbot
entrypoint: ["/entrypoint.sh"] entrypoint: ["/entrypoint.sh"]
networks: env_file:
- app-network - .env
networks:
- app-network
backend: backend:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: "${BACKEND_HOST}" container_name: "${BACKEND_HOST}"
restart: unless-stopped restart: always
depends_on: depends_on:
- db - db
networks: networks:
- app-network - app-network
env_file: env_file:
- ./.env - ./.env
volumes: volumes:
- ./backend/token/:/backend/token - ./backend/token/:/backend/token
- ${OBSIDIAN_DIR}:/backend/notes - ${OBSIDIAN_DIR}:/backend/notes
- ./logs:/backend/logs - ./logs:/backend/logs
- uploads:/backend/uploads
db: db:
image: postgres:16 image: postgres:16
container_name: "${POSTGRES_HOST}" container_name: "${POSTGRES_HOST}"
restart: unless-stopped restart: always
env_file: env_file:
- ./.env - ./.env
networks: networks:
- app-network - app-network
volumes: volumes:
- dbdata:/var/lib/postgresql/data - dbdata:/var/lib/postgresql/data
icecast2: icecast2:
build: build:
context: ./icecast2 context: ./icecast2
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: "${ICECAST_HOST}" container_name: "${ICECAST_HOST}"
restart: always restart: always
networks: networks:
- app-network - app-network
env_file: env_file:
- ./.env - ./.env
ports: volumes:
- "${ICECAST_PORT}:${ICECAST_PORT}" - ./icecast2/fallback_music:/music:ro
ports:
- "${LIQUIDSOAP_HARBOR_PORT:-8001}:${LIQUIDSOAP_HARBOR_PORT:-8001}"
gitea-runner:
image: gitea/act_runner:latest
container_name: "${GITEA_RUNNER_HOST}"
profiles:
- disabled
environment:
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME}
CONFIG_FILE: /config.yaml
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_INSTANCE_URL: "http://${GITEA_HOST}:3000"
GITEA_RUNNER_LABELS: "self-hosted:host"
volumes:
- ./gitea-runner/config.yaml:/config.yaml
- ./gitea-runner/data:/data
- /var/run/docker.sock:/var/run/docker.sock # WARNING: Docker socket mount gives container host-level access. Runner is in 'disabled' profile to mitigate risk.
restart: unless-stopped
networks:
- app-network
gitea:
image: docker.gitea.com/gitea:1.25.4-rootless
container_name: "${GITEA_HOST}"
networks:
- app-network
environment:
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=${POSTGRES_HOST}
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
- GITEA__database__USER=${POSTGRES_USER}
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
- GITEA__server__LFS_JWT_SECRET=${GITEA_LFS_JWT_SECRET}
- GITEA__security__INTERNAL_TOKEN=${GITEA_INTERNAL_TOKEN}
- GITEA__oauth2__JWT_SECRET=${GITEA_OAUTH2_JWT_SECRET}
- USER_UID=1000
- USER_GID=1000
restart: always
volumes:
- ./gitea/data:/var/lib/gitea
- ./gitea/config:/etc/gitea
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "2222:2222"
- "3000:3000"
depends_on:
- db

110
gitea-runner/config.yaml Normal file
View File

@@ -0,0 +1,110 @@
# Example configuration file, it's safe to copy this as the default config file without any modification.
# You don't have to copy this file to your instance,
# just run `./act_runner generate-config > config.yaml` to generate a config file.
log:
# The level of logging, can be trace, debug, info, warn, error, fatal
level: info
runner:
# Where to store the registration result.
file: .runner
# Execute how many tasks concurrently at the same time.
capacity: 1
# Extra environment variables to run jobs.
envs:
A_TEST_ENV_NAME_1: a_test_env_value_1
A_TEST_ENV_NAME_2: a_test_env_value_2
# Extra environment variables to run jobs from a file.
# It will be ignored if it's empty or the file doesn't exist.
env_file: .env
# The timeout for a job to be finished.
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
# So the job could be stopped by the Gitea instance if it's timeout is shorter than this.
timeout: 3h
# The timeout for the runner to wait for running jobs to finish when shutting down.
# Any running jobs that haven't finished after this timeout will be cancelled.
shutdown_timeout: 0s
# Whether skip verifying the TLS certificate of the Gitea instance.
insecure: false
# The timeout for fetching the job from the Gitea instance.
fetch_timeout: 5s
# The interval for fetching the job from the Gitea instance.
fetch_interval: 2s
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
# and github_mirror is not empty. In this case,
# it replaces https://github.com with the value here, which is useful for some special network environments.
github_mirror: ''
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# Find more images provided by Gitea at https://gitea.com/docker.gitea.com/runner-images .
# If it's empty when registering, it will ask for inputting labels.
# If it's empty when execute `daemon`, will use labels in `.runner` file.
labels:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
- "ubuntu-20.04:docker://docker.gitea.com/runner-images:ubuntu-20.04"
cache:
# Enable cache server to use actions/cache.
enabled: true
# The directory to store the cache data.
# If it's empty, the cache data will be stored in $HOME/.cache/actcache.
dir: ""
# The host of the cache server.
# It's not for the address to listen, but the address to connect from job containers.
# So 0.0.0.0 is a bad choice, leave it empty to detect automatically.
host: ""
# The port of the cache server.
# 0 means to use a random available port.
port: 0
# The external cache server URL. Valid only when enable is true.
# If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
# The URL should generally end with "/".
external_server: ""
container:
# Specifies the network to which the container will connect.
# Could be host, bridge or the name of a custom network.
# If it's empty, act_runner will create a network automatically.
network: ""
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
privileged: false
# And other options to be used when the container is started (eg, --add-host=my.gitea.url:host-gateway).
options:
# The parent directory of a job's working directory.
# NOTE: There is no need to add the first '/' of the path as act_runner will add it automatically.
# If the path starts with '/', the '/' will be trimmed.
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
# If it's empty, /workspace will be used.
workdir_parent:
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
# valid_volumes:
# - data
# - /src/*.json
# If you want to allow any volume, please use the following configuration:
# valid_volumes:
# - '**'
valid_volumes: []
# overrides the docker client host with the specified one.
# If it's empty, act_runner will find an available docker host automatically.
# If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
docker_host: ""
# Pull docker image(s) even if already present
force_pull: true
# Rebuild docker image(s) even if already present
force_rebuild: false
# Always require a reachable docker daemon, even if not required by act_runner
require_docker: false
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner
docker_timeout: 0s
host:
# The parent directory of a job's working directory.
# If it's empty, $HOME/.cache/act/ will be used.
workdir_parent:

View File

@@ -0,0 +1,98 @@
APP_NAME = Gitea: Git with a cup of tea
RUN_USER = git
RUN_MODE = prod
WORK_PATH = /var/lib/gitea
[repository]
ROOT = /var/lib/gitea/git/repositories
[repository.local]
LOCAL_COPY_PATH = /tmp/gitea/local-repo
[repository.upload]
TEMP_PATH = /tmp/gitea/uploads
[server]
APP_DATA_PATH = /var/lib/gitea
SSH_DOMAIN = adam-french.co.uk
HTTP_PORT = 3000
ROOT_URL = https://adam-french.co.uk/gitea/
DISABLE_SSH = false
; In rootless gitea container only internal ssh server is supported
START_SSH_SERVER = true
SSH_PORT = 2222
SSH_LISTEN_PORT = 2222
BUILTIN_SSH_SERVER_USER = git
LFS_START_SERVER = true
DOMAIN = stppi.local
LFS_JWT_SECRET =
OFFLINE_MODE = true
[database]
PATH = /var/lib/gitea/data/gitea.db
DB_TYPE = postgres
HOST = db
NAME = gitea
USER = postgres
PASSWD =
SCHEMA =
SSL_MODE = disable
LOG_SQL = false
[session]
PROVIDER_CONFIG = /var/lib/gitea/data/sessions
PROVIDER = file
[picture]
AVATAR_UPLOAD_PATH = /var/lib/gitea/data/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /var/lib/gitea/data/repo-avatars
[attachment]
PATH = /var/lib/gitea/data/attachments
[log]
ROOT_PATH = /var/lib/gitea/data/log
MODE = console
LEVEL = info
[security]
INSTALL_LOCK = true
SECRET_KEY =
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
INTERNAL_TOKEN =
PASSWORD_HASH_ALGO = pbkdf2
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost
[lfs]
PATH = /var/lib/gitea/git/lfs
[mailer]
ENABLED = false
[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true
[cron.update_checker]
ENABLED = false
[repository.pull-request]
DEFAULT_MERGE_STYLE = merge
[repository.signing]
DEFAULT_TRUST_MODEL = committer
[oauth2]
JWT_SECRET =

View File

@@ -1,19 +1,13 @@
FROM debian:latest as builder FROM savonet/liquidsoap:v2.3.2
USER root
WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install --yes icecast2 gettext media-types && apt-get install --yes icecast2 gettext media-types \
# RUN apt-get install --yes liquidsoap && rm -rf /var/lib/apt/lists/*
RUN useradd radio RUN useradd radio
RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2 RUN mkdir -p /music /etc/liquidsoap
# RUN chown -R radio:radio /etc/liquidsoap /var/log/liquidsoap RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2 /music /etc/liquidsoap
USER radio USER radio
COPY icecast.xml.template /etc/icecast2/icecast.xml.template COPY icecast.xml.template /etc/icecast2/icecast.xml.template
# COPY stream.liq.template /etc/liquidsoap/stream.liq.template COPY stream.liq.template /etc/liquidsoap/stream.liq.template
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT [ "/entrypoint.sh" ] ENTRYPOINT [ "/entrypoint.sh" ]

View File

@@ -1,11 +1,12 @@
#!/bin/sh #!/bin/bash
set -e set -e
# Substitute environment variables into template
envsubst < /etc/icecast2/icecast.xml.template > /etc/icecast2/icecast.xml envsubst < /etc/icecast2/icecast.xml.template > /etc/icecast2/icecast.xml
# envsubst < /etc/liquidsoap/stream.liq.template > /etc/liquidsoap/stream.liq envsubst < /etc/liquidsoap/stream.liq.template > /etc/liquidsoap/stream.liq
# Run icecast with the generated config icecast2 -c /etc/icecast2/icecast.xml &
exec icecast2 -c /etc/icecast2/icecast.xml sleep 2
# exec liquidsoap /etc/liquidsoap/stream.liq liquidsoap /etc/liquidsoap/stream.liq &
# wait -n wait -n
kill $(jobs -p) 2>/dev/null || true
exit 1

View File

View File

@@ -1,24 +1,17 @@
[general] settings.server.telnet := false
duration = 0 # 0 = run forever
bufferSecs = 5 # buffer size in seconds
reconnect = yes # reconnect on failure
reconnectDelay = 5
[input] music = playlist("/music", mode="randomize", reload_mode="watch")
device = pulse # PulseAudio input
sampleRate = 44100 # in Hz
bitsPerSample = 16
channel = 2
[icecast2-0] live = input.harbor("${LIQUIDSOAP_HARBOR_MOUNT}", port=${LIQUIDSOAP_HARBOR_PORT}, password="${ICECAST_SOURCE_PASSWORD}")
bitrateMode = cbr
bitrate = 128 # kbps radio = amplify(0.7, fallback(track_sensitive=false, [live, music, blank()]))
format = mp3
server = ${ICECAST_HOST} output.icecast(
port = ${ICECAST_PORT} %mp3,
password = ${ICECAST_SOURCE_PASSWORD} host="localhost",
mountPoint = ${ICECAST_MOUNT} port=${ICECAST_PORT},
name = "Live DJ stream" password="${ICECAST_SOURCE_PASSWORD}",
description = "Live microphone stream" mount="${ICECAST_MOUNT}",
genre = "Various" fallible=true,
public = yes radio
)

5
nginx/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
vue/node_modules
vue/.vite
vue/dist
**/.git
**/.DS_Store

View File

@@ -1,32 +1,21 @@
FROM nginx:latest # Stage 1: Build Vue app
RUN rm -rf /etc/nginx/html/* FROM node:22-slim AS build
RUN apt-get update && apt-get install -y make git && rm -rf /var/lib/apt/lists/*
# Install dependencies needed to add NodeSource repo
RUN apt-get update && apt-get install -y \
curl \
build-essential \
git \
gettext-base \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js LTS + npm
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g npm@latest
WORKDIR /app WORKDIR /app
COPY vue/package.json vue/package-lock.json ./
RUN npm ci
COPY vue/ ./ COPY vue/ ./
RUN npm install
RUN npm run build RUN npm run build
# Stage 2: Serve with nginx
RUN mkdir -p /etc/nginx/html \ FROM nginx:latest
&& cp -r ./dist/* /etc/nginx/html/ RUN rm -rf /etc/nginx/html/* && \
apt-get update && apt-get install -y gettext-base && \
rm -rf /var/lib/apt/lists/*
COPY --from=build /app/dist /etc/nginx/html/
COPY nginx.conf.template /etc/nginx/nginx.conf.template COPY nginx.conf.template /etc/nginx/nginx.conf.template
COPY nginx_setup.conf.template /etc/nginx/nginx_setup.conf.template COPY nginx_setup.conf.template /etc/nginx/nginx_setup.conf.template
COPY nginx_dev.conf.template /etc/nginx/nginx_dev.conf.template
COPY robots.txt /etc/nginx/html/robots.txt COPY robots.txt /etc/nginx/html/robots.txt
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,16 +1,24 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Check if certificate exists # Check if dev mode, certificate exists, or setup mode
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then if [ "$DEV_MODE" = "true" ]; then
echo "Certificates found. Using production nginx config." echo "Dev mode. Using HTTP-only nginx config."
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT}' \ envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
< /etc/nginx/nginx.conf.template \ </etc/nginx/nginx_dev.conf.template \
> /etc/nginx/nginx.conf >/etc/nginx/nginx.conf
elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
echo "Certificates found. Using production nginx config."
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
</etc/nginx/nginx.conf.template \
>/etc/nginx/nginx.conf
else else
echo "Certificates NOT found. Using setup nginx config." echo "Certificates NOT found. Using setup nginx config."
envsubst '${DOMAIN}' < /etc/nginx/nginx_setup.conf.template > /etc/nginx/nginx.conf envsubst '${DOMAIN}' </etc/nginx/nginx_setup.conf.template >/etc/nginx/nginx.conf
fi fi
# Ensure upload directory is traversable by nginx worker
chmod 755 /uploads 2>/dev/null || true
# Start nginx # Start nginx
nginx -g 'daemon off;' nginx -g 'daemon off;'

View File

@@ -9,6 +9,12 @@ http {
server_tokens off; server_tokens off;
charset utf-8; charset utf-8;
client_max_body_size 10M;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
log_format compact log_format compact
'$remote_addr "$request" $status rt=$request_time'; '$remote_addr "$request" $status rt=$request_time';
@@ -55,6 +61,13 @@ http {
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
location /uploads/ {
alias /uploads/;
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition "inline" always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
@@ -77,7 +90,40 @@ http {
return 301 $BACKEND_ENDPOINT/; return 301 $BACKEND_ENDPOINT/;
} }
location $BACKEND_ENDPOINT/ws {
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/auth/login {
limit_req zone=login burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/messages/upload {
limit_req zone=upload burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/ { location $BACKEND_ENDPOINT/ {
limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -98,6 +144,18 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /gitea {
return 301 /gitea/;
}
location /gitea/ {
proxy_pass http://$GITEA_HOST:$GITEA_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
} }
} }

View File

@@ -0,0 +1,127 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
charset utf-8;
client_max_body_size 10M;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
log_format compact
'$remote_addr "$request" $status rt=$request_time';
access_log /var/log/nginx/access.log compact;
types {
text/javascript mjs;
}
server {
listen 80;
server_name $DOMAIN www.$DOMAIN;
root /etc/nginx/html;
index index.html;
location /uploads/ {
alias /uploads/;
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition "inline" always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
}
location / {
try_files $uri $uri/ /index.html;
}
location = /robots.txt {
allow all;
log_not_found off;
access_log off;
}
location = /img/stamps/mine.gif {
add_header Access-Control-Allow-Origin *;
}
location $BACKEND_ENDPOINT {
return 301 $BACKEND_ENDPOINT/;
}
location $BACKEND_ENDPOINT/ws {
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/auth/login {
limit_req zone=login burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/messages/upload {
limit_req zone=upload burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/ {
limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /radio {
return 301 /radio/;
}
location /radio/ {
proxy_pass http://$ICECAST_HOST:$ICECAST_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /gitea {
return 301 /gitea/;
}
location /gitea/ {
proxy_pass http://$GITEA_HOST:$GITEA_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

View File

@@ -10,12 +10,14 @@
"dependencies": { "dependencies": {
"@mdit/plugin-katex": "^0.24.1", "@mdit/plugin-katex": "^0.24.1",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"katex": "^0.16.27", "katex": "^0.16.27",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-wikilinks": "^1.4.0", "markdown-it-wikilinks": "^1.4.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
}, },
@@ -1022,9 +1024,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1035,9 +1037,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1048,9 +1050,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1061,9 +1063,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1074,9 +1076,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1087,9 +1089,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1100,9 +1102,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1113,9 +1115,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1126,9 +1128,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1139,9 +1141,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1152,9 +1154,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -1165,9 +1167,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -1178,9 +1180,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1191,9 +1193,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1204,9 +1206,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1217,9 +1219,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1230,9 +1232,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1243,9 +1245,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1256,9 +1258,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1269,9 +1271,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1282,9 +1284,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1295,9 +1297,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1308,9 +1310,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1321,9 +1323,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1334,9 +1336,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1631,6 +1633,12 @@
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
@@ -1916,6 +1924,44 @@
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueuse/core": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.1",
"@vueuse/shared": "14.2.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/ansis": { "node_modules/ansis": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
@@ -1939,13 +1985,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.2", "version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.11",
"form-data": "^4.0.4", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@@ -2936,9 +2982,9 @@
} }
}, },
"node_modules/markdown-it": { "node_modules/markdown-it": {
"version": "14.1.0", "version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1", "argparse": "^2.0.1",
@@ -3199,9 +3245,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
@@ -3214,31 +3260,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@@ -3382,6 +3428,19 @@
"utf8-byte-length": "^1.0.1" "utf8-byte-length": "^1.0.1"
} }
}, },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uc.micro": { "node_modules/uc.micro": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",

View File

@@ -14,12 +14,14 @@
"dependencies": { "dependencies": {
"@mdit/plugin-katex": "^0.24.1", "@mdit/plugin-katex": "^0.24.1",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"katex": "^0.16.27", "katex": "^0.16.27",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-wikilinks": "^1.4.0", "markdown-it-wikilinks": "^1.4.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

View File

@@ -5,7 +5,7 @@ import Footer from "@/components/Footer.vue";
</script> </script>
<template> <template>
<Navbar class="no-print" /> <Navbar class="no-print sticky" />
<RouterView /> <RouterView />
<!-- <Footer style="height: 10vh" /> --> <!-- <Footer style="height: 10vh" /> -->

View File

@@ -2,6 +2,7 @@
/* PRINTING */ /* PRINTING */
@media print { @media print {
.no-print, .no-print,
.no-print * { .no-print * {
display: none !important; display: none !important;
@@ -11,6 +12,7 @@
height: 0px; height: 0px;
} }
} }
/* END OF PRINTING */ /* END OF PRINTING */
/* FONTS */ /* FONTS */
@@ -27,6 +29,7 @@
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
/* END OF FONTS */ /* END OF FONTS */
/* VARIABLES */ /* VARIABLES */
@@ -75,6 +78,7 @@
--font-heading: var(--font_heading); --font-heading: var(--font_heading);
--default-font-family: var(--font_default); --default-font-family: var(--font_default);
} }
/* END OF VARIABLES */ /* END OF VARIABLES */
/* ELEMENTS */ /* ELEMENTS */
body { body {
@@ -118,9 +122,11 @@ h3,
h4 { h4 {
@apply text-lg; @apply text-lg;
} }
h1 { h1 {
@apply text-2xl; @apply text-2xl;
} }
h2 { h2 {
@apply text-xl; @apply text-xl;
} }
@@ -130,12 +136,17 @@ p {
} }
a { a {
@apply text-primary bg-link text-center font-heading text-xl tracking-wide; @apply text-primary bg-link text-center font-heading tracking-wide;
} }
input, input,
textarea { textarea {
@apply text-primary border; @apply text-primary border p-2 w-full;
}
input::placeholder,
textarea::placeholder {
@apply text-secondary opacity-50;
} }
table { table {
@@ -219,18 +230,24 @@ td {
/* PHONE */ /* PHONE */
@media (max-width: 850px) { @media (max-width: 850px) {
.a4page-portrait { .a4page-portrait {
width: 100%; /* fill mobile width */ width: 100%;
/* fill mobile width */
height: fit-content; height: fit-content;
margin: 0 auto; /* center horizontally */ margin: 0 auto;
/* center horizontally */
box-sizing: border-box; box-sizing: border-box;
} }
.a4page-landscape { .a4page-landscape {
width: 100%; /* fill mobile width */ width: 100%;
/* fill mobile width */
height: fit-content; height: fit-content;
margin: 0 auto; /* center horizontally */ margin: 0 auto;
/* center horizontally */
box-sizing: border-box; box-sizing: border-box;
} }
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.a5page-portrait { .a5page-portrait {
width: 100%; width: 100%;
@@ -238,6 +255,7 @@ td {
margin: 0 auto; margin: 0 auto;
box-sizing: border-box; box-sizing: border-box;
} }
.a5page-landscape { .a5page-landscape {
width: 100%; width: 100%;
height: fit-content; height: fit-content;
@@ -249,12 +267,15 @@ td {
.tl { .tl {
@apply absolute top-0 left-0; @apply absolute top-0 left-0;
} }
.tr { .tr {
@apply absolute top-0 right-0; @apply absolute top-0 right-0;
} }
.bl { .bl {
@apply absolute bottom-0 left-0; @apply absolute bottom-0 left-0;
} }
.br { .br {
@apply absolute bottom-0 right-0; @apply absolute bottom-0 right-0;
} }
@@ -270,17 +291,9 @@ td {
--blur: 0%; --blur: 0%;
background-color: var(--bg_secondary); background-color: var(--bg_secondary);
background-image: radial-gradient( background-image: radial-gradient(circle at center,
circle at center, var(--bg_primary) var(--dot_size),
var(--bg_primary) var(--dot_size), transparent var(--blur));
transparent var(--blur)
);
background-size: var(--bg_size) var(--bg_size); background-size: var(--bg_size) var(--bg_size);
background-position: 0 0; background-position: 0 0;
mask-image: linear-gradient(
30deg,
rgba(1, 1, 1, 1) 0%,
rgba(1, 1, 1, 0.9) 100%
);
} }

View File

@@ -49,14 +49,10 @@ const faces_string = faces.join(" ");
<RouterLink class="bdr-2 bg-bg_primary" to="/" v-if="!inHome"> <RouterLink class="bdr-2 bg-bg_primary" to="/" v-if="!inHome">
<a>HOME</a> <a>HOME</a>
</RouterLink> </RouterLink>
<RouterLink <RouterLink class="bdr-2 bg-bg_primary" v-if="parentPath" :to="parentPath">
class="bdr-2 bg-bg_primary"
v-if="parentPath"
:to="parentPath"
>
<a>UP</a> <a>UP</a>
</RouterLink> </RouterLink>
<Headline class="border flex-1"> <Headline class="border flex-1 max-w-full">
<code class="whitespace-pre">{{ faces_string }}</code> <code class="whitespace-pre">{{ faces_string }}</code>
</Headline> </Headline>
</nav> </nav>

View File

@@ -1,46 +0,0 @@
<template>
<div v-if="streamLive">
<img src="/img/tmpen31z3pe.PNG" />
<audio controls :src="streamUrl" ref="audio"></audio>
</div>
<div v-else>
<img src="/img/tmpen31z3pe.PNG" />
<div class="m-1">
<p>Stream is offline. Tune in Fridays @ 6:00pm, Monday @ 8:00am</p>
<Button @click="checkStream()">Check Stream</Button>
</div>
</div>
</template>
<script setup>
import Button from "@/components/input/Button.vue";
import { ref, onMounted } from "vue";
import axios from "axios";
const streamMount = ref("");
const streamUrl = ref("");
const streamLive = ref(false);
const audio = ref(null);
async function checkStream() {
try {
const res = await axios.get("/radio/status-json.xsl");
const data = res.data;
streamMount.value = data.icestats.source.listenurl.split("/").pop();
if (streamMount.value) {
streamLive.value = true;
streamUrl.value = "/radio/" + streamMount.value;
if (audio.value) audio.value.load();
}
} catch (err) {
streamLive.value = false;
}
}
onMounted(() => {
checkStream();
setInterval(checkStream, 120000);
});
</script>

View File

@@ -1,36 +0,0 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import axios from "axios";
const type = ref("");
const name = ref("");
const link = ref("");
async function post() {
try {
const res = await axios.post("/api/activity", {
type: type.value,
name: name.value,
link: link.value || undefined,
});
type.value = "";
name.value = "";
link.value = "";
console.log(res.data);
} catch (err) {
console.error(err);
}
}
</script>
<template>
<div class="flex flex-col">
<h1>Create Activity</h1>
<input type="text" v-model="type" placeholder="Type" />
<input type="text" v-model="name" placeholder="Name" />
<input type="text" v-model="link" placeholder="Link" />
<Button @click="post">Upload</Button>
</div>
</template>

View File

@@ -1,36 +0,0 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import axios from "axios";
const type = ref("");
const name = ref("");
const link = ref("");
async function post() {
try {
const res = await axios.post("/api/favorites", {
type: type.value,
name: name.value,
link: link.value || undefined,
});
type.value = "";
name.value = "";
link.value = "";
console.log(res.data);
} catch (err) {
console.error(err);
}
}
</script>
<template>
<div class="flex flex-col">
<h1>Create Favorite</h1>
<input type="text" v-model="type" placeholder="Type" />
<input type="text" v-model="name" placeholder="Name" />
<input type="text" v-model="link" placeholder="Link" />
<Button @click="post">Upload</Button>
</div>
</template>

View File

@@ -1,28 +0,0 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref, onMounted, computed } from "vue";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const username = ref("");
const password = ref("");
function handleLogin() {
auth.createUser(username.value, password.value);
}
</script>
<template>
<div v-if="auth.loggedIn" class="flex flex-col">
<h1>Logged in</h1>
<p>{{ auth.user.id }}</p>
<p>{{ auth.user.username }}</p>
<p>{{ auth.user.admin }}</p>
</div>
<div v-else class="flex flex-col">
<h1>Create User</h1>
<input type="text" v-model="username" placeholder="Username" />
<input type="password" v-model="password" placeholder="Password" />
<Button @click="handleLogin">Create Account</Button>
</div>
</template>

View File

@@ -1,79 +1,85 @@
<script setup> <script setup>
import { ref, onMounted, useTemplateRef, onUnmounted } from "vue"; import { onMounted, useTemplateRef, onUnmounted } from "vue";
const container = useTemplateRef("container"); const container = useTemplateRef("container");
const item1 = useTemplateRef("item1"); const item1 = useTemplateRef("item1");
const item2 = useTemplateRef("item2");
let offset = 0; let offset = 0;
let cachedWidth = 0;
let rafId; let rafId;
const speed = 0.5; // pixels per frame const speed = 0.5; // pixels per frame
function animate() { function measureWidth() {
const ctnr = container.value; const ctnr = container.value;
const it1 = item1.value; const it1 = item1.value;
const it2 = item2.value; if (ctnr && it1) {
cachedWidth = Math.max(ctnr.offsetWidth, it1.scrollWidth);
}
}
const width = Math.max(ctnr.offsetWidth, it1.scrollWidth); function animate() {
const ctnr = container.value;
if (!ctnr || cachedWidth === 0) {
rafId = requestAnimationFrame(animate);
return;
}
offset -= speed; offset -= speed;
if (offset <= -width) { if (offset <= -cachedWidth) {
offset += width; offset += cachedWidth;
} }
it1.style.transform = `translateX(${offset}px)`; ctnr.style.transform = `translateX(${offset}px)`;
it2.style.transform = `translateX(${width + offset}px)`;
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
} }
let resizeObserver;
onMounted(() => { onMounted(() => {
measureWidth();
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
resizeObserver = new ResizeObserver(measureWidth);
resizeObserver.observe(container.value);
}); });
onUnmounted(() => { onUnmounted(() => {
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
}); });
</script> </script>
<template> <template>
<div class="marquee"> <div class="root">
<div class="container" ref="container"> <div class="container" ref="container">
<div class="item" ref="item1"><slot /></div> <div ref="item1">
<div class="item item2" ref="item2"><slot /></div> <slot />
</div>
<div>
<slot />
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.marquee { .root {
overflow: hidden; overflow: hidden;
width: 100%;
} }
.container { .container {
width: 100%;
height: fit-content;
position: relative;
will-change: transform;
}
.item {
height: fit-content;
top: 0px;
padding-right: 3em;
width: fit-content; width: fit-content;
white-space: nowrap; height: fit-content;
} display: grid;
grid-auto-flow: column;
.item1 { grid-auto-columns: max-content;
left: 0px; /* Each column fits its content */
} overflow-x: visible;
will-change: transform;
.item2 { gap: 10em;
position: absolute;
} }
</style> </style>

View File

@@ -9,12 +9,19 @@ import { useTemplateRef, onMounted, onBeforeUnmount } from "vue";
const container = useTemplateRef("container"); const container = useTemplateRef("container");
const SPEED = 1; // px per frame const SPEED = 0.0005; // % per frame
const PAUSE = 2000; // ms at top/bottom const PAUSE = 2000; // ms at top/bottom
let pos = 0;
let direction = 1; // 1 = down, -1 = up let direction = 1; // 1 = down, -1 = up
let timeoutId; let timeoutId;
let timeoutId2; let timeoutId2;
let cachedScrollHeight = 0;
function measureScrollHeight() {
const el = container.value;
if (el) cachedScrollHeight = el.scrollHeight;
}
function handleHover() { function handleHover() {
cancelAnimationFrame(timeoutId); cancelAnimationFrame(timeoutId);
@@ -27,25 +34,45 @@ function handleHover() {
function tick() { function tick() {
const el = container.value; const el = container.value;
el.scrollTop += SPEED * direction; if (!el || cachedScrollHeight === 0) {
timeoutId = requestAnimationFrame(tick);
return;
}
const reachedBottom = el.scrollTop + el.clientHeight >= el.scrollHeight; const reachedBottom = pos <= 0;
const reachedTop = el.scrollTop <= 0; const reachedTop = pos >= 1;
if (reachedBottom || reachedTop) { if (reachedBottom) {
direction *= -1; pos = 0.001;
direction = 1;
handleHover();
return;
} else if (reachedTop) {
pos = 0.999;
direction = -1;
handleHover(); handleHover();
return; return;
} }
pos += direction * SPEED;
el.scrollTop = pos * cachedScrollHeight;
timeoutId = requestAnimationFrame(tick); timeoutId = requestAnimationFrame(tick);
} }
let resizeObserver;
onMounted(() => { onMounted(() => {
measureScrollHeight();
timeoutId = requestAnimationFrame(tick); timeoutId = requestAnimationFrame(tick);
resizeObserver = new ResizeObserver(measureScrollHeight);
resizeObserver.observe(container.value);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
cancelAnimationFrame(timeoutId); cancelAnimationFrame(timeoutId);
resizeObserver?.disconnect();
}); });
</script> </script>

View File

@@ -1,9 +1,75 @@
<script setup> <script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue";
import Button from "@/components/input/Button.vue"; import Button from "@/components/input/Button.vue";
import { useMessagesStore } from "@/stores/messages"; import { useMessagesStore } from "@/stores/messages";
import { useAuthStore } from "@/stores/auth";
import Header from "@/components/text/Header.vue";
const messagesStore = useMessagesStore(); const messagesStore = useMessagesStore();
const authStore = useAuthStore();
const messages = computed(() => messagesStore.messages); const messages = computed(() => messagesStore.messages);
const messageInput = ref("");
const messagesContainer = ref(null);
const fileInput = ref(null);
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop =
messagesContainer.value.scrollHeight;
}
});
}
watch(messages, scrollToBottom, { deep: true });
function sendMessage() {
const text = messageInput.value.trim();
if (!text) return;
messagesStore.sendMessage(text);
messageInput.value = "";
}
async function onFileSelected(e) {
const file = e.target.files[0];
if (!file) return;
await messagesStore.uploadAndSendFile(file);
fileInput.value.value = "";
}
function isImageUrl(url) {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
}
function isVideoUrl(url) {
return /\.(mp4|webm|ogg|mov)$/i.test(url);
}
function isSafeFileUrl(url) {
return typeof url === "string" && url.startsWith("/uploads/");
}
const urlRegex = /(https?:\/\/[^\s<]+)/g;
function parseMessageParts(text) {
const parts = [];
let lastIndex = 0;
let match;
while ((match = urlRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({
type: "text",
value: text.slice(lastIndex, match.index),
});
}
parts.push({ type: "link", value: match[1] });
lastIndex = urlRegex.lastIndex;
}
if (lastIndex < text.length) {
parts.push({ type: "text", value: text.slice(lastIndex) });
}
return parts;
}
onMounted(() => { onMounted(() => {
messagesStore.connect(); messagesStore.connect();
@@ -14,15 +80,69 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div> <div class="flex-col flex">
<div class="flex flex-col"> <Header>Chat</Header>
<div ref="messagesContainer" class="flex flex-col overflow-y-auto p-2">
<p v-for="message in messages" :key="message.id"> <p v-for="message in messages" :key="message.id">
{{ message.content }} <span class="text-tertiary">{{ message.authorId }}:</span>
<template
v-for="(part, i) in parseMessageParts(message.text || '')"
:key="i"
>
<a
v-if="part.type === 'link'"
:href="part.value"
target="_blank"
rel="noopener noreferrer"
class="text-primary underline"
>{{ part.value }}</a
>
<span v-else>{{ part.value }}</span>
</template>
<template
v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)"
>
<img
v-if="isImageUrl(message.fileUrl)"
:src="message.fileUrl"
class="max-w-xs max-h-48 rounded"
@load="scrollToBottom"
/>
<video
v-else-if="isVideoUrl(message.fileUrl)"
:src="message.fileUrl"
controls
class="max-w-xs max-h-48 rounded"
@loadedmetadata="scrollToBottom"
/>
<a
v-else
:href="message.fileUrl"
target="_blank"
class="underline"
>{{ message.fileUrl.split("/").pop() }}</a
>
</template>
</p> </p>
</div> </div>
<div class="flex flex-row"> <div>
<input v-model="messageInput" @keyup.enter="sendMessage" /> <input v-model="messageInput" @keyup.enter="sendMessage" />
<Button @click="sendMessage">Send</Button> <input
ref="fileInput"
type="file"
class="hidden"
@change="onFileSelected"
/>
<div class="flex gap-2">
<Button class="flex-1" @click="sendMessage">Send</Button>
<Button
v-if="authStore.user.admin"
class="flex-1"
@click="fileInput.click()"
>Attach</Button
>
<Button class="flex-1" @click="scrollToBottom">Bottom</Button>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,63 @@
<script setup>
import axios from "axios";
import { ref, onMounted } from "vue";
import Header from "@/components/text/Header.vue";
const url = "/gitea/api/v1/users/adamf/activities/feeds?limit=1";
const feed = ref(null);
const isLoading = ref(true);
const hasError = ref(false);
async function checkFeed() {
try {
const res = await axios.get(url);
feed.value = res.data[0] || null;
hasError.value = false;
} catch (err) {
hasError.value = true;
} finally {
isLoading.value = false;
}
}
onMounted(() => {
checkFeed();
});
</script>
<template>
<div class="flex flex-col text-center h-full">
<Header class="text-left">Commits</Header>
<div v-if="isLoading" class="flex-1 overflow-y-auto">
<p>Loading latest activity...</p>
</div>
<div v-else-if="hasError" class="flex-1 overflow-y-auto">
<p>Could not fetch feed. Please try again later.</p>
</div>
<div
v-else-if="feed"
class="flex-1 flex flex-col justify-center overflow-y-auto"
>
<h3>Last git activity</h3>
<img
:src="feed.act_user.avatar_url"
alt="User avatar"
class="avatar"
/>
<a :href="feed.repo.html_url">
<h3>repo: {{ feed.repo.full_name }}</h3>
</a>
<p>Action: {{ feed.op_type }}</p>
<p>Message: {{ JSON.parse(feed.content).Commits[0].Message }}</p>
<small> {{ new Date(feed.created).toLocaleString() }}</small>
</div>
<div v-else class="flex-1 overflow-y-auto">
<p>No activity found.</p>
</div>
</div>
</template>

View File

@@ -13,7 +13,7 @@ const keys = ["name", "link"];
<template> <template>
<a v-for="(row, rowIndex) in linkArr" :key="rowIndex" :href="row.link"> <a v-for="(row, rowIndex) in linkArr" :key="rowIndex" :href="row.link">
<p class="bdr-2 bg-bg_primary"> <p class="bdr-2 bg-bg_tertiary">
{{ row.name }} {{ row.name }}
</p> </p>
</a> </a>

View File

@@ -0,0 +1,48 @@
<template>
<div v-if="streamLive" class="overflow-hidden">
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" />
<audio controls :src="streamUrl" ref="audio"></audio>
</div>
<div v-else>
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" />
<div class="m-1 text-center">
<p>Radio is offline. Message for info!</p>
<Button class="w-full" @click="checkStream()">Check Stream</Button>
</div>
</div>
</template>
<script setup>
import Button from "@/components/input/Button.vue";
import Header from "@/components/text/Header.vue";
import { ref, useTemplateRef, onMounted, nextTick } from "vue";
import axios from "axios";
const streamUrl = ref("");
const streamLive = ref(false);
const audio = useTemplateRef("audio");
async function checkStream() {
try {
await axios.head("/radio/stream");
if (!streamLive.value) {
streamLive.value = true;
streamUrl.value = "/radio/stream";
await nextTick();
if (audio.value) {
audio.value.load();
audio.value.volume = 0.2;
}
}
} catch (err) {
streamLive.value = false;
}
}
onMounted(() => {
checkStream();
setInterval(checkStream, 120000);
});
</script>

View File

@@ -0,0 +1,83 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import Header from "@/components/text/Header.vue";
const props = defineProps({
images: {
type: Array,
required: true,
},
interval: {
type: Number,
default: 10000,
},
});
const currentIndex = ref(0);
const currentComment = computed(() => props.images[currentIndex.value].comment);
const currentUrl = computed(() => props.images[currentIndex.value].url);
let nextId;
function nextImage() {
clearTimeout(nextId);
currentIndex.value = (currentIndex.value + 1) % props.images.length;
nextId = setTimeout(nextImage, props.interval);
}
onMounted(() => {
nextId = setTimeout(nextImage, props.interval);
});
onUnmounted(() => {
clearTimeout(nextId);
});
</script>
<template>
<div class="slideshow-wrapper">
<Transition name="fade">
<div class="image-viewer" @click="nextImage" :key="currentIndex">
<Header v-if="currentComment">
{{ currentComment }}
</Header>
<img :src="currentUrl" alt="Image Viewer" loading="lazy" />
</div>
</Transition>
</div>
</template>
<style scoped>
.slideshow-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.image-viewer {
width: 100%;
height: 100%;
overflow: hidden;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-leave-active {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -24,8 +24,8 @@ setInterval(updateDateTime, 60000);
</script> </script>
<template> <template>
<div class="items-center flex flex-col"> <div class="flex flex-col">
<Header>{{ weekday }} {{ day }}, {{ month }}</Header>
<h1>{{ time }}</h1> <h1>{{ time }}</h1>
<h1>{{ weekday }} {{ day }}, {{ month }}</h1>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,7 @@
<script setup> <script setup>
import Button from "@/components/input/Button.vue"; import Button from "@/components/input/Button.vue";
import Header from "@/components/text/Header.vue";
import { ref } from "vue"; import { ref } from "vue";
const timer = ref(null); const timer = ref(null);
@@ -64,7 +66,7 @@ function playFinishedSound() {
<template> <template>
<div class="flex flex-col gap-1 p-1 items-center"> <div class="flex flex-col gap-1 p-1 items-center">
<h2 class="items-center">Timer</h2> <Header>Timer</Header>
<div v-if="finished && paused" class="flex flex-col"> <div v-if="finished && paused" class="flex flex-col">
<div class="flex flex-row p-2 place-content-around"> <div class="flex flex-row p-2 place-content-around">
<input <input

7
nginx/vue/src/graphql.js Normal file
View File

@@ -0,0 +1,7 @@
import axios from "axios";
export async function gql(query, variables = {}) {
const res = await axios.post("/api/graphql", { query, variables });
if (res.data.errors && !res.data.data) throw new Error(res.data.errors[0].message);
return res.data.data;
}

View File

@@ -2,64 +2,64 @@ import { createRouter, createWebHistory } from "vue-router";
import Home from "@/views/home/Home.vue"; import Home from "@/views/home/Home.vue";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: "/", path: "/",
name: "home", name: "home",
component: Home, component: Home,
}, },
{ {
path: "/cv", path: "/cv",
name: "cv", name: "cv",
component: () => import("../views/CV.vue"), component: () => import("../views/CV/CV.vue"),
}, },
{ {
path: "/admin", path: "/admin",
name: "admin", name: "admin",
component: () => import("../views/Admin.vue"), component: () => import("../views/admin/Admin.vue"),
}, },
{ {
path: "/bookmarks", path: "/bookmarks",
name: "bookmarks", name: "bookmarks",
component: () => import("../views/Bookmarks.vue"), component: () => import("../views/Bookmarks.vue"),
}, },
{ {
path: "/notes/:path(.*)*", path: "/notes/:path(.*)*",
name: "notes", name: "notes",
component: () => import("../views/Notes.vue"), component: () => import("../views/Notes.vue"),
}, },
{ {
path: "/shrines", path: "/shrines",
name: "shrine links", name: "shrine links",
component: () => import("../views/Shrines.vue"), component: () => import("../views/Shrines.vue"),
}, },
{ {
path: "/shrines/gto", path: "/shrines/gto",
name: "gto shrine", name: "gto shrine",
component: () => import("../views/shrines/GTO.vue"), component: () => import("../views/shrines/GTO.vue"),
}, },
{ {
path: "/shrines/skipskipbenben", path: "/shrines/skipskipbenben",
name: "skipskipbenben shrine", name: "skipskipbenben shrine",
component: () => import("../views/shrines/Skipskipbenben.vue"), component: () => import("../views/shrines/Skipskipbenben.vue"),
}, },
{ {
path: "/shrines/evangelion", path: "/shrines/evangelion",
name: "evangelion shrine", name: "evangelion shrine",
component: () => import("../views/shrines/Evangelion.vue"), component: () => import("../views/shrines/Evangelion.vue"),
}, },
{ {
path: "/shrines/demoman", path: "/shrines/demoman",
name: "demoman shrine", name: "demoman shrine",
component: () => import("../views/shrines/Demoman.vue"), component: () => import("../views/shrines/Demoman.vue"),
}, },
{ {
path: "/:pathMatch(.*)*", path: "/:pathMatch(.*)*",
name: "404", name: "404",
component: () => import("../views/404.vue"), component: () => import("../views/404.vue"),
}, },
], ],
}); });
export default router; export default router;

View File

@@ -1,6 +1,6 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { computed, ref } from "vue"; import { computed, ref, watch } from "vue";
import axios from "axios"; import { useHomeDataStore } from "@/stores/homeData";
const activity_template = { const activity_template = {
type: "activity", type: "activity",
@@ -13,19 +13,21 @@ export const useActivityStore = defineStore("activity", () => {
const activityCount = computed(() => activity.value.length); const activityCount = computed(() => activity.value.length);
async function fetchActivity() { const homeData = useHomeDataStore();
try { watch(
const res = await axios.get("/api/activity"); () => homeData.activities,
if (!Array.isArray(res.data)) { (newActivities) => {
throw new Error("Invalid response from posts API"); if (newActivities.length > 0) {
activity.value = newActivities;
} }
activity.value = res.data; },
} catch (err) { { immediate: true },
console.error("Cannot connect to activity API", err); );
}
async function fetchActivity() {
await homeData.fetchAll();
} }
fetchActivity();
return { return {
activity, activity,
activityCount, activityCount,

View File

@@ -1,16 +1,26 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { computed, ref } from "vue"; import { computed, ref, watch } from "vue";
import axios from "axios"; import { gql } from "@/graphql";
import { useHomeDataStore } from "@/stores/homeData";
export const useAuthStore = defineStore("auth", () => { export const useAuthStore = defineStore("auth", () => {
const user = ref({}); const user = ref({});
const loggedIn = computed(() => !!user.value.username); const loggedIn = computed(() => !!user.value.username);
checkToken(); const homeData = useHomeDataStore();
watch(
() => homeData.me,
(me) => {
if (me) {
user.value = me;
}
},
{ immediate: true },
);
async function logOut() { async function logOut() {
try { try {
const res = await axios.post("/api/auth/logout"); await gql(`mutation { logout }`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
@@ -19,11 +29,11 @@ export const useAuthStore = defineStore("auth", () => {
async function logIn(username, password) { async function logIn(username, password) {
try { try {
const res = await axios.post("/api/auth/login", { const data = await gql(
username, `mutation Login($input: LoginInput!) { login(input: $input) { user { id username admin } } }`,
password, { input: { username, password } },
}); );
user.value = res.data; user.value = data.login.user;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
@@ -31,31 +41,38 @@ export const useAuthStore = defineStore("auth", () => {
async function createUser(username, password) { async function createUser(username, password) {
try { try {
const res = await axios.post("/api/user", { const data = await gql(
username, `mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id username admin } }`,
password, { input: { username, password } },
}); );
user.value = res.data; return data.createUser;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
throw err;
} }
} }
async function refreshToken() { async function refreshToken() {
try { try {
const res = await axios.post("/api/auth/refresh"); const data = await gql(
user.value = res.data; `mutation { refreshToken { user { id username admin } } }`,
);
user.value = data.refreshToken.user;
} catch (err) { } catch (err) {
console.log(err); console.log(err);
} }
} }
async function checkToken() { async function setUserAdmin(userId, admin) {
try { try {
const res = await axios.get("/api/auth/check"); const data = await gql(
user.value = res.data; `mutation SetUserAdmin($id: ID!, $admin: Boolean!) { setUserAdmin(id: $id, admin: $admin) { id username admin } }`,
{ id: userId, admin },
);
return data.setUserAdmin;
} catch (err) { } catch (err) {
user.value = {}; console.error(err);
throw err;
} }
} }
@@ -65,9 +82,9 @@ export const useAuthStore = defineStore("auth", () => {
loggedIn, loggedIn,
logIn, logIn,
checkToken,
refreshToken, refreshToken,
logOut, logOut,
createUser, createUser,
setUserAdmin,
}; };
}); });

View File

@@ -1,6 +1,6 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { computed, ref } from "vue"; import { computed, ref, watch } from "vue";
import axios from "axios"; import { useHomeDataStore } from "@/stores/homeData";
const favorite_template = { const favorite_template = {
type: "favorite", type: "favorite",
@@ -13,19 +13,20 @@ export const useFavoritesStore = defineStore("favorites", () => {
const favoritesCount = computed(() => favorites.value.length); const favoritesCount = computed(() => favorites.value.length);
async function fetchFavorites() { const homeData = useHomeDataStore();
try { watch(
const res = await axios.get("/api/favorites"); () => homeData.favorites,
if (!Array.isArray(res.data)) { (newFavorites) => {
throw new Error("Invalid response from favorites API"); if (newFavorites.length > 0) {
favorites.value = newFavorites;
} }
favorites.value = res.data; },
} catch (err) { { immediate: true },
console.error("Cannot connect to favorites API", err); );
}
}
fetchFavorites(); async function fetchFavorites() {
await homeData.fetchAll();
}
return { return {
favorites, favorites,

View File

@@ -0,0 +1,82 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { gql } from "@/graphql";
import axios from "axios";
export const useHomeDataStore = defineStore("homeData", () => {
const loaded = ref(false);
const error = ref(null);
const me = ref(null);
const posts = ref([]);
const favorites = ref([]);
const activities = ref([]);
const spotifyRecent = ref([]);
const rowingSessions = ref([]);
const gitFeed = ref(null);
const radioLive = ref(false);
async function fetchAll() {
try {
const [data] = await Promise.all([
gql(`
query HomeData {
posts { id title content createdAt updatedAt author { id username } }
favorites { id type name link createdAt }
activities { id type name link createdAt }
spotifyRecent { track { name album { name images { url } } artists { name } } playedAt }
rowingSessions { id date time distance timePer500m calories }
me { id username admin }
}
`),
fetchGitFeed(),
fetchRadioStatus(),
]);
posts.value = data.posts;
favorites.value = data.favorites;
activities.value = data.activities;
spotifyRecent.value = data.spotifyRecent;
rowingSessions.value = data.rowingSessions;
me.value = data.me || null;
loaded.value = true;
} catch (err) {
console.error("HomeData fetch failed:", err);
error.value = err;
}
}
async function fetchGitFeed() {
try {
const res = await axios.get("/gitea/api/v1/users/adamf/activities/feeds?limit=1");
gitFeed.value = res.data[0] || null;
} catch {
gitFeed.value = null;
}
}
async function fetchRadioStatus() {
try {
await axios.head("/radio/stream");
radioLive.value = true;
} catch {
radioLive.value = false;
}
}
fetchAll();
return {
loaded,
error,
me,
posts,
favorites,
activities,
spotifyRecent,
rowingSessions,
gitFeed,
radioLive,
fetchAll,
fetchRadioStatus,
};
});

View File

@@ -1,29 +1,34 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import axios from "axios";
const URL = "/api/ws"; function getWebSocketURL() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const message_template = { return `${protocol}//${window.location.host}/api/ws`;
id: 0, }
content: "Yo",
};
export const useMessagesStore = defineStore("messages", () => { export const useMessagesStore = defineStore("messages", () => {
const socket = ref(null); const socket = ref(null);
const messages = ref([message_template]); const messages = ref([]);
const isConnected = ref(false); const isConnected = ref(false);
const lastError = ref(null); const lastError = ref(null);
let intentionalClose = false;
let reconnectDelay = 1000;
let reconnectTimer = null;
const messagesCount = computed(() => messages.value.length); const messagesCount = computed(() => messages.value.length);
function connect() { function connect() {
if (socket.value && isConnected.value) return; if (socket.value && isConnected.value) return;
intentionalClose = false;
socket.value = new WebSocket(URL); socket.value = new WebSocket(getWebSocketURL());
socket.value.onopen = () => { socket.value.onopen = () => {
isConnected.value = true; isConnected.value = true;
lastError.value = null; lastError.value = null;
reconnectDelay = 1000;
messages.value = [];
}; };
socket.value.onmessage = (event) => { socket.value.onmessage = (event) => {
@@ -31,8 +36,7 @@ export const useMessagesStore = defineStore("messages", () => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
messages.value.push(data); messages.value.push(data);
} catch { } catch {
// fallback if server sends plain text messages.value.push({ text: event.data });
messages.value.push(event.data);
} }
}; };
@@ -43,25 +47,46 @@ export const useMessagesStore = defineStore("messages", () => {
socket.value.onclose = () => { socket.value.onclose = () => {
isConnected.value = false; isConnected.value = false;
socket.value = null; socket.value = null;
if (!intentionalClose) {
reconnectTimer = setTimeout(() => {
connect();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
}
}; };
} }
function disconnect() { function disconnect() {
intentionalClose = true;
clearTimeout(reconnectTimer);
if (!socket.value) return; if (!socket.value) return;
socket.value.close(); socket.value.close();
socket.value = null; socket.value = null;
isConnected.value = false; isConnected.value = false;
} }
function sendMessage(payload) { function sendMessage(text) {
if (!socket.value || !isConnected.value) return; if (!socket.value || !isConnected.value) return;
socket.value.send(JSON.stringify(payload)); socket.value.send(JSON.stringify({ text }));
} }
function clearMessages() { function clearMessages() {
messages.value = []; messages.value = [];
} }
async function uploadAndSendFile(file) {
try {
const formData = new FormData();
formData.append("file", file);
const res = await axios.post("/api/messages/upload", formData);
const { url } = res.data;
if (!socket.value || !isConnected.value) return;
socket.value.send(JSON.stringify({ text: "", fileUrl: url }));
} catch (err) {
lastError.value = err;
}
}
return { return {
messages, messages,
isConnected, isConnected,
@@ -73,5 +98,6 @@ export const useMessagesStore = defineStore("messages", () => {
disconnect, disconnect,
sendMessage, sendMessage,
clearMessages, clearMessages,
uploadAndSendFile,
}; };
}); });

View File

@@ -1,6 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { computed, ref } from "vue"; import { computed, ref, watch } from "vue";
import axios from "axios"; import { gql } from "@/graphql";
import { useHomeDataStore } from "@/stores/homeData";
const post_template = { const post_template = {
title: "Can't fetch from the db yo", title: "Can't fetch from the db yo",
@@ -17,32 +18,34 @@ export const usePostsStore = defineStore("posts", () => {
const postsCount = computed(() => posts.value.length); const postsCount = computed(() => posts.value.length);
async function fetchPosts() { const homeData = useHomeDataStore();
try { watch(
const res = await axios.get("/api/posts"); () => homeData.posts,
if (!Array.isArray(res.data)) { (newPosts) => {
throw new Error("Invalid response from posts API"); if (newPosts.length > 0) {
posts.value = newPosts;
} }
posts.value = res.data; },
} catch (err) { { immediate: true },
console.error("Cannot connect to Post API", err); );
}
async function fetchPosts() {
await homeData.fetchAll();
} }
async function deletePost(post) { async function deletePost(post) {
try { try {
const res = await axios.delete( await gql(
`/api/posts/${encodeURIComponent(post.id)}`, `mutation DeletePost($id: ID!) { deletePost(id: $id) { id } }`,
{ id: post.id },
); );
console.log("Deleted:", res.data); console.log("Deleted:", post.id);
fetchPosts(); await homeData.fetchAll();
} catch (err) { } catch (err) {
console.error("Delete failed:", err); console.error("Delete failed:", err);
} }
} }
fetchPosts();
return { return {
posts, posts,

View File

@@ -1,13 +1,12 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed } from "vue"; import { ref, computed, watch } from "vue";
import axios from "axios"; import { gql } from "@/graphql";
import { useHomeDataStore } from "@/stores/homeData";
const song_template = { const song_template = {
id: 1,
track: { track: {
id: 1,
name: "^_^", name: "^_^",
album: { images: [{ url: "/img/Untitled.png" }] }, album: { name: "", images: [{ url: "/img/Untitled.png" }] },
artists: [{ name: ">_<" }], artists: [{ name: ">_<" }],
}, },
}; };
@@ -17,13 +16,34 @@ export const useSongsStore = defineStore("songs", () => {
const songsCount = computed(() => songs.value.length); const songsCount = computed(() => songs.value.length);
const homeData = useHomeDataStore();
watch(
() => homeData.spotifyRecent,
(newSongs) => {
if (newSongs.length > 0) {
songs.value = newSongs;
}
},
{ immediate: true },
);
async function fetchSongs() { async function fetchSongs() {
try { try {
const res = await axios.get("/api/spotify/recent"); const data = await gql(`
if (!Array.isArray(res.data)) { query {
throw new Error("Invalid response from Spotify API"); spotifyRecent {
track {
name
album { name images { url } }
artists { name }
}
playedAt
}
}
`);
if (Array.isArray(data.spotifyRecent) && data.spotifyRecent.length > 0) {
songs.value = data.spotifyRecent;
} }
songs.value = res.data;
} catch (err) { } catch (err) {
console.error("Cannot connect to Spotify API", err); console.error("Cannot connect to Spotify API", err);
} }

View File

@@ -1,603 +0,0 @@
<template>
<main>
<div class="a4page">
<div class="contact">
<h1>Adam French</h1>
<!-- <a href="covers.html"><img width=25 height=50 src="img/rune.png"></a> -->
<div class="contact-details">
<p>+447563266931</p>
<p>adam.a.french@outlook.com</p>
<p>www.adam-french.co.uk</p>
</div>
</div>
<h2>Profile</h2>
<p>
Recently graduated from the University of Leeds with a BSc
Computer Science with Mathematics (International) degree.
Currently self-studying and building projects aligned with the
type of roles I am seeking. I have a strong background across a
variety of programming languages and will be able to quickly get
on board with any codebase.
</p>
<p>
I am most keen to work for a company with altruistic values and
a focus on durable solutions. Looking forward to learning from
experts and collaborating with motivated individuals.
</p>
<h2>Personal Projects</h2>
<table>
<thead>
<tr>
<th>Project</th>
<th>Skills</th>
<th>Date</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>Personal Websites</td>
<td>Nginx, Vue, Postgres, Docker, Go, Python</td>
<td>Ongoing</td>
<td class="row-leftalign">
My personal site, Currently
<b>self hosted</b>
using <b>listed skills</b>. In the past, I have used
Svelte, React/Redux, SQLite, Rust and Deno.
</td>
</tr>
<tr>
<td>Computer Graphics</td>
<td>Rust, Linear Algebra, Multithreading</td>
<td>2023</td>
<td class="row-leftalign">
A multithreaded, recursive ray tracer implemented in
Rust.
</td>
</tr>
<tr>
<td>Mobile Automata</td>
<td>Mathematica, JS</td>
<td>2024</td>
<td class="row-leftalign">
Investigated properties of cellular automata by
observing emergent behaviors through custom
simulations.
</td>
</tr>
<tr>
<td>Arduino Programming & Circuits</td>
<td>C++, Soldering, Embedded Systems</td>
<td>2022 - 2025</td>
<td class="row-leftalign">
Created decorations using salvaged components from
discarded electronics.
</td>
</tr>
<tr>
<td>Memory Palace Website</td>
<td>TS, Rust, React, Redux, SQLite</td>
<td>2025</td>
<td class="row-leftalign">
Full-stack web application aiming to make the
memory palace memorization technique easy.
</td>
</tr>
<tr>
<td>3D Printing</td>
<td>FreeCAD</td>
<td>Ongoing</td>
<td class="row-leftalign">
Designing quality of life objects using FreeCAD and
printing with a BambuLab A1.
</td>
</tr>
</tbody>
</table>
<h2>Education</h2>
<table>
<thead>
<tr>
<th>Location</th>
<th>Date</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>The University of Leeds</td>
<td>
<!-- <div style="display: flex; flex-direction: column; align-items: center;"> -->
<!-- <span>2021</span> -->
<!-- <span>to</span> -->
<!-- <span>2025</span> -->
<!-- </div> -->
2021-2025
</td>
<td class="row-leftalign">
<strong
>BSc Computer Science with Mathematics
(International)</strong
><br />
<strong
>Average:
81.1%&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;(First
Class Honours) </strong
><br />
<strong>Relevant Courses: </strong>
Procedural Programming, Object Oriented Programming,
Algorithms and Data Structures I & II, Databases,
Computer Processors, Compiler Design and
Construction, Formal Languages and Finite Automata,
Probability and Statistics I, Machine Learning,
Graph Algorithms & Complexity Theory
</td>
</tr>
<tr>
<td>The University of Waterloo</td>
<td>
<!-- <div style="display: flex; flex-direction: column; align-items: center;"> -->
<!-- <span>2023</span> -->
<!-- <span>to</span> -->
<!-- <span>2024</span> -->
<!-- </div> -->
2023-2024
</td>
<td class="row-leftalign">
<strong>Average: 74.5%</strong>
<br />
<strong>Relevant Courses:</strong>
Applied Cryptography, Introduction to Computer
Graphics, Introduction to Rings and Fields with
Applications<br /><br />
</td>
</tr>
</tbody>
</table>
</div>
<div class="a4page">
<h2>Experience</h2>
<table>
<thead>
<tr>
<th>Role</th>
<th>Location</th>
<th>Date</th>
<th>Duties</th>
</tr>
</thead>
<tbody>
<tr>
<td>Student</td>
<td>Wolfram Summer School</td>
<td>2024</td>
<td class="row-leftalign">
Designed and completed a time-constrained research
project exploring Mobile Automata and conditions for
computational reversibility. Communicated findings
through visualizations and presentations.
</td>
</tr>
<tr>
<td>Bartender, Waiter, Cashier</td>
<td>Hospitality Venues</td>
<td>2018-2023</td>
<td class="row-leftalign">
Delivered heartfelt customer service in various
fast-paced, high-pressure hospitality environments.
</td>
</tr>
</tbody>
</table>
<h2>Commitments</h2>
<table>
<thead>
<tr>
<th>Activity</th>
<th>Date</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<tr>
<td>Learning Mandarin</td>
<td>Ongoing</td>
<td class="row-leftalign">
Aiming to complete HSK 3 proficiency exam by
December 2026
</td>
</tr>
<!-- <tr> -->
<!-- <td>Cybersecurity Training</td> -->
<!-- <td>Ongoing</td> -->
<!-- <td class="row-leftalign"> -->
<!-- Using <em>pwn.college, tryhackme.com</em> to learn pentesting techniques.</td> -->
<!-- </tr> -->
<tr>
<td>Sports Activities</td>
<td>Ongoing</td>
<td class="row-leftalign">
Run weekly, active gym attendee, regularly go
hiking.
</td>
</tr>
<tr>
<td>meetup.com</td>
<td>Ongoing</td>
<td class="row-leftalign">
Attending various tech meetups and social events.
</td>
</tr>
<tr>
<td>Boardgames</td>
<td>Ongoing</td>
<td class="row-leftalign">
Meet up regularly to play the game
<i>Root</i>.
</td>
</tr>
<tr>
<td>Leetcode</td>
<td>Ongoing</td>
<td class="row-leftalign">
Do the leetcode daily challenge and hone in on
different programming languages.
</td>
</tr>
<tr>
<td>Construction and Landscaping</td>
<td>Ongoing</td>
<td class="row-leftalign">
Involved in building a house in Bulgaria.
</td>
</tr>
<tr>
<td>University of Waterloo Film Club</td>
<td>2023-2024</td>
<td class="row-leftalign">
Worked on student films <em>Moon King</em> and
<em>HAM</em>, available online.
</td>
</tr>
<tr>
<td>Socratica</td>
<td>2023-2024</td>
<td class="row-leftalign">
Worked with individuals exploring innovative tech.
</td>
</tr>
<tr>
<td>University of Leeds Hockey Club</td>
<td>2022-2023</td>
<td class="row-leftalign">
Played for the University of Leeds Hockey Club.
</td>
</tr>
<tr>
<td>Royal Air Force Air Cadets</td>
<td>2017-2020</td>
<td class="row-leftalign">
Achieved the role of Sergeant and Best Cadet"
award.
</td>
</tr>
</tbody>
</table>
<!-- <div class="interests"> -->
<!-- <table> -->
<!-- <tr><th>Personal qualities</th></tr> -->
<!-- <tr><td>Intuitive</td></tr> -->
<!-- <tr><td>Communicative</td></tr> -->
<!-- <tr><td>Adaptable</td></tr> -->
<!-- <tr><td>Versatile</td></tr> -->
<!-- <tr><td>Diligent</td></tr> -->
<!-- </table> -->
<!-- <table> -->
<!-- <tr><th>Interests</th></tr> -->
<!-- <tr><td>Neuroscience</td></tr> -->
<!-- <tr><td>Bouldering</td></tr> -->
<!-- <tr><td>Science Fiction</td></tr> -->
<!-- <tr><td>Mathematics</td></tr> -->
<!-- <tr><td>Hiking</td></tr> -->
<!-- </table> -->
<!-- <table> -->
<!-- <tr><th>Languages</th></tr> -->
<!-- <tr><td>Rust</td></tr> -->
<!-- <tr><td>HTML/JS</td></tr> -->
<!-- <tr><td>C/C++</td></tr> -->
<!-- <tr><td>React/Vue</td></tr> -->
<!-- <tr><td>Python</td></tr> -->
<!-- </table> -->
<!-- </div> -->
</div>
</main>
</template>
<style scoped>
/* Fonts */
/*@font-face {
font-family: "AldoTheApache";
src: url("/fonts/AldotheApache.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "RobotFont";
src: url("/fonts/Robot_Font.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "m12";
src: url("/fonts/m12.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}*/
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
/* Variables */
* {
/* Blue - Beige */
/* --primary: #153448;
--secondary: #3C5B6F;
--tertiary: #948979;
--quaternary: #f5bb78;
--background: #DFD0B8; */
/* Blue - Turqouise */
/* --primary: #161D6F;
--secondary: #0B2F9F;
--tertiary: #98DED9;
--quaternary: #C7FFD8;
--background: #C2EFD1; */
/* Red - Blue */
/* --primary: #ff204e; */
/* --secondary: #a0153e; */
/* --tertiary: #5d0341; */
/* --quaternary: #3a0e41; */
/* --background: #00224d; */
/* Blue - Brown */
/* --primary: #35374B; */
/* --secondary: #344955; */
/* --tertiary: #50727b; */
/* --quaternary: #78a083; */
/* --background: #c7b077; */
/* Black - White */
--primary: black;
--secondary: black;
--tertiary: black;
--quaternary: #cccccc;
--background: white;
/* Blue - White */
/* --primary: #201e43; */
/* --secondary: #134b70; */
/* --tertiary: #508c9b; */
/* --quaternary: #cceeee; */
/* --background: #eeeeee; */
--font-heading: big_noodle_titling;
--font-text: CreatoDisplay;
--font-size-text: 90%;
--font-size-heading: 2.5em;
--font-size-subheading: 1.5em;
--font-size-tableheading: 1.2em;
}
/* A5 Page */
.a5page {
/* overflow: scroll; */
display: flex;
flex-direction: column;
font-family: var(--font-text);
height: 148mm;
width: 210mm;
padding: 4mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.2);
border: solid 2px var(--primary);
}
/* A4 Page */
.a4page {
line-height: 1.6;
font-family: var(--font-text);
width: 210mm;
/* Standard A4 width */
height: 297mm;
/* Standard A4 height */
padding: 8mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid var(--primary);
overflow: auto;
/* Enables scrolling when content exceeds height */
margin: auto auto;
/* Centers the page horizontally */
}
/* Component Styling */
main {
padding: 0px;
display: flex;
flex-direction: column;
height: fit-content;
}
span {
height: 2em;
}
h1,
h2,
h3,
h4 {
border: none;
color: var(--primary);
font-family: var(--font-heading);
text-transform: capitalize;
text-align: center;
}
h1 {
font-size: var(--font-size-heading);
}
h2 {
margin: 0px;
margin-bottom: 3px;
border-bottom: 1px solid var(--primary);
font-size: var(--font-size-subheading);
}
p {
color: var(--secondary);
font-size: var(--font-size-text);
margin-top: 0.3em;
margin-bottom: 0.5em;
}
table {
color: var(--secondary);
border-collapse: collapse;
border: 1px solid black;
}
td {
/* border: 2px solid var(--tertiary); */
color: var(--secondary);
border-top: 1px solid var(--tertiary);
padding: 1px 10px 1px 10px;
font-size: var(--font-size-text);
text-align: left;
}
th {
color: var(--secondary);
border: 2px solid var(--tertiary);
padding: 1px 0px 1px 7px;
font-family: var(--font-heading);
font-size: var(--font-size-tableheading);
background-color: var(--quaternary);
text-align: left;
}
a {
text-decoration: none;
color: inherit;
}
a:hover,
a:visited {
color: inherit;
}
/* Classes */
/* Cover Navigation (for ease of use) */
.cover-nav {
position: fixed;
top: 0.5vh;
/* Position the element at the top of the screen */
left: 80vw;
/* Position the element at the left of the screen */
border: 2px solid var(--tertiary);
width: 19.5vw;
/* Make the element span the width of the screen (optional) */
background-color: var(--background);
/* Set a background color to avoid overlap issues */
z-index: 1000;
/* Ensures the element is above other content */
}
.cover-nav td,
tr {
font-family: var(--font-text);
border: 0;
}
.cover-nav th {
text-align: center;
align-items: center;
border-bottom: 2px solid var(--tertiary);
}
@media print {
.no-print {
display: none !important;
}
}
/* Cover letter styling */
textarea {
width: 100%;
height: 100%;
padding: 10px;
box-sizing: border-box;
border: 0px solid var(--primary);
resize: none;
font-family: var(--font-text);
}
/* Contact At Top of Page */
.contact {
all: unset;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--primary);
}
.contact-details {
all: unset;
display: flex;
flex-direction: column;
text-align: right;
}
.contact-details p {
margin: 1px 0;
}
/* Interests and Skills at bottom of page */
.interests {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-top: solid 2px var(--primary);
}
.interests td,
tr,
th {
border: 0;
}
.row-leftalign {
/* background-image: url("https://www.fridakahlo.org/assets/img/paintings/without-hope.jpg"); */
text-align: left;
}
</style>

View File

@@ -0,0 +1,359 @@
<script setup>
import Project from "./Project.vue";
</script>
<template>
<main>
<div class="no-print w-full h-20">
</div>
<div class="a4page">
<div class="flex flex-row justify-between">
<h1 class="name">Adam French</h1>
<div class="contact-details text-right">
<p>+447563266931</p>
<p>adam.a.french@outlook.com</p>
<h4>
<a href="https://www.adam-french.co.uk">
www.adam-french.co.uk
</a>
</h4>
</div>
</div>
<h2>Profile</h2>
<p>
First Class Honours graduate in Computer Science with Mathematics
from the University of Leeds (81.1%), with a year abroad at the
University of Waterloo. Proficient in full-stack development,
systems programming, and CI/CD automation. Eager to contribute to
a collaborative engineering team, apply strong academic
foundations to real-world problems, and grow through hands-on
experience.
</p>
<h2>Skills</h2>
<div class="skills-grid">
<div><strong>Languages</strong><br /><small>Go, Rust, Python, JavaScript / TypeScript, SQL</small></div>
<div><strong>Frontend</strong><br /><small>Vue, React / Redux, Svelte, Tailwind CSS, WebAssembly</small></div>
<div><strong>Backend / Infra</strong><br /><small>Nginx, Docker, PostgreSQL, SQLite, JWT Auth, Git Actions</small></div>
</div>
<h2>Projects</h2>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
>
web_server.git
</a>
</h4>
</template>
<template v-slot:top>
<small>
Nginx, Vue, Postgres, Docker, Go, Python, Rust Wasm,
Git Actions, JWT Auth
</small>
<small>2025</small>
</template>
<p>
Self-hosted personal website with a fully automated CI/CD
pipeline. Iterated across diverse tech stacks including
Svelte, React/Redux, SQLite, Rust Actix, and Deno.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
>
tour.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust</small>
<small>2026</small>
</template>
<p>
CLI tool for building and navigating interactive code
tutorials, with version-traversal semantics inspired by Git.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git"
>
rust-raytracer.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust, Linear Algebra, Multithreading</small>
<small>2023</small>
</template>
<p>
Parallelised recursive ray tracer for realistic 3D rendering.
Emphasised algorithmic efficiency and low-level memory
management in Rust.
</p>
</Project>
<Project>
<template #left>
<h4>
<a
class="text-center w-full"
href="https://community.wolfram.com/groups/-/m/t/3210947"
>
Wolfram Summer School
</a>
</h4>
</template>
<template #top>
<small>Wolfram Mathematica</small>
<small>2024</small>
</template>
<p>
Research project on Mobile Automata with data visualisation
and academic presentation. Delivered within a tight deadline
in collaboration with academic mentors.
</p>
</Project>
<h2>Education</h2>
<div class="w-full h-fit flex-row flex gap-5">
<div class="flex-1 border-r border-dotted pr-3">
<h3>
<a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
University of Leeds
</a>
</h3>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>81.1% First Class Honours</small>
<small>20212025</small>
</div>
<small>BSc Computer Science with Mathematics (International)</small>
<ul>
<li>Algorithms & Data Structures I & II</li>
<li>Compiler Design and Construction</li>
<li>Formal Languages & Finite Automata</li>
<li>Graph Algorithms & Complexity Theory</li>
<li>Machine Learning · Databases · Computer Processors</li>
<li>Probability and Statistics I</li>
</ul>
</div>
<div class="flex-1 pl-3">
<h3>University of Waterloo</h3>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>Year abroad</small>
<small>20232024</small>
</div>
<ul>
<li>Applied Cryptography</li>
<li>Introduction to Computer Graphics</li>
<li>Introduction to Rings and Fields with Applications</li>
</ul>
</div>
</div>
</div>
<div class="no-print w-full h-20">
</div>
<div class="a4page">
<div class="flex-1 pl-3">
<h2>Experience</h2>
<Project>
<template #left>
<p>Hospitality</p>
</template>
<template #top>
<small>Cashier, Bartender, Waiter</small>
<small>20182023</small>
</template>
<p>
Worked at <em>Belgrave Music Hall</em>,
<em>The Crown and Anchor</em>, and
<em>BFI Riverfront Kitchen</em>. Developed
communication, composure under pressure, and
reliability in customer-facing roles.
</p>
</Project>
<h2>Interests</h2>
<ul>
<li>Leetcode daily competitive problem solving</li>
<li>Learning Mandarin</li>
<li>Rhythm Games</li>
<li>Climbing · Gym</li>
<li>Board games · Meetup.com</li>
</ul>
</div>
</div>
<div class="no-print w-full h-20">
</div>
</main>
</template>
<style scoped>
/* Fonts */
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
/* Variables */
* {
--primary: black;
--secondary: #0000ff;
--tertiary: #ff0000;
--quaternary: #cccccc;
--background: white;
--font-heading: big_noodle_titling;
--font-text: CreatoDisplay;
--font-size-name: 2.5em;
--font-size-text: 100%;
--font-size-small: 0.9em;
--font-size-heading: 2.1em;
--font-size-subheading: 1.7em;
--font-size-subsubheading: 1.4em;
}
/* A4 Page */
.a4page {
line-height: 1.6;
font-family: var(--font-text);
width: 210mm;
height: 297mm;
padding: 5mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid var(--primary);
overflow: hidden;
margin: auto auto;
}
/* Component Styling */
main {
padding: 0px;
display: flex;
flex-direction: column;
height: fit-content;
}
span {
height: 2em;
}
h1,
h2,
h3,
h4 {
border: none;
color: var(--primary);
font-family: var(--font-heading);
text-transform: capitalize;
}
h1 {
font-size: var(--font-size-heading);
}
h2 {
border-bottom: 1px solid var(--primary);
font-size: var(--font-size-subheading);
}
h3 {
font-size: var(--font-size-subsubheading);
}
a:hover {
color: var(--tertiary);
}
a {
background-color: transparent;
color: var(--secondary);
}
p {
margin-bottom: 0.2em;
color: var(--primary);
font-size: var(--font-size-text);
}
table {
color: var(--secondary);
border-collapse: collapse;
border: 1px solid black;
}
td {
color: var(--secondary);
border-top: 1px solid var(--tertiary);
padding: 1px 10px 1px 10px;
font-size: var(--font-size-text);
text-align: left;
}
th {
color: var(--secondary);
border: 2px solid var(--tertiary);
padding: 1px 0px 1px 7px;
font-family: var(--font-heading);
font-size: var(--font-size-subsubheading);
background-color: var(--quaternary);
text-align: left;
}
@media print {
.no-print {
display: none !important;
}
}
small {
font-size: var(--font-size-small);
color: var(--primary);
}
ul {
font-size: var(--font-size-small);
margin: 0;
padding-left: 1.2em;
}
li {
font-size: var(--font-size-small);
color: var(--primary);
}
.skills-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.3em 1em;
margin-bottom: 0.2em;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup></script>
<template>
<div class="flex-row flex">
<div class="w-2/7 p-5 m-auto">
<slot name="left" />
</div>
<div class="w-full p-2">
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<slot name="top" />
</div>
<slot />
</div>
</div>
</template>

View File

@@ -2,11 +2,13 @@
import { ref } from "vue"; import { ref } from "vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import Login from "@/components/admin/Login.vue"; import Login from "./Login.vue";
import CreateUser from "@/components/admin/CreateUser.vue"; import CreateUser from "./CreateUser.vue";
import CreatePost from "@/components/admin/CreatePost.vue"; import CreatePost from "./CreatePost.vue";
import CreateFavorite from "@/components/admin/CreateFavorite.vue"; import CreateFavorite from "./CreateFavorite.vue";
import CreateActivity from "@/components/admin/CreateActivity.vue"; import CreateActivity from "./CreateActivity.vue";
import CreateRowing from "./CreateRowing.vue";
import ManageUsers from "./ManageUsers.vue";
const auth = useAuthStore(); const auth = useAuthStore();
</script> </script>
@@ -15,12 +17,12 @@ const auth = useAuthStore();
<main class="halftone justify-center flex flex-row w-full h-full"> <main class="halftone justify-center flex flex-row w-full h-full">
<div class="a5page-portrait bdr-1 flex flex-col"> <div class="a5page-portrait bdr-1 flex flex-col">
<Login class="bdr-2 bg-bg_primary" /> <Login class="bdr-2 bg-bg_primary" />
<!-- <CreateUser class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreateUser class="bdr-2 bg-bg_primary" />
-->
<CreatePost class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" /> <CreatePost class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreateFavorite class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" /> <CreateFavorite class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreateActivity class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" /> <CreateActivity class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreateRowing class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<ManageUsers class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
</div> </div>
</main> </main>
</template> </template>

View File

@@ -0,0 +1,35 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import { gql } from "@/graphql";
const type = ref("");
const name = ref("");
const link = ref("");
async function post() {
try {
const data = await gql(
`mutation CreateActivity($input: CreateActivityInput!) { createActivity(input: $input) { id } }`,
{ input: { type: type.value, name: name.value, link: link.value || undefined } },
);
type.value = "";
name.value = "";
link.value = "";
console.log(data.createActivity);
} catch (err) {
console.error(err);
}
}
</script>
<template>
<div class="flex flex-col">
<h1>Create Activity</h1>
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" />
<input type="text" v-model="name" placeholder="Name" @keyup.enter="post" />
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
<Button @click="post">Upload</Button>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import { gql } from "@/graphql";
const type = ref("");
const name = ref("");
const link = ref("");
async function post() {
try {
const data = await gql(
`mutation CreateFavorite($input: CreateFavoriteInput!) { createFavorite(input: $input) { id } }`,
{ input: { type: type.value, name: name.value, link: link.value || undefined } },
);
type.value = "";
name.value = "";
link.value = "";
console.log(data.createFavorite);
} catch (err) {
console.error(err);
}
}
</script>
<template>
<div class="flex flex-col">
<h1>Create Favorite</h1>
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" />
<input type="text" v-model="name" placeholder="Name" @keyup.enter="post" />
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
<Button @click="post">Upload</Button>
</div>
</template>

View File

@@ -1,20 +1,20 @@
<script setup> <script setup>
import Button from "@/components/input/Button.vue"; import Button from "@/components/input/Button.vue";
import { ref } from "vue"; import { ref } from "vue";
import axios from "axios"; import { gql } from "@/graphql";
const title = ref(""); const title = ref("");
const content = ref(""); const content = ref("");
async function post() { async function post() {
try { try {
const res = await axios.post("/api/posts", { const data = await gql(
title: title.value, `mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id } }`,
content: content.value, { input: { title: title.value, content: content.value } },
}); );
title.value = ""; title.value = "";
content.value = ""; content.value = "";
console.log(res.data); console.log(data.createPost);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
@@ -24,7 +24,7 @@ async function post() {
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<h1>Create Post</h1> <h1>Create Post</h1>
<input type="text" v-model="title" placeholder="Title" /> <input type="text" v-model="title" placeholder="Title" @keyup.enter="post" />
<textarea <textarea
class="h-50" class="h-50"
v-model="content" v-model="content"

View File

@@ -0,0 +1,51 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import axios from "axios";
const images = ref([]);
const results = ref([]);
function onFileChange(e) {
images.value = Array.from(e.target.files);
results.value = [];
}
async function submit() {
if (!images.value.length) return;
results.value = images.value.map((f) => ({ name: f.name, status: "Uploading..." }));
await Promise.all(
images.value.map(async (file, i) => {
const formData = new FormData();
formData.append("image", file);
try {
const res = await axios.post("/api/rowing", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
const mins = Math.floor(res.data.Time / 1e9 / 60);
const secs = String(Math.floor((res.data.Time / 1e9) % 60)).padStart(2, "0");
results.value[i].status = `${res.data.Distance}m in ${mins}:${secs}`;
results.value[i].ok = true;
} catch (err) {
results.value[i].status = err.response?.data?.error || "Upload failed";
results.value[i].ok = false;
}
})
);
images.value = [];
}
</script>
<template>
<div class="flex flex-col gap-2">
<h1>Create Rowing</h1>
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple @change="onFileChange" />
<Button @click="submit">Upload</Button>
<div v-for="r in results" :key="r.name">
<span class="text-primary">{{ r.name }}: </span>
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{ r.status }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import { useAuthStore } from "@/stores/auth";
import { gql } from "@/graphql";
const auth = useAuthStore();
const username = ref("");
const password = ref("");
const message = ref("");
const error = ref("");
async function handleCreate() {
message.value = "";
error.value = "";
try {
const data = await gql(
`mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id username } }`,
{ input: { username: username.value, password: password.value } },
);
message.value = `User "${data.createUser.username}" created successfully.`;
username.value = "";
password.value = "";
} catch (err) {
error.value = err.message || "Failed to create user.";
}
}
</script>
<template>
<div v-if="auth.loggedIn && auth.user.admin" class="flex flex-col">
<h1>Create User</h1>
<p v-if="message" class="text-green-500">{{ message }}</p>
<p v-if="error" class="text-red-500">{{ error }}</p>
<input type="text" v-model="username" placeholder="Username" @keyup.enter="handleCreate" />
<input type="password" v-model="password" placeholder="Password" @keyup.enter="handleCreate" />
<Button @click="handleCreate">Create Account</Button>
</div>
<div v-else-if="auth.loggedIn" class="flex flex-col">
<p>You do not have permission to create users.</p>
</div>
<div v-else class="flex flex-col">
<p>You must be logged in as an admin to create users.</p>
</div>
</template>

View File

@@ -27,8 +27,8 @@ function handleLogout() {
</div> </div>
<div v-else class="flex flex-col"> <div v-else class="flex flex-col">
<h1>Login</h1> <h1>Login</h1>
<input type="text" v-model="username" placeholder="Username" /> <input type="text" v-model="username" placeholder="Username" @keyup.enter="handleLogin" />
<input type="password" v-model="password" placeholder="Password" /> <input type="password" v-model="password" placeholder="Password" @keyup.enter="handleLogin" />
<Button @click="handleLogin">Log In</Button> <Button @click="handleLogin">Log In</Button>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,45 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref, onMounted } from "vue";
import { useAuthStore } from "@/stores/auth";
import { gql } from "@/graphql";
const auth = useAuthStore();
const users = ref([]);
async function fetchUsers() {
try {
const data = await gql(`query { users { id username admin } }`);
users.value = data.users;
} catch (err) {
console.error(err);
}
}
async function toggleAdmin(user) {
try {
const data = await auth.setUserAdmin(user.id, !user.admin);
user.admin = data.admin;
} catch (err) {
console.error(err);
}
}
onMounted(fetchUsers);
</script>
<template>
<div class="flex flex-col">
<h1>Manage Users</h1>
<div v-for="user in users" :key="user.id" class="flex flex-row items-center gap-2">
<span>{{ user.username }}</span>
<span v-if="user.admin">(admin)</span>
<Button
v-if="user.id !== auth.user.id"
@click="toggleAdmin(user)"
>
{{ user.admin ? "Demote" : "Promote" }}
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useTemplateRef, ref, onMounted, onUnmounted } from 'vue';
const display = useTemplateRef('display')
const displayText = ref("");
const charHeight: number = 14;
const charWidth: number = charHeight * 0.6;
let n: number;
let m: number;
function setup() {
display.value.style.fontSize = `${charHeight}px`;
display.value.style.lineHeight = `${charHeight}px`;
fillDisplay()
}
function fillDisplay() {
// M rows N columns
m = Math.floor(display.value.offsetHeight / charHeight);
n = Math.floor(display.value.offsetWidth / charWidth);
const row = ' '.repeat(n);
displayText.value = (row + '\n').repeat(m - 1) + row
}
function close() {
displayText.value = ""
}
onMounted(() => {
setup()
})
onUnmounted(() => {
close()
})
</script>
<template>
<pre class="overflow-scroll w-full h-full bg-black text-white m-0 p-0" id="container" ref="display">{{ displayText
}}</pre>
</template>

View File

@@ -1,70 +1,15 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from "vue"; import Slideshow from "@/components/util/Slideshow.vue";
import { Transition } from "vue";
import Header from "@/components/text/Header.vue";
const images = [ const images = [
{ url: "/img/memes/pidgeon.gif", comment: "鸟" }, { url: "/img/memes/pidgeon.gif", comment: "鸟" },
//{ url: "/img/memes/no_slip.png" }, // { url: "/img/memes/no_slip.png" },
//{ url: "/img/memes/epic.jpeg" }, // { url: "/img/memes/epic.jpeg" },
{ url: "/img/bedroom/img2.png", comment: "办公桌" }, // { url: "/img/bedroom/img2.png", comment: "办公桌" },
{ url: "/img/bedroom/img1.png", comment: "床" }, // { url: "/img/bedroom/img1.png", comment: "床" },
]; ];
const currentIndex = ref(0);
const currentComment = computed(() => images[currentIndex.value].comment);
const currentUrl = computed(() => images[currentIndex.value].url);
let nextId;
function nextImage() {
clearTimeout(nextId);
let newIndex;
do {
newIndex = Math.floor(Math.random() * images.length);
} while (newIndex === currentIndex.value);
currentIndex.value = newIndex;
nextId = setTimeout(nextImage, 10000);
}
onMounted(() => {
nextId = setTimeout(nextImage, 10000);
});
onUnmounted(() => {
clearTimeout(nextId);
});
</script> </script>
<template> <template>
<Transition name="fade" mode="out-in"> <Slideshow :images="images" />
<div class="image-viewer" @click="nextImage" :key="currentIndex">
<Header v-if="currentComment">
{{ currentComment }}
</Header>
<img :src="currentUrl" alt="Image Viewer" />
</div>
</Transition>
</template> </template>
<style scoped>
.image-viewer {
width: 100%;
overflow: hidden;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -38,30 +38,40 @@ function deletePost() {
</script> </script>
<template> <template>
<div <div class="flex flex-col flex-1 min-h-0">
class="flex flex-col p-1 overflow-scroll text-left items-start justify-start"
>
<Header>{{ post.title }}</Header> <Header>{{ post.title }}</Header>
<small <div
>Created at: {{ new Date(post.createdAt).toLocaleString() }}</small class="flex flex-col flex-1 min-h-0 p-1 overflow-auto text-left items-start justify-start"
> >
<small>By: {{ post.author.username }}</small> <small
<Markdown class="flex-1 border-box text-wrap" :source="post.content" /> >Created at:
<div class="flex flex-row w-full"> {{ new Date(post.createdAt).toLocaleString() }}</small
<Button class="flex-1 border-box" v-if="!leftCap" @click="prevPost"> >
Prev <small>By: {{ post.author.username }}</small>
</Button> <Markdown
<Button class="flex-1 border-box text-wrap"
class="flex-1 border-box" :source="post.content"
v-if="!rightCap" />
@click="nextPost" <div class="flex flex-row w-full">
<Button
class="flex-1 border-box"
v-if="!leftCap"
@click="prevPost"
>
Prev
</Button>
<Button
class="flex-1 border-box"
v-if="!rightCap"
@click="nextPost"
>
Next
</Button>
</div>
<Button class="w-full" v-if="userOwnsPost" @click="deletePost"
>Delete</Button
> >
Next
</Button>
</div> </div>
<Button class="w-full" v-if="userOwnsPost" @click="deletePost"
>Delete</Button
>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,318 @@
<script setup>
import { ref, computed } from "vue";
import Header from "@/components/text/Header.vue";
import { useHomeDataStore } from "@/stores/homeData";
import { storeToRefs } from "pinia";
const store = useHomeDataStore();
const { loaded, error, rowingSessions } = storeToRefs(store);
const rows = computed(() => rowingSessions.value.slice().reverse());
const loading = computed(() => !loaded.value);
const metric = ref("distance");
const hovered = ref(null);
const METRICS = [
{ key: "distance", label: "Distance (m)", color: "#55ffbb" },
{ key: "timePer500m", label: "Pace /500m", color: "#ff579a" },
{ key: "calories", label: "Calories", color: "#62ff57" },
];
const activeMetric = computed(() =>
METRICS.find((m) => m.key === metric.value),
);
// SVG layout constants
const W = 290;
const H = 120;
const PL = 46; // padding left
const PT = 8; // padding top
const PR = 8; // padding right
const PB = 28; // padding bottom
const PLOT_W = W - PL - PR;
const PLOT_H = H - PT - PB;
const chartData = computed(() =>
rows.value.map((r) => ({
date: new Date(r.date),
value: r[metric.value],
raw: r,
})),
);
const minVal = computed(() => Math.min(...chartData.value.map((d) => d.value)));
const maxVal = computed(() => Math.max(...chartData.value.map((d) => d.value)));
const points = computed(() => {
const data = chartData.value;
const n = data.length;
if (!n) return [];
const min = minVal.value;
const range = maxVal.value - min || 1;
return data.map((d, i) => ({
x: PL + (n <= 1 ? PLOT_W / 2 : (i / (n - 1)) * PLOT_W),
y: PT + PLOT_H - ((d.value - min) / range) * PLOT_H,
date: d.date,
value: d.value,
raw: d.raw,
}));
});
const polyline = computed(() =>
points.value.map((p) => `${p.x},${p.y}`).join(" "),
);
const xLabels = computed(() => {
const data = chartData.value;
const pts = points.value;
if (!data.length) return [];
const indices = new Set([
0,
Math.floor((data.length - 1) / 2),
data.length - 1,
]);
return [...indices].map((i) => ({
x: pts[i].x,
label: data[i].date.toLocaleDateString("en-GB", {
month: "short",
day: "numeric",
}),
}));
});
const yLabels = computed(() => {
const min = minVal.value;
const max = maxVal.value;
return [0, 0.5, 1].map((t) => {
const raw = Math.round(min + t * (max - min));
return {
y: PT + PLOT_H - t * PLOT_H,
label: metric.value === "timePer500m" ? formatTime(raw) : raw,
};
});
});
function formatTime(secs) {
const m = Math.floor(secs / 60);
const s = Math.round(secs % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
function formatValue(key, val) {
if (key === "timePer500m") return formatTime(val) + " /500m";
if (key === "distance") return val + " m";
if (key === "calories") return Math.round(val) + " kcal";
return val;
}
</script>
<template>
<div class="flex flex-col h-full overflow-hidden">
<Header>Rowing</Header>
<div v-if="loading" class="flex-1 flex items-center justify-center">
<p>Loading...</p>
</div>
<div v-else-if="error" class="flex-1 flex items-center justify-center">
<p class="text-tertiary text-xs">{{ error }}</p>
</div>
<div v-else class="flex flex-col flex-1 px-1 pb-1 gap-1 overflow-hidden">
<!-- Metric tabs -->
<div class="flex gap-1 pt-1">
<button
v-for="m in METRICS"
:key="m.key"
class="metric-btn text-xs px-2 py-0.5 font-heading border"
:style="{
borderColor: m.color,
color: metric === m.key ? '#1b110e' : m.color,
backgroundColor: metric === m.key ? m.color : 'transparent',
}"
@click="metric = m.key"
>
{{ m.label }}
</button>
</div>
<!-- SVG Chart -->
<div class="flex-1 relative">
<svg
:viewBox="`0 0 ${W} ${H}`"
width="100%"
height="100%"
preserveAspectRatio="none"
class="overflow-visible"
>
<!-- Grid lines -->
<line
v-for="yl in yLabels"
:key="yl.y"
:x1="PL"
:y1="yl.y"
:x2="W - PR"
:y2="yl.y"
stroke="var(--quaternary)"
stroke-width="0.5"
/>
<!-- Area fill -->
<polygon
v-if="points.length"
:points="`${PL},${PT + PLOT_H} ${polyline} ${W - PR},${PT + PLOT_H}`"
:fill="activeMetric.color"
fill-opacity="0.08"
/>
<!-- Line -->
<polyline
v-if="points.length"
:points="polyline"
:stroke="activeMetric.color"
stroke-width="1.5"
fill="none"
stroke-linejoin="round"
stroke-linecap="round"
/>
<!-- Data points -->
<circle
v-for="(p, i) in points"
:key="i"
:cx="p.x"
:cy="p.y"
:r="hovered === i ? 4 : 2"
:fill="activeMetric.color"
style="cursor: pointer"
@mouseenter="hovered = i"
@mouseleave="hovered = null"
/>
<!-- Y axis labels -->
<text
v-for="yl in yLabels"
:key="`y${yl.y}`"
:x="PL - 3"
:y="yl.y + 3"
text-anchor="end"
font-size="10"
fill="var(--primary)"
font-family="var(--font_heading)"
>
{{ yl.label }}
</text>
<!-- X axis labels -->
<text
v-for="xl in xLabels"
:key="`x${xl.x}`"
:x="xl.x"
:y="H - 4"
text-anchor="middle"
font-size="10"
fill="var(--primary)"
font-family="var(--font_heading)"
>
{{ xl.label }}
</text>
<!-- Axes -->
<line
:x1="PL"
:y1="PT"
:x2="PL"
:y2="PT + PLOT_H"
stroke="var(--primary)"
stroke-width="0.5"
/>
<line
:x1="PL"
:y1="PT + PLOT_H"
:x2="W - PR"
:y2="PT + PLOT_H"
stroke="var(--primary)"
stroke-width="0.5"
/>
<!-- Tooltip -->
<g v-if="hovered !== null && points[hovered]">
<rect
:x="Math.min(points[hovered].x + 4, W - 85)"
:y="points[hovered].y - 20"
width="82"
height="32"
fill="var(--bg_primary)"
:stroke="activeMetric.color"
stroke-width="0.5"
rx="1"
/>
<text
:x="Math.min(points[hovered].x + 7, W - 82)"
:y="points[hovered].y - 6"
font-size="12"
fill="var(--secondary)"
font-family="var(--font_heading)"
>
{{
points[hovered].date.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "2-digit",
})
}}
</text>
<text
:x="Math.min(points[hovered].x + 7, W - 82)"
:y="points[hovered].y + 8"
font-size="14"
:fill="activeMetric.color"
font-family="var(--font_heading)"
>
{{ formatValue(metric, points[hovered].value) }}
</text>
</g>
</svg>
</div>
<!-- Summary stats -->
<div class="flex justify-between text-xs border-t border-quaternary pt-1">
<div class="flex flex-col items-center">
<span class="text-primary font-heading">{{ rows.length }}</span>
<span class="text-quaternary" style="font-size: 0.6rem"
>sessions</span
>
</div>
<div class="flex flex-col items-center">
<span class="text-primary font-heading"
>{{
rows.reduce((s, r) => s + r.distance, 0).toLocaleString()
}}m</span
>
<span class="text-quaternary" style="font-size: 0.6rem"
>total dist</span
>
</div>
<div class="flex flex-col items-center">
<span class="text-primary font-heading">{{
formatTime(
rows.reduce((s, r) => s + r.timePer500m, 0) / (rows.length || 1),
)
}}</span>
<span class="text-quaternary" style="font-size: 0.6rem"
>avg pace</span
>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.metric-btn {
cursor: pointer;
transition:
background-color 0.15s,
color 0.15s;
letter-spacing: 0.03em;
}
</style>

View File

@@ -1,28 +1,48 @@
<script setup> <script setup>
import Timer from "@/components/util/Timer.vue"; import Timer from "@/components/util/Timer.vue";
import Elle from "@/components/elle/Elle.vue";
import Time from "@/components/util/Time.vue"; import Time from "@/components/util/Time.vue";
import Radio from "@/components/util/Radio.vue";
import Elle from "@/components/elle/Elle.vue";
import Chat from "@/components/util/Chat.vue"; import Chat from "@/components/util/Chat.vue";
import MusicPlayer from "@/components/util/MusicPlayer.vue"; import MusicPlayer from "@/components/util/MusicPlayer.vue";
import CommitHistory from "@/components/util/CommitHistory.vue";
import Intro from "./Intro.vue"; import Intro from "./Intro.vue";
import Intro2 from "./Intro2.vue";
import BadApple from "./BadApple.vue";
import Miku from "./Miku.vue";
import Stamps from "./Stamps.vue"; import Stamps from "./Stamps.vue";
import Listening from "./Listening.vue"; import Listening from "./Listening.vue";
import Links from "./Links.vue"; import Links from "./Links.vue";
import Feed from "./Feed.vue"; import Feed from "./Feed.vue";
import Collage from "./Collage.vue"; import Collage from "./Collage.vue";
import Favorites from "./Favorites.vue"; import Favorites from "./Favorites.vue";
import Gym from "./Gym.vue"; // import Gym from "./Gym.vue";
import Gym2 from "./Gym2.vue";
import Consumption from "./Consumption.vue"; import Consumption from "./Consumption.vue";
import UtenaFrame from "@/components/borders/UtenaFrame.vue";
</script> </script>
<template> <template>
<main class="halftone justify-center flex flex-row w-full h-full"> <main class="halftone justify-center flex flex-row w-full h-full">
<div class="h-fit flex flex-row"> <div class="outerWrap h-fit flex flex-row">
<div class="a4page-portrait homeGrid relative bdr-1"> <div
<Intro class="intro" /> class="sidebar place-content-between flex-1 flex flex-col m-10 w-60 gap-2"
>
<div
class="flex flex-col background-children border-children gap-2"
>
<Chat class="h-306" />
</div>
<div>
<Miku class="border-tertiary border bg-bg_secondary" />
</div>
</div>
<div
class="a4page-portrait homeGrid relative background-children border-children bdr-1"
>
<!-- <Intro class="intro" /> -->
<Intro2 class="intro" />
<!-- <BadApple class="intro" /> -->
<Listening class="listening" /> <Listening class="listening" />
<Stamps class="stamps" /> <Stamps class="stamps" />
<Feed class="feed" /> <Feed class="feed" />
@@ -30,22 +50,28 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
<Collage class="collage" /> <Collage class="collage" />
<Consumption class="consumption" /> <Consumption class="consumption" />
<Favorites class="favorites" /> <Favorites class="favorites" />
<Gym class="gym" /> <!-- <Gym class="gym" /> -->
<Gym2 class="gym" />
</div> </div>
<div <div
class="sidebar border-quaternary place-content-between flex-1 flex flex-col m-10 w-60 border-2" class="sidebar place-content-between flex-1 flex flex-col m-10 w-60 gap-2"
> >
<div class="flex flex-col flex-1"> <div
<Time class="bg-bg_primary border-primary border-b" /> class="flex flex-col background-children border-children gap-2"
<Timer class="border-primary border-b bg-bg_primary" /> >
<Time />
<Timer />
<Radio />
<CommitHistory class="h-120" />
<!-- <Elle class="flex-1" /> --> <!-- <Elle class="flex-1" /> -->
<!-- <Chat class="bdr-2 bg-bg_primary" /> -->
<!-- <MusicPlayer /> --> <!-- <MusicPlayer /> -->
</div> </div>
<div> <div>
<img <img
src="/img/memes/fire-woman.gif" src="/img/memes/fire-woman.gif"
class="border-tertiary border" class="border-tertiary border"
loading="lazy"
/> />
</div> </div>
</div> </div>
@@ -54,32 +80,74 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
</template> </template>
<style scoped> <style scoped>
.homeGrid > * { .border-children > * {
border: 2px solid var(--quaternary); border: 2px solid var(--quaternary);
border-color: var(--quaternary); }
.background-children > * {
background-color: var(--bg_primary); background-color: var(--bg_primary);
} }
.homeGrid { .homeGrid {
display: grid; display: grid;
grid-gap: 5px; gap: 5px;
grid-template-columns: repeat(10, 1fr); grid-template-columns: repeat(10, 1fr);
grid-template-rows: repeat(10, 1fr); grid-template-rows: repeat(10, 1fr);
} }
@media (max-width: 850px) { @media (max-width: 1200px) {
.homeGrid { .outerWrap {
width: 100%;
display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch;
}
.homeGrid {
order: -1;
width: 100%;
height: 350mm;
margin-inline: 0;
box-sizing: border-box;
}
.sidebar {
width: 100%;
margin: 5px 10px;
flex-direction: column;
align-items: center;
gap: 8px;
} }
} }
@media (max-width: 1200px) { @media (max-width: 850px) {
.tr, .homeGrid {
.br, display: flex;
flex-direction: column;
height: auto;
}
.sidebar { .sidebar {
display: none; flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.sidebar > * {
max-width: 400px;
width: 100%;
}
}
@media (max-width: 500px) {
main {
overflow-x: hidden;
}
.outerWrap {
max-width: 100vw;
}
.sidebar {
margin: 5px 0;
} }
} }
@@ -117,6 +185,7 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
grid-column: span 4; grid-column: span 4;
grid-row: span 2; grid-row: span 2;
} }
.gym { .gym {
grid-column: span 3; grid-column: span 3;
grid-row: span 2; grid-row: span 2;
@@ -126,4 +195,10 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
grid-column: span 3; grid-column: span 3;
grid-row: span 2; grid-row: span 2;
} }
.bg-random {
background-color: var(--bg_primary);
background-image: url("/img/miku/miku2.gif");
background-size: 10px 10px;
}
</style> </style>

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