Big formatting spree
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m50s

This commit is contained in:
2026-04-29 09:06:41 +01:00
parent b41e67fe1a
commit 3844a32751
76 changed files with 3146 additions and 2788 deletions

View File

@@ -51,12 +51,12 @@ type graphMessagesResponse struct {
} }
type graphMessage struct { type graphMessage struct {
ID string `json:"id"` ID string `json:"id"`
Subject string `json:"subject"` Subject string `json:"subject"`
ReceivedDateTime string `json:"receivedDateTime"` ReceivedDateTime string `json:"receivedDateTime"`
From graphFrom `json:"from"` From graphFrom `json:"from"`
Body graphBody `json:"body"` Body graphBody `json:"body"`
BodyPreview string `json:"bodyPreview"` BodyPreview string `json:"bodyPreview"`
} }
type graphFrom struct { type graphFrom struct {

View File

@@ -41,8 +41,8 @@ var (
) )
const ( const (
rateLimitWindow = time.Second rateLimitWindow = time.Second
rateLimitMaxMsgs = 10 rateLimitMaxMsgs = 10
) )
func InitWebSocket(database *gorm.DB, domain string) { func InitWebSocket(database *gorm.DB, domain string) {

View File

@@ -1,198 +1,201 @@
networks: networks:
app-network: app-network:
driver: bridge driver: bridge
ipam: ipam:
config: config:
- subnet: 172.28.0.0/16 - subnet: 172.28.0.0/16
volumes: volumes:
dbdata: # Postgres database
uploads: dbdata:
vue_dist: # File upload
uploads:
searxng_data: # Vue build
vue_dist:
# Searxng data
searxng_data:
services: services:
vue: vue:
build: build:
context: ./vue context: ./vue
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: vue container_name: vue
volumes: volumes:
- vue_dist:/output - vue_dist:/output
networks: networks:
- app-network - app-network
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: always restart: always
depends_on: depends_on:
- vue - vue
- backend - backend
- icecast2 - icecast2
- gitea - gitea
- hasura - hasura
- quartz - quartz
- searxng - searxng
networks: networks:
- app-network - app-network
ports: ports:
- 80:80 - 80:80
- 443:443 - 443:443
volumes: volumes:
- ./certbot/conf:/etc/letsencrypt - ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot - ./certbot/www:/var/www/certbot
- uploads:/uploads - uploads:/uploads
- vue_dist:/etc/nginx/html - vue_dist:/etc/nginx/html
certbot: certbot:
image: certbot/certbot:v3.1.0 image: certbot/certbot:v3.1.0
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"]
env_file: env_file:
- .env - .env
networks: networks:
- app-network - app-network
backend: backend:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: "${BACKEND_HOST}" container_name: "${BACKEND_HOST}"
restart: always 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 - uploads:/backend/uploads
- ./icecast2/fallback_music:/backend/fallback_music - ./icecast2/fallback_music:/backend/fallback_music
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/"] test: ["CMD", "wget", "-qO-", "http://localhost:8080/"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10s start_period: 10s
autoheal: autoheal:
image: willfarrell/autoheal:latest image: willfarrell/autoheal:latest
container_name: autoheal container_name: autoheal
restart: always restart: always
environment: environment:
- AUTOHEAL_CONTAINER_LABEL=all - AUTOHEAL_CONTAINER_LABEL=all
- AUTOHEAL_INTERVAL=30 - AUTOHEAL_INTERVAL=30
- AUTOHEAL_START_PERIOD=60 - AUTOHEAL_START_PERIOD=60
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
db: db:
image: postgres:16 image: postgres:16
container_name: "${POSTGRES_HOST}" container_name: "${POSTGRES_HOST}"
restart: always 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
hasura: hasura:
image: hasura/graphql-engine:v2.44.0 image: hasura/graphql-engine:v2.44.0
container_name: "${HASURA_HOST}" container_name: "${HASURA_HOST}"
restart: always restart: always
depends_on: depends_on:
- db - db
networks: networks:
- app-network - app-network
environment: environment:
HASURA_GRAPHQL_DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" HASURA_GRAPHQL_DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
HASURA_GRAPHQL_ADMIN_SECRET: "${HASURA_GRAPHQL_ADMIN_SECRET}" HASURA_GRAPHQL_ADMIN_SECRET: "${HASURA_GRAPHQL_ADMIN_SECRET}"
HASURA_GRAPHQL_ENABLE_CONSOLE: "false" HASURA_GRAPHQL_ENABLE_CONSOLE: "false"
HASURA_GRAPHQL_DEV_MODE: "false" HASURA_GRAPHQL_DEV_MODE: "false"
HASURA_GRAPHQL_ENABLED_LOG_TYPES: "startup, http-log, webhook-log, websocket-log, query-log" HASURA_GRAPHQL_ENABLED_LOG_TYPES: "startup, http-log, webhook-log, websocket-log, query-log"
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
volumes: volumes:
- ./icecast2/fallback_music:/music:ro - ./icecast2/fallback_music:/music:ro
ports: ports:
- "${LIQUIDSOAP_HARBOR_PORT:-8001}:${LIQUIDSOAP_HARBOR_PORT:-8001}" - "${LIQUIDSOAP_HARBOR_PORT:-8001}:${LIQUIDSOAP_HARBOR_PORT:-8001}"
quartz: quartz:
build: build:
context: ./quartz context: ./quartz
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: "${QUARTZ_HOST}" container_name: "${QUARTZ_HOST}"
restart: always restart: always
networks: networks:
- app-network - app-network
env_file: env_file:
- ./.env - ./.env
volumes: volumes:
- ${OBSIDIAN_DIR}:/quartz/content:ro - ${OBSIDIAN_DIR}:/quartz/content:ro
searxng: searxng:
build: build:
context: ./searxng context: ./searxng
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: "${SEARXNG_HOST}" container_name: "${SEARXNG_HOST}"
restart: unless-stopped restart: unless-stopped
networks: networks:
- app-network - app-network
environment: environment:
- BASE_URL=https://www.${DOMAIN}/searxng/ - BASE_URL=https://www.${DOMAIN}/searxng/
- INSTANCE_NAME=searxng - INSTANCE_NAME=searxng
- SEARXNG_SECRET_KEY=${SEARXNG_SECRET_KEY} - SEARXNG_SECRET_KEY=${SEARXNG_SECRET_KEY}
volumes: volumes:
- searxng_data:/etc/searxng - searxng_data:/etc/searxng
gitea: gitea:
image: docker.gitea.com/gitea:1.25.4-rootless image: docker.gitea.com/gitea:1.25.4-rootless
container_name: "${GITEA_HOST}" container_name: "${GITEA_HOST}"
entrypoint: ["/usr/bin/dumb-init", "--", "/etc/gitea/entrypoint.sh"] entrypoint: ["/usr/bin/dumb-init", "--", "/etc/gitea/entrypoint.sh"]
networks: networks:
- app-network - app-network
environment: environment:
- GITEA__database__DB_TYPE=postgres - GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=${POSTGRES_HOST} - GITEA__database__HOST=${POSTGRES_HOST}
- GITEA__database__NAME=${POSTGRES_GITEA_DB} - GITEA__database__NAME=${POSTGRES_GITEA_DB}
- GITEA__database__USER=${POSTGRES_USER} - GITEA__database__USER=${POSTGRES_USER}
- GITEA__database__PASSWD=${POSTGRES_PASSWORD} - GITEA__database__PASSWD=${POSTGRES_PASSWORD}
- GITEA__server__LFS_JWT_SECRET=${GITEA_LFS_JWT_SECRET} - GITEA__server__LFS_JWT_SECRET=${GITEA_LFS_JWT_SECRET}
- GITEA__security__INTERNAL_TOKEN=${GITEA_INTERNAL_TOKEN} - GITEA__security__INTERNAL_TOKEN=${GITEA_INTERNAL_TOKEN}
- GITEA__oauth2__JWT_SECRET=${GITEA_OAUTH2_JWT_SECRET} - GITEA__oauth2__JWT_SECRET=${GITEA_OAUTH2_JWT_SECRET}
- USER_UID=1000 - USER_UID=1000
- USER_GID=1000 - USER_GID=1000
restart: always restart: always
volumes: volumes:
- ./gitea/data:/var/lib/gitea - ./gitea/data:/var/lib/gitea
- ./gitea/config:/etc/gitea - ./gitea/config:/etc/gitea
- ./gitea/entrypoint.sh:/etc/gitea/entrypoint.sh:ro - ./gitea/entrypoint.sh:/etc/gitea/entrypoint.sh:ro
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
ports: ports:
- "2222:2222" - "2222:2222"
- "3000:3000" - "3000:3000"
depends_on: depends_on:
- db - db

View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Check if dev mode, certificate exists, or setup mode # Check if DEV_MODE
if [ "$DEV_MODE" = "true" ]; then if [ "$DEV_MODE" = "true" ]; then
echo "Dev mode. Generating self-signed certificate for HTTPS." echo "Dev mode. Generating self-signed certificate for HTTPS."
CERT_DIR="/etc/letsencrypt/live/localhost" CERT_DIR="/etc/letsencrypt/live/localhost"
@@ -12,16 +12,19 @@ if [ "$DEV_MODE" = "true" ]; then
-out "$CERT_DIR/fullchain.pem" \ -out "$CERT_DIR/fullchain.pem" \
-subj "/CN=localhost" 2>/dev/null -subj "/CN=localhost" 2>/dev/null
fi fi
# In dev mode, so use nginx_dev.conf.template
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT}' \ envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT}' \
</etc/nginx/nginx_dev.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 elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
echo "Certificates found. Using production nginx config." echo "Certificates found. Using production nginx config."
# In production with certificates already existing, so use nginx.conf.template
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT}' \ envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT}' \
</etc/nginx/nginx.conf.template \ </etc/nginx/nginx.conf.template \
>/etc/nginx/nginx.conf >/etc/nginx/nginx.conf
else else
echo "Certificates NOT found. Using setup nginx config." echo "Certificates NOT found. Using setup nginx config."
# In production with no certificates, so use nginx_setup.conf.template and will need restart after generation
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

View File

@@ -1,33 +1,33 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Adam French's personal website"> <meta name="description" content="Adam French's personal website" />
<title>AF</title> <title>AF</title>
<link rel="preconnect" href="https://i.scdn.co" crossorigin> <link rel="preconnect" href="https://i.scdn.co" crossorigin />
<link <link
rel="preconnect" rel="preconnect"
href="https://cdn.akamai.steamstatic.com" href="https://cdn.akamai.steamstatic.com"
crossorigin crossorigin
> />
<link rel="icon" type="/img/x-icon" href="/img/favicon.ico"> <link rel="icon" type="/img/x-icon" href="/img/favicon.ico" />
<link <link
rel="preload" rel="preload"
href="/fonts/big_noodle_titling.woff2" href="/fonts/big_noodle_titling.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin crossorigin
> />
<link <link
rel="preload" rel="preload"
href="/fonts/CreatoDisplay-Bold.woff2" href="/fonts/CreatoDisplay-Bold.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin crossorigin
> />
</head> </head>
<body id="app"> <body id="app">
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -3,5 +3,5 @@ import { RouterView } from "vue-router";
</script> </script>
<template> <template>
<RouterView /> <RouterView />
</template> </template>

View File

@@ -5,128 +5,128 @@ const clock = ref("");
let timer; let timer;
function updateClock() { function updateClock() {
const now = new Date(); const now = new Date();
clock.value = now.toLocaleDateString("en-US", { clock.value = now.toLocaleDateString("en-US", {
weekday: "short", weekday: "short",
month: "short", month: "short",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
} }
onMounted(() => { onMounted(() => {
updateClock(); updateClock();
timer = setInterval(updateClock, 1000); timer = setInterval(updateClock, 1000);
}); });
onUnmounted(() => { onUnmounted(() => {
clearInterval(timer); clearInterval(timer);
}); });
const user = "visitor"; const user = "visitor";
</script> </script>
<template> <template>
<footer class="waybar"> <footer class="waybar">
<div class="modules-left"> <div class="modules-left">
<span class="workspace active"></span> <span class="workspace active"></span>
</div> </div>
<div class="modules-right"> <div class="modules-right">
<span class="module greeting">Hi, {{ user }}!</span> <span class="module greeting">Hi, {{ user }}!</span>
<span class="module cpu hide-sm">CPU 3%</span> <span class="module cpu hide-sm">CPU 3%</span>
<span class="module mem hide-sm">MEM 42%</span> <span class="module mem hide-sm">MEM 42%</span>
<span class="module disk hide-sm">DISK 67%</span> <span class="module disk hide-sm">DISK 67%</span>
<span class="module network hide-sm"> 12K 84K</span> <span class="module network hide-sm"> 12K 84K</span>
<span class="module battery hide-sm">BAT 98%</span> <span class="module battery hide-sm">BAT 98%</span>
<span class="module clock">{{ clock }}</span> <span class="module clock">{{ clock }}</span>
</div> </div>
</footer> </footer>
</template> </template>
<style scoped> <style scoped>
.waybar { .waybar {
font-family: "URWGothic-Book", monospace; font-family: "URWGothic-Book", monospace;
background-color: var(--bg_primary); background-color: var(--bg_primary);
color: var(--primary); color: var(--primary);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 4px 8px; padding: 4px 8px;
font-size: 14px; font-size: 14px;
min-height: 36px; min-height: 36px;
flex-shrink: 0; flex-shrink: 0;
} }
.modules-left { .modules-left {
display: flex; display: flex;
gap: 2px; gap: 2px;
} }
.workspace { .workspace {
background: var(--quaternary); background: var(--quaternary);
border: none; border: none;
border-bottom: 2px solid var(--secondary); border-bottom: 2px solid var(--secondary);
color: var(--secondary); color: var(--secondary);
padding: 2px 10px; padding: 2px 10px;
font-family: inherit; font-family: inherit;
font-size: 14px; font-size: 14px;
} }
.modules-right { .modules-right {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.module { .module {
padding: 2px 12px; padding: 2px 12px;
border-left: 1px solid var(--tertiary); border-left: 1px solid var(--tertiary);
} }
.module:first-child { .module:first-child {
border-left: none; border-left: none;
} }
.greeting { .greeting {
color: var(--secondary); color: var(--secondary);
} }
.clock { .clock {
color: var(--tertiary); color: var(--tertiary);
} }
.cpu, .cpu,
.mem, .mem,
.disk { .disk {
color: var(--primary); color: var(--primary);
} }
.network { .network {
color: var(--secondary); color: var(--secondary);
} }
.battery { .battery {
color: var(--primary); color: var(--primary);
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.waybar { .waybar {
font-size: 11px; font-size: 11px;
padding: 4px 4px; padding: 4px 4px;
} }
.workspace { .workspace {
padding: 2px 6px; padding: 2px 6px;
font-size: 11px; font-size: 11px;
} }
.module { .module {
padding: 2px 6px; padding: 2px 6px;
} }
.hide-sm { .hide-sm {
display: none; display: none;
} }
} }
</style> </style>

View File

@@ -6,55 +6,55 @@ import { useRoute } from "vue-router";
const route = useRoute(); const route = useRoute();
const parentPath = computed(() => { const parentPath = computed(() => {
const segments = route.path.split("/").filter(Boolean); const segments = route.path.split("/").filter(Boolean);
if (segments.length == 1) { if (segments.length == 1) {
return "/"; return "/";
} else { } else {
segments.pop(); segments.pop();
return segments.length ? "/" + segments.join("/") : null; return segments.length ? "/" + segments.join("/") : null;
} }
}); });
const faces = [ const faces = [
"^_^", "^_^",
"¯\\_(ツ)_/¯", "¯\\_(ツ)_/¯",
"(◕‿◕✿)", "(◕‿◕✿)",
"ಠ_ಠ", "ಠ_ಠ",
"ʘ‿ʘ", "ʘ‿ʘ",
"^̮^", "^̮^",
">_>", ">_>",
"¬_¬", "¬_¬",
"˙ ͜ʟ˙", "˙ ͜ʟ˙",
"( ͡° ͜ʖ ͡°)", "( ͡° ͜ʖ ͡°)",
"[̲̅$̲̅(̲̅5̲̅)̲̅$̲̅]", "[̲̅$̲̅(̲̅5̲̅)̲̅$̲̅]",
"(ง'̀-'́)ง", "(ง'̀-'́)ง",
"\ (•◡•) /", "\ (•◡•) /",
"( ͡ᵔ ͜ʖ ͡ᵔ )", "( ͡ᵔ ͜ʖ ͡ᵔ )",
"ᕙ(⇀‸↼‶)ᕗ", "ᕙ(⇀‸↼‶)ᕗ",
"⚆ _ ⚆", "⚆ _ ⚆",
"(。◕‿◕。)", "(。◕‿◕。)",
"(╯°□°)╯︵ ʞooqǝɔɐɟ", "(╯°□°)╯︵ ʞooqǝɔɐɟ",
"̿ ̿ ̿'̿'\̵͇̿̿\з=(•_•)=ε/̵͇̿̿/'̿'̿ ̿", "̿ ̿ ̿'̿'\̵͇̿̿\з=(•_•)=ε/̵͇̿̿/'̿'̿ ̿",
"(☞゚ヮ゚)☞ ☜(゚ヮ゚☜)", "(☞゚ヮ゚)☞ ☜(゚ヮ゚☜)",
]; ];
const faces_string = faces.join(" "); const faces_string = faces.join(" ");
</script> </script>
<template> <template>
<nav class="flex flex-row w-full h-fit border border-primary bg-bg_primary"> <nav class="flex flex-row w-full h-fit border border-primary bg-bg_primary">
<RouterLink class="bdr-2 bg-bg_primary" v-if="parentPath" :to="parentPath"> <RouterLink class="bdr-2 bg-bg_primary" v-if="parentPath" :to="parentPath">
<span>UP</span> <span>UP</span>
</RouterLink> </RouterLink>
<Headline class="border flex-1 max-w-full"> <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>
</template> </template>
<style scoped> <style scoped>
.left { .left {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
} }
</style> </style>

View File

@@ -1,57 +1,57 @@
<template> <template>
<div class="container"> <div class="container">
<img src="/img/borders/utena.png" class="flower tl antirotate" /> <img src="/img/borders/utena.png" class="flower tl antirotate" />
<img src="/img/borders/utena.png" class="flower tr rotate" /> <img src="/img/borders/utena.png" class="flower tr rotate" />
<img src="/img/borders/utena.png" class="flower bl rotate" /> <img src="/img/borders/utena.png" class="flower bl rotate" />
<img src="/img/borders/utena.png" class="flower br antirotate" /> <img src="/img/borders/utena.png" class="flower br antirotate" />
<slot /> <slot />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.container { .container {
position: relative; position: relative;
margin: 100px; margin: 100px;
} }
.flower { .flower {
position: absolute; position: absolute;
width: 150px; width: 150px;
} }
.tl { .tl {
top: -80px; top: -80px;
left: -80px; left: -80px;
--start: 0deg; --start: 0deg;
} }
.tr { .tr {
top: -80px; top: -80px;
right: -80px; right: -80px;
--start: 90deg; --start: 90deg;
} }
.bl { .bl {
bottom: -80px; bottom: -80px;
left: -80px; left: -80px;
--start: 180deg; --start: 180deg;
} }
.br { .br {
bottom: -80px; bottom: -80px;
right: -80px; right: -80px;
--start: 270deg; --start: 270deg;
} }
.rotate { .rotate {
animation: spin 3s linear infinite; animation: spin 3s linear infinite;
} }
.antirotate { .antirotate {
animation: spin 3s linear infinite reverse; animation: spin 3s linear infinite reverse;
} }
@keyframes spin { @keyframes spin {
from { from {
transform: rotate(var(--start)); transform: rotate(var(--start));
} }
to { to {
transform: rotate(calc(var(--start) + 360deg)); transform: rotate(calc(var(--start) + 360deg));
} }
} }
</style> </style>

View File

@@ -6,11 +6,11 @@ const container = ref(null);
// List of (offset, width) // List of (offset, width)
function generateOffsets(width = 100, step = 10, n = 20) { function generateOffsets(width = 100, step = 10, n = 20) {
return Array.from({ length: n }, (_, i) => ({ return Array.from({ length: n }, (_, i) => ({
width, width,
offset: step * i, offset: step * i,
color: getRandomColor(), color: getRandomColor(),
})); }));
} }
const offsets = ref(generateOffsets((150, 15, 10))); const offsets = ref(generateOffsets((150, 15, 10)));
let rafId; let rafId;
@@ -18,71 +18,71 @@ let rafId;
const speed = 0.5; // pixels per frame const speed = 0.5; // pixels per frame
function animate() { function animate() {
const ctnr = container.value; const ctnr = container.value;
for (const item of offsets.value) { for (const item of offsets.value) {
const width = Math.max(ctnr.offsetWidth, item.width); const width = Math.max(ctnr.offsetWidth, item.width);
console.log(ctnr.offsetWidth); console.log(ctnr.offsetWidth);
item.offset -= speed; item.offset -= speed;
if (item.offset <= -width) { if (item.offset <= -width) {
item.color = getRandomColor(); item.color = getRandomColor();
item.offset = 0; item.offset = 0;
}
} }
rafId = requestAnimationFrame(animate); }
rafId = requestAnimationFrame(animate);
} }
onMounted(() => { onMounted(() => {
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
}); });
onUnmounted(() => { onUnmounted(() => {
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
}); });
</script> </script>
<template> <template>
<div class="bg-primary container" ref="container"> <div class="bg-primary container" ref="container">
<div :key="index" v-for="(item, index) in offsets"> <div :key="index" v-for="(item, index) in offsets">
<div <div
:style="{ :style="{
width: item.width + 'px', width: item.width + 'px',
translate: item.offset + 'px', translate: item.offset + 'px',
backgroundColor: item.color, backgroundColor: item.color,
}" }"
class="item item1" class="item item1"
/> />
<div <div
:style="{ :style="{
width: item.width + 'px', width: item.width + 'px',
right: -item.width + 'px', right: -item.width + 'px',
translate: item.offset + 'px', translate: item.offset + 'px',
backgroundColor: item.color, backgroundColor: item.color,
}" }"
class="item item2" class="item item2"
/> />
</div>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.container { .container {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
will-change: transform; will-change: transform;
} }
.item { .item {
opacity: 40%; opacity: 40%;
height: 100%; height: 100%;
position: absolute; position: absolute;
} }
.item1 { .item1 {
left: 0px; left: 0px;
top: 0px; top: 0px;
} }
.item2 { .item2 {
top: 0px; top: 0px;
} }
</style> </style>

View File

@@ -1,11 +1,11 @@
<script setup></script> <script setup></script>
<template> <template>
<button <button
class="text-primary bg-link text-center border cursor-pointer transition-colors duration-150 ease-in-out hover:bg-bg_primary active:scale-95" class="text-primary bg-link text-center border cursor-pointer transition-colors duration-150 ease-in-out hover:bg-bg_primary active:scale-95"
> >
<slot /> <slot />
</button> </button>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -2,30 +2,30 @@
import { ref } from "vue"; import { ref } from "vue";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}); });
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);
function toggle() { function toggle() {
emit("update:modelValue", !props.modelValue); emit("update:modelValue", !props.modelValue);
} }
</script> </script>
<template> <template>
<button <button
@click="toggle" @click="toggle"
class="box-content border-2 border-primary w-10 h-fit rounded-full cursor-pointer" class="box-content border-2 border-primary w-10 h-fit rounded-full cursor-pointer"
:class="[props.modelValue ? 'bg-bg_secondary' : 'bg-bg_primary']" :class="[props.modelValue ? 'bg-bg_secondary' : 'bg-bg_primary']"
>
<svg
viewBox="0 0 40 40"
class="w-5 h-5 transition-all duration-300 ease-in-out"
:class="[props.modelValue ? 'ml-5' : 'ml-0']"
> >
<svg <circle class="fill-primary" cx="20" cy="20" r="20" />
viewBox="0 0 40 40" </svg>
class="w-5 h-5 transition-all duration-300 ease-in-out" </button>
:class="[props.modelValue ? 'ml-5' : 'ml-0']"
>
<circle class="fill-primary" cx="20" cy="20" r="20" />
</svg>
</button>
</template> </template>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full border-b border-primary pb-0.5"> <div class="w-full border-b border-primary pb-0.5">
<h1 class="p-0 m-0"> <h1 class="p-0 m-0">
<slot /> <slot />
</h1> </h1>
</div> </div>
</template> </template>

View File

@@ -12,74 +12,74 @@ let rafId;
const speed = 0.5; // pixels per frame const speed = 0.5; // pixels per frame
function measureWidth() { function measureWidth() {
const ctnr = container.value; const ctnr = container.value;
const it1 = item1.value; const it1 = item1.value;
if (ctnr && it1) { if (ctnr && it1) {
cachedWidth = Math.max(ctnr.offsetWidth, it1.scrollWidth); cachedWidth = Math.max(ctnr.offsetWidth, it1.scrollWidth);
} }
} }
function animate() { function animate() {
const ctnr = container.value; const ctnr = container.value;
if (!ctnr || cachedWidth === 0) { if (!ctnr || cachedWidth === 0) {
rafId = requestAnimationFrame(animate);
return;
}
offset -= speed;
if (offset <= -cachedWidth) {
offset += cachedWidth;
}
ctnr.style.transform = `translateX(${offset}px)`;
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
return;
}
offset -= speed;
if (offset <= -cachedWidth) {
offset += cachedWidth;
}
ctnr.style.transform = `translateX(${offset}px)`;
rafId = requestAnimationFrame(animate);
} }
let resizeObserver; let resizeObserver;
onMounted(() => { onMounted(() => {
measureWidth(); measureWidth();
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
resizeObserver = new ResizeObserver(measureWidth); resizeObserver = new ResizeObserver(measureWidth);
resizeObserver.observe(container.value); resizeObserver.observe(container.value);
}); });
onUnmounted(() => { onUnmounted(() => {
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
resizeObserver?.disconnect(); resizeObserver?.disconnect();
}); });
</script> </script>
<template> <template>
<div class="root"> <div class="root">
<div class="container" ref="container"> <div class="container" ref="container">
<div ref="item1"> <div ref="item1">
<slot /> <slot />
</div> </div>
<div> <div>
<slot /> <slot />
</div> </div>
</div>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.root { .root {
overflow: hidden; overflow: hidden;
} }
.container { .container {
width: fit-content; width: fit-content;
height: fit-content; height: fit-content;
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
grid-auto-columns: max-content; grid-auto-columns: max-content;
/* Each column fits its content */ /* Each column fits its content */
overflow-x: visible; overflow-x: visible;
will-change: transform; will-change: transform;
gap: 10em; gap: 10em;
} }
</style> </style>

View File

@@ -2,38 +2,44 @@
import { computed } from "vue"; import { computed } from "vue";
const props = defineProps({ const props = defineProps({
href: { type: String, default: "" }, href: { type: String, default: "" },
to: { type: String, default: "" }, to: { type: String, default: "" },
target: { type: String, default: undefined }, target: { type: String, default: undefined },
rel: { type: String, default: undefined }, rel: { type: String, default: undefined },
}); });
const computedRel = computed(() => { const computedRel = computed(() => {
if (props.rel !== undefined) return props.rel; if (props.rel !== undefined) return props.rel;
if (props.target === "_blank") return "noopener noreferrer"; if (props.target === "_blank") return "noopener noreferrer";
return undefined; return undefined;
}); });
</script> </script>
<template> <template>
<RouterLink v-if="to" :to="to" class="inline-link"> <RouterLink v-if="to" :to="to" class="inline-link">
<slot /> <slot />
</RouterLink> </RouterLink>
<a v-else :href="href" :target="target" :rel="computedRel" class="inline-link"> <a
<slot /> v-else
</a> :href="href"
:target="target"
:rel="computedRel"
class="inline-link"
>
<slot />
</a>
</template> </template>
<style scoped> <style scoped>
.inline-link { .inline-link {
color: var(--primary); color: var(--primary);
font-weight: bold; font-weight: bold;
font-style: italic; font-style: italic;
text-decoration: none; text-decoration: none;
transition: color 0.15s ease; transition: color 0.15s ease;
} }
.inline-link:hover { .inline-link:hover {
color: var(--tertiary); color: var(--tertiary);
} }
</style> </style>

View File

@@ -2,37 +2,43 @@
import { computed } from "vue"; import { computed } from "vue";
const props = defineProps({ const props = defineProps({
href: { type: String, default: "" }, href: { type: String, default: "" },
to: { type: String, default: "" }, to: { type: String, default: "" },
target: { type: String, default: undefined }, target: { type: String, default: undefined },
rel: { type: String, default: undefined }, rel: { type: String, default: undefined },
bare: { type: Boolean, default: false }, bare: { type: Boolean, default: false },
}); });
const computedRel = computed(() => { const computedRel = computed(() => {
if (props.rel !== undefined) return props.rel; if (props.rel !== undefined) return props.rel;
if (props.target === "_blank") return "noopener noreferrer"; if (props.target === "_blank") return "noopener noreferrer";
return undefined; return undefined;
}); });
</script> </script>
<template> <template>
<RouterLink v-if="to" :to="to" :class="{ link: !bare }"> <RouterLink v-if="to" :to="to" :class="{ link: !bare }">
<slot /> <slot />
</RouterLink> </RouterLink>
<a v-else :href="href" :target="target" :rel="computedRel" :class="{ link: !bare }"> <a
<slot /> v-else
</a> :href="href"
:target="target"
:rel="computedRel"
:class="{ link: !bare }"
>
<slot />
</a>
</template> </template>
<style scoped> <style scoped>
.link { .link {
color: var(--primary); color: var(--primary);
text-decoration: none; text-decoration: none;
transition: color 0.15s ease; transition: color 0.15s ease;
} }
.link:hover { .link:hover {
color: var(--tertiary); color: var(--tertiary);
} }
</style> </style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<p class="p-1"> <p class="p-1">
<slot /> <slot />
</p> </p>
</template> </template>

View File

@@ -3,36 +3,36 @@ import { ref } from "vue";
import ToggleButton from "@/components/input/ToggleButton.vue"; import ToggleButton from "@/components/input/ToggleButton.vue";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}); });
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);
const toggleButtonRef = ref(null); const toggleButtonRef = ref(null);
const updateValue = (newValue) => { const updateValue = (newValue) => {
emit("update:modelValue", newValue); emit("update:modelValue", newValue);
}; };
const handleClick = () => { const handleClick = () => {
toggleButtonRef.value?.$el?.click(); toggleButtonRef.value?.$el?.click();
}; };
</script> </script>
<template> <template>
<div <div
class="w-full border-b border-primary cursor-pointer" class="w-full border-b border-primary cursor-pointer"
@click="handleClick" @click="handleClick"
> >
<h3 class="pl-2 m-0"> <h3 class="pl-2 m-0">
<slot /> <slot />
</h3> </h3>
<ToggleButton <ToggleButton
class="pointer-events-none" class="pointer-events-none"
:model-value="props.modelValue" :model-value="props.modelValue"
@update:model-value="updateValue" @update:model-value="updateValue"
@click.stop @click.stop
ref="toggleButtonRef" ref="toggleButtonRef"
/> />
</div> </div>
</template> </template>

View File

@@ -1,7 +1,12 @@
<template> <template>
<div ref="container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" class="overflow-y-auto"> <div
<slot /> ref="container"
</div> @mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
class="overflow-y-auto"
>
<slot />
</div>
</template> </template>
<script setup> <script setup>
@@ -20,89 +25,89 @@ let pauseTimeoutId = null;
let cachedScrollHeight = 0; let cachedScrollHeight = 0;
function measureScrollHeight() { function measureScrollHeight() {
const el = container.value; const el = container.value;
if (el) cachedScrollHeight = el.scrollHeight; if (el) cachedScrollHeight = el.scrollHeight;
} }
function stopLoop() { function stopLoop() {
if (rafId !== null) { if (rafId !== null) {
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
rafId = null; rafId = null;
} }
if (pauseTimeoutId !== null) { if (pauseTimeoutId !== null) {
clearTimeout(pauseTimeoutId); clearTimeout(pauseTimeoutId);
pauseTimeoutId = null; pauseTimeoutId = null;
} }
} }
function startLoop() { function startLoop() {
stopLoop(); stopLoop();
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
} }
function onMouseEnter() { function onMouseEnter() {
hovered = true; hovered = true;
stopLoop(); stopLoop();
} }
function onMouseLeave() { function onMouseLeave() {
hovered = false; hovered = false;
const el = container.value; const el = container.value;
if (el && cachedScrollHeight > 0) { if (el && cachedScrollHeight > 0) {
pos = el.scrollTop / cachedScrollHeight; pos = el.scrollTop / cachedScrollHeight;
} }
startLoop(); startLoop();
} }
function schedulePause(callback) { function schedulePause(callback) {
stopLoop(); stopLoop();
pauseTimeoutId = setTimeout(callback, PAUSE); pauseTimeoutId = setTimeout(callback, PAUSE);
} }
function tick() { function tick() {
rafId = null; rafId = null;
const el = container.value; const el = container.value;
if (hovered) return; if (hovered) return;
if (!el || cachedScrollHeight === 0) {
rafId = requestAnimationFrame(tick);
return;
}
const reachedBottom = pos >= 1;
const reachedTop = pos <= 0;
if (reachedBottom) {
pos = 0.999;
direction = -1;
schedulePause(startLoop);
return;
} else if (reachedTop && direction === -1) {
pos = 0.001;
direction = 1;
schedulePause(startLoop);
return;
}
pos += direction * SPEED;
el.scrollTop = pos * cachedScrollHeight;
if (!el || cachedScrollHeight === 0) {
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
return;
}
const reachedBottom = pos >= 1;
const reachedTop = pos <= 0;
if (reachedBottom) {
pos = 0.999;
direction = -1;
schedulePause(startLoop);
return;
} else if (reachedTop && direction === -1) {
pos = 0.001;
direction = 1;
schedulePause(startLoop);
return;
}
pos += direction * SPEED;
el.scrollTop = pos * cachedScrollHeight;
rafId = requestAnimationFrame(tick);
} }
let resizeObserver; let resizeObserver;
onMounted(() => { onMounted(() => {
measureScrollHeight(); measureScrollHeight();
schedulePause(startLoop); schedulePause(startLoop);
resizeObserver = new ResizeObserver(measureScrollHeight); resizeObserver = new ResizeObserver(measureScrollHeight);
resizeObserver.observe(container.value); resizeObserver.observe(container.value);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopLoop(); stopLoop();
resizeObserver?.disconnect(); resizeObserver?.disconnect();
}); });
</script> </script>

View File

@@ -19,210 +19,205 @@ const SCROLL_THRESHOLD = 100;
let resizeObserver = null; let resizeObserver = null;
function scrollToBottom() { function scrollToBottom() {
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
messagesContainer.value.scrollHeight; }
}
} }
function scrollToBottomIfNear() { function scrollToBottomIfNear() {
if (isNearBottom.value) { if (isNearBottom.value) {
scrollToBottom(); scrollToBottom();
} }
} }
function onScroll() { function onScroll() {
if (!messagesContainer.value) return; if (!messagesContainer.value) return;
const { scrollHeight, scrollTop, clientHeight } = messagesContainer.value; const { scrollHeight, scrollTop, clientHeight } = messagesContainer.value;
isNearBottom.value = isNearBottom.value =
scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD; scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
} }
function goToBottom() { function goToBottom() {
isNearBottom.value = true; isNearBottom.value = true;
scrollToBottom(); scrollToBottom();
} }
watch( watch(
() => messages.value.length, () => messages.value.length,
() => { () => {
nextTick(scrollToBottomIfNear); nextTick(scrollToBottomIfNear);
}, },
); );
function sendMessage() { function sendMessage() {
const text = messageInput.value.trim(); const text = messageInput.value.trim();
if (!text) return; if (!text) return;
isNearBottom.value = true; isNearBottom.value = true;
messagesStore.sendMessage(text); messagesStore.sendMessage(text);
messageInput.value = ""; messageInput.value = "";
} }
async function onFileSelected(e) { async function onFileSelected(e) {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
isNearBottom.value = true; isNearBottom.value = true;
await messagesStore.uploadAndSendFile(file); await messagesStore.uploadAndSendFile(file);
fileInput.value.value = ""; fileInput.value.value = "";
} }
function isImageUrl(url) { function isImageUrl(url) {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url); return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
} }
function isVideoUrl(url) { function isVideoUrl(url) {
return /\.(mp4|webm|ogg|mov)$/i.test(url); return /\.(mp4|webm|ogg|mov)$/i.test(url);
} }
function isSafeFileUrl(url) { function isSafeFileUrl(url) {
return typeof url === "string" && url.startsWith("/uploads/"); return typeof url === "string" && url.startsWith("/uploads/");
} }
const urlRegex = /(https?:\/\/[^\s<]+)/g; const urlRegex = /(https?:\/\/[^\s<]+)/g;
function parseMessageParts(text) { function parseMessageParts(text) {
const parts = []; const parts = [];
let lastIndex = 0; let lastIndex = 0;
let match; let match;
while ((match = urlRegex.exec(text)) !== null) { while ((match = urlRegex.exec(text)) !== null) {
if (match.index > lastIndex) { if (match.index > lastIndex) {
parts.push({ parts.push({
type: "text", type: "text",
value: text.slice(lastIndex, match.index), value: text.slice(lastIndex, match.index),
}); });
}
parts.push({ type: "link", value: match[1] });
lastIndex = urlRegex.lastIndex;
} }
if (lastIndex < text.length) { parts.push({ type: "link", value: match[1] });
parts.push({ type: "text", value: text.slice(lastIndex) }); lastIndex = urlRegex.lastIndex;
} }
return parts; if (lastIndex < text.length) {
parts.push({ type: "text", value: text.slice(lastIndex) });
}
return parts;
} }
onMounted(() => { onMounted(() => {
messagesStore.connect(); messagesStore.connect();
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.addEventListener("scroll", onScroll, { messagesContainer.value.addEventListener("scroll", onScroll, {
passive: true, passive: true,
}); });
} }
if (messagesInner.value) { if (messagesInner.value) {
resizeObserver = new ResizeObserver(scrollToBottomIfNear); resizeObserver = new ResizeObserver(scrollToBottomIfNear);
resizeObserver.observe(messagesInner.value); resizeObserver.observe(messagesInner.value);
} }
scrollToBottom(); scrollToBottom();
}); });
onUnmounted(() => { onUnmounted(() => {
messagesStore.disconnect(); messagesStore.disconnect();
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.removeEventListener("scroll", onScroll); messagesContainer.value.removeEventListener("scroll", onScroll);
} }
if (resizeObserver) { if (resizeObserver) {
resizeObserver.disconnect(); resizeObserver.disconnect();
resizeObserver = null; resizeObserver = null;
} }
}); });
</script> </script>
<template> <template>
<div class="chat-root flex-col flex min-h-0"> <div class="chat-root flex-col flex min-h-0">
<Header>Chat</Header> <Header>Chat</Header>
<div <div
ref="messagesContainer" ref="messagesContainer"
class="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2 min-w-0" class="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2 min-w-0"
>
<div ref="messagesInner">
<p
v-for="message in messages"
:key="message.id"
class="break-words min-w-0 w-full"
> >
<div ref="messagesInner"> <span class="text-tertiary">{{ message.authorId }}:</span>
<p <template
v-for="message in messages" v-for="(part, i) in parseMessageParts(message.text || '')"
:key="message.id" :key="i"
class="break-words min-w-0 w-full" >
> <Link
<span class="text-tertiary">{{ message.authorId }}:</span> v-if="part.type === 'link'"
<template bare
v-for="(part, i) in parseMessageParts( :href="part.value"
message.text || '', target="_blank"
)" class="text-primary underline break-all"
:key="i" >{{ part.value }}</Link
> >
<Link <span v-else>{{ part.value }}</span>
v-if="part.type === 'link'" </template>
bare <template v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)">
:href="part.value" <img
target="_blank" v-if="isImageUrl(message.fileUrl)"
class="text-primary underline break-all" :src="message.fileUrl"
>{{ part.value }}</Link alt="Uploaded image"
> loading="lazy"
<span v-else>{{ part.value }}</span> class="w-full max-w-full rounded block"
</template>
<template
v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)"
>
<img
v-if="isImageUrl(message.fileUrl)"
:src="message.fileUrl"
alt="Uploaded image"
loading="lazy"
class="w-full max-w-full rounded block"
/>
<video
v-else-if="isVideoUrl(message.fileUrl)"
:src="message.fileUrl"
controls
preload="none"
class="w-full max-w-full max-h-48 rounded block"
/>
<Link
v-else
bare
:href="message.fileUrl"
target="_blank"
class="underline break-all"
>{{ message.fileUrl.split("/").pop() }}</Link
>
</template>
</p>
</div>
</div>
<div>
<input
v-model="messageInput"
@keyup.enter="sendMessage"
aria-label="Chat message"
/> />
<input <video
ref="fileInput" v-else-if="isVideoUrl(message.fileUrl)"
type="file" :src="message.fileUrl"
class="hidden" controls
@change="onFileSelected" preload="none"
class="w-full max-w-full max-h-48 rounded block"
/> />
<div class="flex gap-2"> <Link
<Button class="flex-1" @click="sendMessage">Send</Button> v-else
<Button bare
v-if="authStore.user.admin" :href="message.fileUrl"
class="flex-1" target="_blank"
@click="fileInput.click()" class="underline break-all"
>Attach</Button >{{ message.fileUrl.split("/").pop() }}</Link
> >
<Button v-if="!isNearBottom" class="flex-1" @click="goToBottom" </template>
>Bottom</Button </p>
> </div>
</div>
</div>
</div> </div>
<div>
<input
v-model="messageInput"
@keyup.enter="sendMessage"
aria-label="Chat message"
/>
<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 v-if="!isNearBottom" class="flex-1" @click="goToBottom"
>Bottom</Button
>
</div>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
@media (max-width: 850px) { @media (max-width: 850px) {
.chat-root { .chat-root {
max-height: none; max-height: none;
height: 100%; height: 100%;
} }
} }
</style> </style>

View File

@@ -9,37 +9,42 @@ const { gitFeed: feed, loaded } = storeToRefs(homeData);
</script> </script>
<template> <template>
<div class="flex flex-col text-center min-h-0 h-full overflow-x-hidden"> <div class="flex flex-col text-center min-h-0 h-full overflow-x-hidden">
<Header class="text-left">Commits</Header> <Header class="text-left">Commits</Header>
<div v-if="!loaded" class="flex-1 overflow-y-auto"> <div v-if="!loaded" class="flex-1 overflow-y-auto">
<p>Loading latest activity...</p> <p>Loading latest activity...</p>
</div>
<div
v-else-if="feed"
class="flex-1 flex flex-col items-center overflow-y-auto overflow-x-hidden"
>
<h3>Last git activity</h3>
<img :src="feed.avatarUrl" alt="User avatar" class="avatar" loading="lazy" />
<Link :href="feed.repoUrl">
<h3>repo: {{ feed.repoName }}</h3>
</Link>
<p>Action: {{ feed.opType }}</p>
<p>Message: {{ feed.commitMessage }}</p>
<small> {{ new Date(feed.createdAt).toLocaleString() }}</small>
</div>
<div v-else class="flex-1 overflow-y-auto">
<p>No activity found.</p>
</div>
</div> </div>
<div
v-else-if="feed"
class="flex-1 flex flex-col items-center overflow-y-auto overflow-x-hidden"
>
<h3>Last git activity</h3>
<img
:src="feed.avatarUrl"
alt="User avatar"
class="avatar"
loading="lazy"
/>
<Link :href="feed.repoUrl">
<h3>repo: {{ feed.repoName }}</h3>
</Link>
<p>Action: {{ feed.opType }}</p>
<p>Message: {{ feed.commitMessage }}</p>
<small> {{ new Date(feed.createdAt).toLocaleString() }}</small>
</div>
<div v-else class="flex-1 overflow-y-auto">
<p>No activity found.</p>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.avatar { .avatar {
max-width: 200px; max-width: 200px;
width: 100%; width: 100%;
height: auto; height: auto;
} }
</style> </style>

View File

@@ -4,70 +4,66 @@ import Link from "@/components/text/Link.vue";
import ToggleHeader from "@/components/text/ToggleHeader.vue"; import ToggleHeader from "@/components/text/ToggleHeader.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
type: Array, type: Array,
required: true, required: true,
}, },
variant: { variant: {
type: String, type: String,
default: "list", default: "list",
}, },
title: { title: {
type: String, type: String,
default: "", default: "",
}, },
}); });
const show = ref(false); const show = ref(false);
</script> </script>
<template> <template>
<div v-if="title" class="h-fit w-full"> <div v-if="title" class="h-fit w-full">
<ToggleHeader v-model="show" class="justify-between flex items-center"> <ToggleHeader v-model="show" class="justify-between flex items-center">
{{ title }} {{ title }}
</ToggleHeader> </ToggleHeader>
<template v-if="show"> <template v-if="show">
<Link <Link
v-if="variant === 'list'" v-if="variant === 'list'"
v-for="(item, i) in items" v-for="(item, i) in items"
:key="i" :key="i"
:href="item.link" :href="item.link"
> >
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p> <p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
</Link> </Link>
<table class="w-full" v-else> <table class="w-full" v-else>
<tbody> <tbody>
<tr v-for="item in items" :key="item.id"> <tr v-for="item in items" :key="item.id">
<th>{{ item.type }}</th> <th>{{ item.type }}</th>
<td v-if="item.link"> <td v-if="item.link">
<Link :href="item.link">{{ item.name }}</Link> <Link :href="item.link">{{ item.name }}</Link>
</td> </td>
<td v-else>{{ item.name }}</td> <td v-else>{{ item.name }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</template>
</div>
<template v-else>
<template v-if="variant === 'list'">
<Link
v-for="(item, i) in items"
:key="i"
:href="item.link"
>
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
</Link>
</template>
<table class="w-full" v-else>
<tbody>
<tr v-for="item in items" :key="item.id">
<th>{{ item.type }}</th>
<td v-if="item.link">
<Link :href="item.link">{{ item.name }}</Link>
</td>
<td v-else>{{ item.name }}</td>
</tr>
</tbody>
</table>
</template> </template>
</div>
<template v-else>
<template v-if="variant === 'list'">
<Link v-for="(item, i) in items" :key="i" :href="item.link">
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
</Link>
</template>
<table class="w-full" v-else>
<tbody>
<tr v-for="item in items" :key="item.id">
<th>{{ item.type }}</th>
<td v-if="item.link">
<Link :href="item.link">{{ item.name }}</Link>
</td>
<td v-else>{{ item.name }}</td>
</tr>
</tbody>
</table>
</template>
</template> </template>

View File

@@ -6,19 +6,19 @@ import DOMPurify from "dompurify";
const mdIt = MarkdownIt().use(katex); const mdIt = MarkdownIt().use(katex);
const props = defineProps({ const props = defineProps({
source: String, source: String,
}); });
function renderMarkdown(source) { function renderMarkdown(source) {
return DOMPurify.sanitize(mdIt.render(source)); return DOMPurify.sanitize(mdIt.render(source));
} }
</script> </script>
<template> <template>
<div <div
v-html="renderMarkdown(props.source)" v-html="renderMarkdown(props.source)"
class="flex flex-col items-center" class="flex flex-col items-center"
></div> ></div>
</template> </template>
<style> <style>

View File

@@ -1,93 +1,92 @@
<script setup> <script setup>
import Button from "@/components/input/Button.vue"; import Button from "@/components/input/Button.vue";
</script> </script>
<template> <template>
<audio/> <audio />
<div class="musicPlayerGrid"> <div class="musicPlayerGrid">
<div class="album_cover"> <div class="album_cover">
<img src="/img/Untitled.png" alt=""></img> <img src="/img/Untitled.png" alt="" />
</div>
<div class="controls">
<div class="sliders">
<div class="timeline"/>
<div class="volume"/>
</div>
<div class="buttons">
<div class="rewind"/>
<div class="playPause"/>
<div class="fastforward"/>
</div>
</div>
</div> </div>
<div class="controls">
<div class="sliders">
<div class="timeline" />
<div class="volume" />
</div>
<div class="buttons">
<div class="rewind" />
<div class="playPause" />
<div class="fastforward" />
</div>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.musicPlayerGrid { .musicPlayerGrid {
display: grid; display: grid;
grid-gap: 5px; grid-gap: 5px;
grid-template-rows: repeat(4, 1fr); grid-template-rows: repeat(4, 1fr);
background-color: blue; background-color: blue;
align-items: stretch; /* rows (block axis) */ align-items: stretch; /* rows (block axis) */
justify-items: stretch; /* columns (inline axis) */ justify-items: stretch; /* columns (inline axis) */
padding: 5px; padding: 5px;
} }
img { img {
width: 100%; width: 100%;
} }
.album_cover { .album_cover {
grid-row: 1 / span 3; grid-row: 1 / span 3;
background-color: grey; background-color: grey;
box-sizing: border-box; box-sizing: border-box;
} }
.controls { .controls {
width: 100%; width: 100%;
grid-row: 4 / span 1; grid-row: 4 / span 1;
box-sizing: border-box; box-sizing: border-box;
display: grid; display: grid;
grid-template-rows: repeat(4, 1fr); grid-template-rows: repeat(4, 1fr);
grid-gap: 5px; grid-gap: 5px;
} }
.sliders { .sliders {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
grid-gap: 5px; grid-gap: 5px;
} }
.timeline { .timeline {
grid-column: 1; grid-column: 1;
background-color: white; background-color: white;
} }
.volume { .volume {
grid-column: 2; grid-column: 2;
background-color: white; background-color: white;
} }
.buttons { .buttons {
background-color: black; background-color: black;
grid-row: 2 / -1; grid-row: 2 / -1;
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
grid-gap: 5px; grid-gap: 5px;
} }
.rewind { .rewind {
grid-column: 1; grid-column: 1;
background-color: grey; background-color: grey;
} }
.fastforward { .fastforward {
grid-column: 4; grid-column: 4;
background-color: grey; background-color: grey;
} }
.playPause { .playPause {
grid-column: 2/span 2; grid-column: 2 / span 2;
background-color: grey; background-color: grey;
} }
</style> </style>

View File

@@ -2,42 +2,42 @@
import { computed } from "vue"; import { computed } from "vue";
const props = defineProps({ const props = defineProps({
objArr: { objArr: {
type: Array, type: Array,
required: true, required: true,
}, },
}); });
const resolvedColumns = computed(() => { const resolvedColumns = computed(() => {
const keys = new Set(); const keys = new Set();
for (const obj of props.objArr) { for (const obj of props.objArr) {
Object.keys(obj).forEach((key) => keys.add(key)); Object.keys(obj).forEach((key) => keys.add(key));
} }
return Array.from(keys).map((key) => ({ return Array.from(keys).map((key) => ({
key, key,
label: key, label: key,
})); }));
}); });
</script> </script>
<template> <template>
<table> <table>
<thead> <thead>
<tr> <tr>
<th v-for="col in resolvedColumns" :key="col.key"> <th v-for="col in resolvedColumns" :key="col.key">
{{ col.label }} {{ col.label }}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(row, rowIndex) in objArr" :key="rowIndex"> <tr v-for="(row, rowIndex) in objArr" :key="rowIndex">
<td v-for="col in resolvedColumns" :key="col.key"> <td v-for="col in resolvedColumns" :key="col.key">
{{ row[col.key] ?? "" }} {{ row[col.key] ?? "" }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</template> </template>

View File

@@ -1,17 +1,17 @@
<template> <template>
<div v-if="streamLive" class="overflow-auto"> <div v-if="streamLive" class="overflow-auto">
<Header>Radio</Header> <Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" /> <img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" />
<audio controls :src="streamUrl" ref="audio"></audio> <audio controls :src="streamUrl" ref="audio"></audio>
</div> </div>
<div v-else class="overflow-auto"> <div v-else class="overflow-auto">
<Header>Radio</Header> <Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" /> <img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" />
<div class="m-1 text-center"> <div class="m-1 text-center">
<p>Radio is offline. Message for info!</p> <p>Radio is offline. Message for info!</p>
<Button class="w-full" @click="checkStream()">Check Stream</Button> <Button class="w-full" @click="checkStream()">Check Stream</Button>
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
@@ -25,32 +25,32 @@ const streamLive = ref(false);
const audio = useTemplateRef("audio"); const audio = useTemplateRef("audio");
async function checkStream() { async function checkStream() {
try { try {
await axios.head("/radio/stream"); await axios.head("/radio/stream");
if (!streamLive.value) { if (!streamLive.value) {
streamLive.value = true; streamLive.value = true;
streamUrl.value = "/radio/stream"; streamUrl.value = "/radio/stream";
await nextTick(); await nextTick();
if (audio.value) { if (audio.value) {
audio.value.load(); audio.value.load();
audio.value.volume = 0.2; audio.value.volume = 0.2;
} }
}
} catch (err) {
streamLive.value = false;
} }
} catch (err) {
streamLive.value = false;
}
} }
onMounted(() => { onMounted(() => {
checkStream(); checkStream();
setInterval(checkStream, 120000); setInterval(checkStream, 120000);
}); });
</script> </script>
<style scoped> <style scoped>
img { img {
width: 100%; width: 100%;
max-height: 150px; max-height: 150px;
object-fit: cover; object-fit: cover;
} }
</style> </style>

View File

@@ -2,22 +2,22 @@
import { computed } from "vue"; import { computed } from "vue";
const props = defineProps({ const props = defineProps({
linkArr: { linkArr: {
type: Array, type: Array,
required: true, required: true,
}, },
}); });
const keys = ["name", "link"]; const keys = ["name", "link"];
</script> </script>
<template> <template>
<RouterLink <RouterLink
class="bdr-2 bg-bg_primary" class="bdr-2 bg-bg_primary"
v-for="(row, rowIndex) in linkArr" v-for="(row, rowIndex) in linkArr"
:key="rowIndex" :key="rowIndex"
:to="row.link" :to="row.link"
> >
{{ row.name }} {{ row.name }}
</RouterLink> </RouterLink>
</template> </template>

View File

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

View File

@@ -8,14 +8,14 @@ const day = ref("");
const month = ref(""); const month = ref("");
function updateDateTime() { function updateDateTime() {
const date = new Date(); const date = new Date();
day.value = date.getDate(); day.value = date.getDate();
time.value = date.toLocaleTimeString("en-GB", { time.value = date.toLocaleTimeString("en-GB", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
weekday.value = date.toLocaleDateString("en-GB", { weekday: "long" }); weekday.value = date.toLocaleDateString("en-GB", { weekday: "long" });
month.value = date.toLocaleDateString("en-GB", { month: "long" }); month.value = date.toLocaleDateString("en-GB", { month: "long" });
} }
updateDateTime(); updateDateTime();
@@ -24,15 +24,15 @@ setInterval(updateDateTime, 60000);
</script> </script>
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<Header>{{ weekday }} {{ day }}, {{ month }}</Header> <Header>{{ weekday }} {{ day }}, {{ month }}</Header>
<h1>{{ time }}</h1> <h1>{{ time }}</h1>
</div> </div>
</template> </template>
<style scoped> <style scoped>
div { div {
text-align: center; text-align: center;
padding: 4px; padding: 4px;
} }
</style> </style>

View File

@@ -18,109 +18,109 @@ const seconds = ref(0);
const audio = new Audio("/sound/auughhh.mp3"); const audio = new Audio("/sound/auughhh.mp3");
function tick() { function tick() {
seconds.value++; seconds.value++;
if (seconds.value === 60) { if (seconds.value === 60) {
minutes.value++; minutes.value++;
seconds.value = 0; seconds.value = 0;
} }
if (minutes.value >= minutesInput.value) { if (minutes.value >= minutesInput.value) {
if (seconds.value >= secondsInput.value) { if (seconds.value >= secondsInput.value) {
finished.value = true; finished.value = true;
playFinishedSound(); playFinishedSound();
clearInterval(timer.value); clearInterval(timer.value);
}
} }
}
} }
function startTimer() { function startTimer() {
finished.value = false; finished.value = false;
paused.value = false; paused.value = false;
timer.value = setInterval(tick, 1000); timer.value = setInterval(tick, 1000);
} }
function pauseTimer() { function pauseTimer() {
if (finished.value) return; if (finished.value) return;
if (paused.value) { if (paused.value) {
timer.value = setInterval(tick, 1000); timer.value = setInterval(tick, 1000);
paused.value = false; paused.value = false;
} else { } else {
clearInterval(timer.value); clearInterval(timer.value);
paused.value = true; paused.value = true;
} }
} }
function resetTimer() { function resetTimer() {
finished.value = true; finished.value = true;
paused.value = true; paused.value = true;
clearInterval(timer.value); clearInterval(timer.value);
minutes.value = 0; minutes.value = 0;
seconds.value = 0; seconds.value = 0;
} }
function playFinishedSound() { function playFinishedSound() {
audio.play(); audio.play();
} }
</script> </script>
<template> <template>
<div class="timer-root flex flex-col gap-1 p-1 items-center"> <div class="timer-root flex flex-col gap-1 p-1 items-center">
<Header>Timer</Header> <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
class="w-2/3" class="w-2/3"
v-model="minutesInput" v-model="minutesInput"
type="range" type="range"
min="0" min="0"
max="59" max="59"
aria-label="Minutes" aria-label="Minutes"
/> />
<p>{{ minutesInput }}m</p> <p>{{ minutesInput }}m</p>
</div> </div>
<div class="flex flex-row p-2 place-content-around"> <div class="flex flex-row p-2 place-content-around">
<input <input
class="w-2/3" class="w-2/3"
v-model="secondsInput" v-model="secondsInput"
type="range" type="range"
min="0" min="0"
max="59" max="59"
aria-label="Seconds" aria-label="Seconds"
/> />
<p>{{ secondsInput }}s</p> <p>{{ secondsInput }}s</p>
</div> </div>
<Button @click="startTimer">Proceed</Button> <Button @click="startTimer">Proceed</Button>
</div>
<div v-if="finished && !paused" class="flex flex-col">
<h1>Timer finished!</h1>
<Button @click="resetTimer">Reset</Button>
</div>
<div v-if="!finished && paused" class="flex flex-col">
<h1>Paused</h1>
<Button @click="resetTimer">Reset</Button>
</div>
<div v-if="!finished && !paused" class="flex flex-col">
<p>
{{ minutes.toString().padStart(2, "0") }}:{{
seconds.toString().padStart(2, "0")
}}
</p>
<p>
{{ minutesInput.toString().padStart(2, "0") }}:{{
secondsInput.toString().padStart(2, "0")
}}
</p>
<Button @click="pauseTimer">Pause</Button>
</div>
</div> </div>
<div v-if="finished && !paused" class="flex flex-col">
<h1>Timer finished!</h1>
<Button @click="resetTimer">Reset</Button>
</div>
<div v-if="!finished && paused" class="flex flex-col">
<h1>Paused</h1>
<Button @click="resetTimer">Reset</Button>
</div>
<div v-if="!finished && !paused" class="flex flex-col">
<p>
{{ minutes.toString().padStart(2, "0") }}:{{
seconds.toString().padStart(2, "0")
}}
</p>
<p>
{{ minutesInput.toString().padStart(2, "0") }}:{{
secondsInput.toString().padStart(2, "0")
}}
</p>
<Button @click="pauseTimer">Pause</Button>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
@media (max-width: 850px) { @media (max-width: 850px) {
.timer-root { .timer-root {
padding: 2px; padding: 2px;
gap: 2px; gap: 2px;
} }
} }
</style> </style>

View File

@@ -1,15 +1,15 @@
<template> <template>
<div <div
ref="container" ref="container"
class="overflow-auto w-full h-full" class="overflow-auto w-full h-full"
@mousedown="handleMouseDown" @mousedown="handleMouseDown"
@mousemove="handleMouseMove" @mousemove="handleMouseMove"
@mouseup="handleMouseUp" @mouseup="handleMouseUp"
@mouseleave="handleMouseLeave" @mouseleave="handleMouseLeave"
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }" :style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
> >
<slot /> <slot />
</div> </div>
</template> </template>
<script setup> <script setup>
@@ -23,45 +23,45 @@ const scrollLeft = ref(0);
const scrollTop = ref(0); const scrollTop = ref(0);
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
isDragging.value = true; isDragging.value = true;
startX.value = e.pageX - container.value.offsetLeft; startX.value = e.pageX - container.value.offsetLeft;
startY.value = e.pageY - container.value.offsetTop; startY.value = e.pageY - container.value.offsetTop;
scrollLeft.value = container.value.scrollLeft; scrollLeft.value = container.value.scrollLeft;
scrollTop.value = container.value.scrollTop; scrollTop.value = container.value.scrollTop;
// Prevent text selection while dragging // Prevent text selection while dragging
e.preventDefault(); e.preventDefault();
}; };
const handleMouseMove = (e) => { const handleMouseMove = (e) => {
if (!isDragging.value) return; if (!isDragging.value) return;
e.preventDefault(); e.preventDefault();
const x = e.pageX - container.value.offsetLeft; const x = e.pageX - container.value.offsetLeft;
const y = e.pageY - container.value.offsetTop; const y = e.pageY - container.value.offsetTop;
const walkX = (x - startX.value) * 1; // Multiply by scroll speed factor const walkX = (x - startX.value) * 1; // Multiply by scroll speed factor
const walkY = (y - startY.value) * 1; const walkY = (y - startY.value) * 1;
container.value.scrollLeft = scrollLeft.value - walkX; container.value.scrollLeft = scrollLeft.value - walkX;
container.value.scrollTop = scrollTop.value - walkY; container.value.scrollTop = scrollTop.value - walkY;
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
isDragging.value = false; isDragging.value = false;
}; };
const handleMouseLeave = () => { const handleMouseLeave = () => {
isDragging.value = false; isDragging.value = false;
}; };
</script> </script>
<style scoped> <style scoped>
/* Prevent text selection while dragging */ /* Prevent text selection while dragging */
div[style*="cursor: grabbing"] { div[style*="cursor: grabbing"] {
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
} }
</style> </style>

View File

@@ -2,29 +2,29 @@
import { computed } from "vue"; import { computed } from "vue";
const props = defineProps({ const props = defineProps({
sourceArr: { sourceArr: {
type: Array, type: Array,
required: true, required: true,
}, },
}); });
function sourceType(link) { function sourceType(link) {
return "video/" + link.split(".").pop(); return "video/" + link.split(".").pop();
} }
const keys = ["name", "link"]; const keys = ["name", "link"];
</script> </script>
<template> <template>
<video <video
v-for="(source, rowIndex) in sourceArr" v-for="(source, rowIndex) in sourceArr"
:key="rowIndex" :key="rowIndex"
class="bdr-1" class="bdr-1"
width="300" width="300"
height="400" height="400"
controls controls
preload="none" preload="none"
> >
<source :src="source.link" :type="sourceType(source.link)" /> <source :src="source.link" :type="sourceType(source.link)" />
</video> </video>
</template> </template>

View File

@@ -1,11 +1,11 @@
<template> <template>
<div class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll"> <div class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll">
<RouterLink to="/" class="bdr-2"> <RouterLink to="/" class="bdr-2">
<img src="/img/memes/epic.jpeg" alt="" loading="lazy" /> <img src="/img/memes/epic.jpeg" alt="" loading="lazy" />
</RouterLink> </RouterLink>
<h1>Click her, she will take you home</h1> <h1>Click her, she will take you home</h1>
<span style="height: 100px"></span> <span style="height: 100px"></span>
<h1>WIP</h1> <h1>WIP</h1>
<h4>Sorry for taking you here</h4> <h4>Sorry for taking you here</h4>
</div> </div>
</template> </template>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div ref="container" class="overflow-y-auto"> <div ref="container" class="overflow-y-auto">
<slot /> <slot />
</div> </div>
</template> </template>
<script setup> <script setup>
@@ -13,14 +13,14 @@ const container = useTemplateRef("container");
let scroller = null; let scroller = null;
onMounted(() => { onMounted(() => {
if (!container.value) return; if (!container.value) return;
scroller = new AutoScroller(container.value); scroller = new AutoScroller(container.value);
scroller.start(); scroller.start();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
scroller?.destroy(); scroller?.destroy();
scroller?.free(); scroller?.free();
scroller = null; scroller = null;
}); });
</script> </script>

View File

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

View File

@@ -3,41 +3,69 @@ import { RouterView } from "vue-router";
</script> </script>
<template> <template>
<div class="cv-layout"> <div class="cv-layout">
<RouterView /> <RouterView />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.cv-layout { .cv-layout {
min-height: 100vh; min-height: 100vh;
background: white; background: white;
color: #111; color: #111;
} }
</style> </style>
<style> <style>
.cv-layout h1, .cv-layout h2, .cv-layout h3, .cv-layout h4, .cv-layout h1,
.cv-layout p, .cv-layout small, .cv-layout code, .cv-layout ul, .cv-layout li, .cv-layout h2,
.cv-layout td, .cv-layout tr, .cv-layout table { .cv-layout h3,
color: #111; .cv-layout h4,
.cv-layout p,
.cv-layout small,
.cv-layout code,
.cv-layout ul,
.cv-layout li,
.cv-layout td,
.cv-layout tr,
.cv-layout table {
color: #111;
}
.cv-layout h1,
.cv-layout h2,
.cv-layout h3,
.cv-layout h4 {
margin: 0;
} }
.cv-layout h1, .cv-layout h2, .cv-layout h3, .cv-layout h4 { margin: 0; }
.cv-layout a { .cv-layout a {
color: #111; color: #111;
background-color: transparent !important; background-color: transparent !important;
letter-spacing: normal; letter-spacing: normal;
} }
.cv-layout input, .cv-layout textarea { .cv-layout input,
color: #111; .cv-layout textarea {
background-color: white; color: #111;
border: 1px solid #ccc; background-color: white;
padding: 0; border: 1px solid #ccc;
width: auto; padding: 0;
width: auto;
}
.cv-layout input::placeholder,
.cv-layout textarea::placeholder {
color: #999;
opacity: 1;
}
.cv-layout table {
border: 0 solid transparent;
}
.cv-layout tr {
border-color: transparent;
}
.cv-layout th {
border: none;
padding: 0;
}
.cv-layout td {
padding: 0;
} }
.cv-layout input::placeholder, .cv-layout textarea::placeholder { color: #999; opacity: 1; }
.cv-layout table { border: 0 solid transparent; }
.cv-layout tr { border-color: transparent; }
.cv-layout th { border: none; padding: 0; }
.cv-layout td { padding: 0; }
</style> </style>

View File

@@ -5,28 +5,28 @@ import Footer from "@/components/Footer.vue";
</script> </script>
<template> <template>
<div class="default-layout halftone"> <div class="default-layout halftone">
<Navbar class="no-print sticky top-0 z-50" /> <Navbar class="no-print sticky top-0 z-50" />
<main class="default-content"> <main class="default-content">
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
<Transition name="slide" mode="out-in"> <Transition name="slide" mode="out-in">
<component :is="Component" :key="$route.path" /> <component :is="Component" :key="$route.path" />
</Transition> </Transition>
</RouterView> </RouterView>
</main> </main>
<Footer class="no-print sticky bottom-0 z-50" /> <Footer class="no-print sticky bottom-0 z-50" />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.default-layout { .default-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
} }
.default-content { .default-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
} }
</style> </style>

View File

@@ -7,96 +7,103 @@ import { useHomeDataStore } from "@/stores/homeData";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{
path: "/",
component: DefaultLayout,
children: [
{ {
path: "/", path: "",
component: DefaultLayout, name: "landing",
children: [ component: Landing,
{
path: "",
name: "landing",
component: Landing,
},
{
path: "stp",
name: "home",
component: () => import("@/views/home/Home.vue"),
},
{
path: "admin/login",
name: "admin-login",
component: () => import("@/views/admin/Login.vue"),
},
{
path: "admin",
name: "admin",
component: () => import("@/views/admin/Admin.vue"),
meta: { requiresAdmin: true },
},
{
path: "shrines",
name: "shrine links",
component: () => import("@/views/home/shrines/Shrines.vue"),
},
{
path: "shrines/gto",
name: "gto shrine",
component: () => import("@/views/home/shrines/GTO.vue"),
},
{
path: "shrines/skipskipbenben",
name: "skipskipbenben shrine",
component: () => import("@/views/home/shrines/Skipskipbenben.vue"),
},
{
path: "shrines/evangelion",
name: "evangelion shrine",
component: () => import("@/views/home/shrines/Evangelion.vue"),
},
{
path: "shrines/demoman",
name: "demoman shrine",
component: () => import("@/views/home/shrines/Demoman.vue"),
},
{
path: ":pathMatch(.*)*",
name: "404",
component: () => import("@/views/404/404.vue"),
},
],
}, },
{ {
path: "/cv", path: "stp",
component: CVLayout, name: "home",
children: [ component: () => import("@/views/home/Home.vue"),
{
path: "",
name: "cv",
component: () => import("@/views/CV/CV.vue"),
},
{
path: "jobs",
name: "job-applications",
component: () => import("@/views/CV/JobApplications.vue"),
meta: { requiresAdmin: true },
},
],
}, },
], {
path: "admin/login",
name: "admin-login",
component: () => import("@/views/admin/Login.vue"),
},
{
path: "admin",
name: "admin",
component: () => import("@/views/admin/Admin.vue"),
meta: { requiresAdmin: true },
},
{
path: "shrines",
name: "shrine links",
component: () => import("@/views/home/shrines/Shrines.vue"),
},
{
path: "shrines/gto",
name: "gto shrine",
component: () => import("@/views/home/shrines/GTO.vue"),
},
{
path: "shrines/skipskipbenben",
name: "skipskipbenben shrine",
component: () => import("@/views/home/shrines/Skipskipbenben.vue"),
},
{
path: "shrines/evangelion",
name: "evangelion shrine",
component: () => import("@/views/home/shrines/Evangelion.vue"),
},
{
path: "shrines/demoman",
name: "demoman shrine",
component: () => import("@/views/home/shrines/Demoman.vue"),
},
{
path: ":pathMatch(.*)*",
name: "404",
component: () => import("@/views/404/404.vue"),
},
],
},
{
path: "/cv",
component: CVLayout,
children: [
{
path: "",
name: "cv",
component: () => import("@/views/CV/CV.vue"),
},
{
path: "jobs",
name: "job-applications",
component: () => import("@/views/CV/JobApplications.vue"),
meta: { requiresAdmin: true },
},
],
},
],
}); });
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
if (!to.meta.requiresAdmin) return; if (!to.meta.requiresAdmin) return;
const homeData = useHomeDataStore(); const homeData = useHomeDataStore();
if (!homeData.loaded) { if (!homeData.loaded) {
await new Promise((resolve) => { await new Promise((resolve) => {
const stop = watch(() => homeData.loaded, (val) => { const stop = watch(
if (val) { stop(); resolve(); } () => homeData.loaded,
}); (val) => {
}); if (val) {
} stop();
if (!useAuthStore().user.admin) return { path: "/admin/login", query: { redirect: to.fullPath } }; resolve();
}
},
);
});
}
if (!useAuthStore().user.admin)
return { path: "/admin/login", query: { redirect: to.fullPath } };
}); });
export default router; export default router;

View File

@@ -1,13 +1,13 @@
<template> <template>
<main class="flex flex-col items-center"> <main class="flex flex-col items-center">
<div <div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll" class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
> >
<h1>404</h1> <h1>404</h1>
<RouterLink to="/" class="bdr-2"> <RouterLink to="/" class="bdr-2">
<img src="/img/memes/epic.jpeg" loading="lazy" /> <img src="/img/memes/epic.jpeg" loading="lazy" />
</RouterLink> </RouterLink>
<h1>Click her, she will take you home</h1> <h1>Click her, she will take you home</h1>
</div> </div>
</main> </main>
</template> </template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,17 @@
<script setup></script> <script setup></script>
<template> <template>
<div class="flex flex-col sm:flex-row"> <div class="flex flex-col sm:flex-row">
<div class="sm:w-2/7 mt-auto mb-auto ml-0"> <div class="sm:w-2/7 mt-auto mb-auto ml-0">
<slot name="left" /> <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> </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> </template>

View File

@@ -9,15 +9,15 @@ import ManageRadio from "./ManageRadio.vue";
</script> </script>
<template> <template>
<main class="justify-center flex flex-row w-full h-full"> <main class="justify-center flex flex-row w-full h-full">
<div class="bdr-1 flex flex-col"> <div class="bdr-1 flex flex-col">
<CreateUser class="bdr-2 bg-bg_primary" /> <CreateUser class="bdr-2 bg-bg_primary" />
<CreatePost class="bdr-2 bg-bg_primary" /> <CreatePost class="bdr-2 bg-bg_primary" />
<CreateFavorite class="bdr-2 bg-bg_primary" /> <CreateFavorite class="bdr-2 bg-bg_primary" />
<CreateActivity class="bdr-2 bg-bg_primary" /> <CreateActivity class="bdr-2 bg-bg_primary" />
<CreateRowing class="bdr-2 bg-bg_primary" /> <CreateRowing class="bdr-2 bg-bg_primary" />
<ManageUsers class="bdr-2 bg-bg_primary" /> <ManageUsers class="bdr-2 bg-bg_primary" />
<ManageRadio class="bdr-2 bg-bg_primary" /> <ManageRadio class="bdr-2 bg-bg_primary" />
</div> </div>
</main> </main>
</template> </template>

View File

@@ -11,29 +11,35 @@ const name = ref("");
const link = ref(""); const link = ref("");
async function post() { async function post() {
try { try {
const data = await gql( const data = await gql(
`mutation CreateActivity($input: CreateActivityInput!) { createActivity(input: $input) { id } }`, `mutation CreateActivity($input: CreateActivityInput!) { createActivity(input: $input) { id } }`,
{ input: { type: type.value, name: name.value, link: link.value || undefined } }, {
); input: {
type.value = ""; type: type.value,
name.value = ""; name: name.value,
link.value = ""; link: link.value || undefined,
console.log(data.createActivity); },
emit("done"); },
} catch (err) { );
console.error(err); type.value = "";
} name.value = "";
link.value = "";
console.log(data.createActivity);
emit("done");
} catch (err) {
console.error(err);
}
} }
</script> </script>
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<h1>Create Activity</h1> <h1>Create Activity</h1>
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" /> <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="name" placeholder="Name" @keyup.enter="post" />
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" /> <input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
<Button @click="post">Upload</Button> <Button @click="post">Upload</Button>
<Button @click="emit('cancel')">Cancel</Button> <Button @click="emit('cancel')">Cancel</Button>
</div> </div>
</template> </template>

View File

@@ -10,28 +10,40 @@ const name = ref("");
const link = ref(""); const link = ref("");
async function submit() { async function submit() {
try { try {
await gql( await gql(
`mutation CreateBookmark($input: CreateBookmarkInput!) { createBookmark(input: $input) { id } }`, `mutation CreateBookmark($input: CreateBookmarkInput!) { createBookmark(input: $input) { id } }`,
{ input: { category: category.value, name: name.value, link: link.value } }, {
); input: { category: category.value, name: name.value, link: link.value },
category.value = ""; },
name.value = ""; );
link.value = ""; category.value = "";
emit("done"); name.value = "";
} catch (err) { link.value = "";
console.error(err); emit("done");
} } catch (err) {
console.error(err);
}
} }
</script> </script>
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<h1>Create Bookmark</h1> <h1>Create Bookmark</h1>
<input type="text" v-model="category" placeholder="Category" /> <input type="text" v-model="category" placeholder="Category" />
<input type="text" v-model="name" placeholder="Name" @keyup.enter="submit" /> <input
<input type="text" v-model="link" placeholder="Link" @keyup.enter="submit" /> type="text"
<Button @click="submit">Upload</Button> v-model="name"
<Button @click="emit('cancel')">Cancel</Button> placeholder="Name"
</div> @keyup.enter="submit"
/>
<input
type="text"
v-model="link"
placeholder="Link"
@keyup.enter="submit"
/>
<Button @click="submit">Upload</Button>
<Button @click="emit('cancel')">Cancel</Button>
</div>
</template> </template>

View File

@@ -11,29 +11,35 @@ const name = ref("");
const link = ref(""); const link = ref("");
async function post() { async function post() {
try { try {
const data = await gql( const data = await gql(
`mutation CreateFavorite($input: CreateFavoriteInput!) { createFavorite(input: $input) { id } }`, `mutation CreateFavorite($input: CreateFavoriteInput!) { createFavorite(input: $input) { id } }`,
{ input: { type: type.value, name: name.value, link: link.value || undefined } }, {
); input: {
type.value = ""; type: type.value,
name.value = ""; name: name.value,
link.value = ""; link: link.value || undefined,
console.log(data.createFavorite); },
emit("done"); },
} catch (err) { );
console.error(err); type.value = "";
} name.value = "";
link.value = "";
console.log(data.createFavorite);
emit("done");
} catch (err) {
console.error(err);
}
} }
</script> </script>
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<h1>Create Favorite</h1> <h1>Create Favorite</h1>
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" /> <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="name" placeholder="Name" @keyup.enter="post" />
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" /> <input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
<Button @click="post">Upload</Button> <Button @click="post">Upload</Button>
<Button @click="emit('cancel')">Cancel</Button> <Button @click="emit('cancel')">Cancel</Button>
</div> </div>
</template> </template>

View File

@@ -9,31 +9,32 @@ const title = ref("");
const content = ref(""); const content = ref("");
async function post() { async function post() {
try { try {
const data = await gql( const data = await gql(
`mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id } }`, `mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id } }`,
{ input: { title: title.value, content: content.value } }, { input: { title: title.value, content: content.value } },
); );
title.value = ""; title.value = "";
content.value = ""; content.value = "";
console.log(data.createPost); console.log(data.createPost);
emit("done"); emit("done");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
</script> </script>
<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" @keyup.enter="post" /> <input
<textarea type="text"
class="h-50" v-model="title"
v-model="content" placeholder="Title"
placeholder="Content" @keyup.enter="post"
></textarea> />
<Button @click="post">Upload</Button> <textarea class="h-50" v-model="content" placeholder="Content"></textarea>
<Button @click="emit('cancel')">Cancel</Button> <Button @click="post">Upload</Button>
</div> <Button @click="emit('cancel')">Cancel</Button>
</div>
</template> </template>

View File

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

View File

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

View File

@@ -12,29 +12,39 @@ const username = ref("");
const password = ref(""); const password = ref("");
async function handleLogin() { async function handleLogin() {
await auth.logIn(username.value, password.value); await auth.logIn(username.value, password.value);
if (auth.loggedIn && route.query.redirect) { if (auth.loggedIn && route.query.redirect) {
router.push(route.query.redirect); router.push(route.query.redirect);
} }
} }
function handleLogout() { function handleLogout() {
auth.logOut(); auth.logOut();
} }
</script> </script>
<template> <template>
<div v-if="auth.loggedIn" class="flex flex-col"> <div v-if="auth.loggedIn" class="flex flex-col">
<h1>Logged in</h1> <h1>Logged in</h1>
<p>{{ auth.user.id }}</p> <p>{{ auth.user.id }}</p>
<p>{{ auth.user.username }}</p> <p>{{ auth.user.username }}</p>
<p>{{ auth.user.admin }}</p> <p>{{ auth.user.admin }}</p>
<Button @click="handleLogout">Log Out</Button> <Button @click="handleLogout">Log Out</Button>
</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" @keyup.enter="handleLogin" /> <input
<input type="password" v-model="password" placeholder="Password" @keyup.enter="handleLogin" /> type="text"
<Button @click="handleLogin">Log In</Button> v-model="username"
</div> placeholder="Username"
@keyup.enter="handleLogin"
/>
<input
type="password"
v-model="password"
placeholder="Password"
@keyup.enter="handleLogin"
/>
<Button @click="handleLogin">Log In</Button>
</div>
</template> </template>

View File

@@ -9,94 +9,108 @@ const results = ref([]);
const loading = ref(false); const loading = ref(false);
async function fetchSongs() { async function fetchSongs() {
try { try {
const res = await axios.get("/api/radio/songs"); const res = await axios.get("/api/radio/songs");
songs.value = res.data.songs; songs.value = res.data.songs;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
function onFileChange(e) { function onFileChange(e) {
files.value = Array.from(e.target.files); files.value = Array.from(e.target.files);
results.value = []; results.value = [];
} }
async function upload() { async function upload() {
if (!files.value.length) return; if (!files.value.length) return;
loading.value = true; loading.value = true;
results.value = files.value.map((f) => ({ name: f.name, status: "Uploading..." })); results.value = files.value.map((f) => ({
name: f.name,
status: "Uploading...",
}));
await Promise.all( await Promise.all(
files.value.map(async (file, i) => { files.value.map(async (file, i) => {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
try { try {
await axios.post("/api/radio/upload", formData, { await axios.post("/api/radio/upload", formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
}); });
results.value[i].status = "Uploaded"; results.value[i].status = "Uploaded";
results.value[i].ok = true; results.value[i].ok = true;
} catch (err) { } catch (err) {
results.value[i].status = err.response?.data?.error || "Upload failed"; results.value[i].status = err.response?.data?.error || "Upload failed";
results.value[i].ok = false; results.value[i].ok = false;
} }
}) }),
); );
files.value = []; files.value = [];
loading.value = false; loading.value = false;
await fetchSongs(); await fetchSongs();
} }
async function deleteSong(name) { async function deleteSong(name) {
try { try {
await axios.delete(`/api/radio/songs/${encodeURIComponent(name)}`); await axios.delete(`/api/radio/songs/${encodeURIComponent(name)}`);
await fetchSongs(); await fetchSongs();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
async function toggleSong(song) { async function toggleSong(song) {
const action = song.disabled ? "enable" : "disable"; const action = song.disabled ? "enable" : "disable";
try { try {
await axios.patch(`/api/radio/songs/${encodeURIComponent(song.name)}/${action}`); await axios.patch(
await fetchSongs(); `/api/radio/songs/${encodeURIComponent(song.name)}/${action}`,
} catch (err) { );
console.error(err); await fetchSongs();
} } catch (err) {
console.error(err);
}
} }
function formatSize(bytes) { function formatSize(bytes) {
if (bytes < 1024) return bytes + " B"; if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB"; return (bytes / (1024 * 1024)).toFixed(1) + " MB";
} }
onMounted(fetchSongs); onMounted(fetchSongs);
</script> </script>
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h1>Manage Radio</h1> <h1>Manage Radio</h1>
<input type="file" accept=".mp3,.ogg,.flac,.wav,.m4a,.opus" multiple @change="onFileChange" /> <input
<Button @click="upload" :disabled="loading">Upload</Button> type="file"
<div v-for="r in results" :key="r.name"> accept=".mp3,.ogg,.flac,.wav,.m4a,.opus"
<span class="text-primary">{{ r.name }}: </span> multiple
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{ r.status }}</span> @change="onFileChange"
</div> />
<div <Button @click="upload" :disabled="loading">Upload</Button>
v-for="song in songs" <div v-for="r in results" :key="r.name">
:key="song.name" <span class="text-primary">{{ r.name }}: </span>
class="flex flex-row items-center gap-2" <span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{
:class="{ 'opacity-50': song.disabled }" r.status
> }}</span>
<span :class="{ 'line-through': song.disabled }">{{ song.name }}</span>
<span class="text-secondary text-sm">{{ formatSize(song.size) }}</span>
<span v-if="song.disabled" class="text-red-400 text-xs">disabled</span>
<Button @click="toggleSong(song)">{{ song.disabled ? "Enable" : "Disable" }}</Button>
<Button @click="deleteSong(song.name)">Delete</Button>
</div>
</div> </div>
<div
v-for="song in songs"
:key="song.name"
class="flex flex-row items-center gap-2"
:class="{ 'opacity-50': song.disabled }"
>
<span :class="{ 'line-through': song.disabled }">{{ song.name }}</span>
<span class="text-secondary text-sm">{{ formatSize(song.size) }}</span>
<span v-if="song.disabled" class="text-red-400 text-xs">disabled</span>
<Button @click="toggleSong(song)">{{
song.disabled ? "Enable" : "Disable"
}}</Button>
<Button @click="deleteSong(song.name)">Delete</Button>
</div>
</div>
</template> </template>

View File

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

View File

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

View File

@@ -5,7 +5,9 @@ import Header from "@/components/text/Header.vue";
import { useHomeDataStore } from "@/stores/homeData"; import { useHomeDataStore } from "@/stores/homeData";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
const CreateBookmark = defineAsyncComponent(() => import("@/views/admin/CreateBookmark.vue")); const CreateBookmark = defineAsyncComponent(
() => import("@/views/admin/CreateBookmark.vue"),
);
const homeData = useHomeDataStore(); const homeData = useHomeDataStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -13,48 +15,57 @@ const authStore = useAuthStore();
const showCreate = ref(false); const showCreate = ref(false);
const groupedBookmarks = computed(() => { const groupedBookmarks = computed(() => {
const groups = {}; const groups = {};
for (const b of homeData.bookmarks) { for (const b of homeData.bookmarks) {
if (!groups[b.category]) groups[b.category] = []; if (!groups[b.category]) groups[b.category] = [];
groups[b.category].push(b); groups[b.category].push(b);
} }
return Object.entries(groups); return Object.entries(groups);
}); });
</script> </script>
<template> <template>
<div class="bookmarks-wrapper"> <div class="bookmarks-wrapper">
<Header class="text-left"> <Header class="text-left">
<span class="flex items-center justify-between w-full"> <span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Bookmark" : "Bookmarks" }} {{ showCreate ? "Create Bookmark" : "Bookmarks" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate"> <button
{{ showCreate ? "x" : "+" }} v-if="authStore.user.admin"
</button> class="text-sm px-1"
</span> @click="showCreate = !showCreate"
</Header> >
<CreateBookmark v-if="showCreate" class="flex-1 min-h-0 p-1" @done="showCreate = false" @cancel="showCreate = false" /> {{ showCreate ? "x" : "+" }}
<div v-if="!showCreate" class="bookmarks-scroll"> </button>
<LinkTable </span>
v-for="group in groupedBookmarks" </Header>
:key="group[0]" <CreateBookmark
:title="group[0]" v-if="showCreate"
:items="group[1]" class="flex-1 min-h-0 p-1"
/> @done="showCreate = false"
</div> @cancel="showCreate = false"
/>
<div v-if="!showCreate" class="bookmarks-scroll">
<LinkTable
v-for="group in groupedBookmarks"
:key="group[0]"
:title="group[0]"
:items="group[1]"
/>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.bookmarks-wrapper { .bookmarks-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
} }
.bookmarks-scroll { .bookmarks-scroll {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
</style> </style>

View File

@@ -2,14 +2,14 @@
import Slideshow from "@/components/util/Slideshow.vue"; import Slideshow from "@/components/util/Slideshow.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: "床" },
]; ];
</script> </script>
<template> <template>
<Slideshow :images="images" /> <Slideshow :images="images" />
</template> </template>

View File

@@ -7,7 +7,9 @@ import { ref, defineAsyncComponent } from "vue";
import { useActivityStore } from "@/stores/activity"; import { useActivityStore } from "@/stores/activity";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
const CreateActivity = defineAsyncComponent(() => import("@/views/admin/CreateActivity.vue")); const CreateActivity = defineAsyncComponent(
() => import("@/views/admin/CreateActivity.vue"),
);
const activityStore = useActivityStore(); const activityStore = useActivityStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -15,18 +17,31 @@ const showCreate = ref(false);
</script> </script>
<template> <template>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<Header> <Header>
<span class="flex items-center justify-between w-full"> <span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Activity" : "Consumption" }} {{ showCreate ? "Create Activity" : "Consumption" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate"> <button
{{ showCreate ? "x" : "+" }} v-if="authStore.user.admin"
</button> class="text-sm px-1"
</span> @click="showCreate = !showCreate"
</Header> >
<CreateActivity v-if="showCreate" class="flex-1 w-full p-1" @done="showCreate = false" @cancel="showCreate = false" /> {{ showCreate ? "x" : "+" }}
<AutoScroll v-if="!showCreate" class="flex-1 w-full"> </button>
<LinkTable variant="table" class="w-full" :items="activityStore.activity" /> </span>
</AutoScroll> </Header>
</div> <CreateActivity
v-if="showCreate"
class="flex-1 w-full p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<AutoScroll v-if="!showCreate" class="flex-1 w-full">
<LinkTable
variant="table"
class="w-full"
:items="activityStore.activity"
/>
</AutoScroll>
</div>
</template> </template>

View File

@@ -7,7 +7,9 @@ import { ref, defineAsyncComponent } from "vue";
import { useFavoritesStore } from "@/stores/favorites"; import { useFavoritesStore } from "@/stores/favorites";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
const CreateFavorite = defineAsyncComponent(() => import("@/views/admin/CreateFavorite.vue")); const CreateFavorite = defineAsyncComponent(
() => import("@/views/admin/CreateFavorite.vue"),
);
const favoritesStore = useFavoritesStore(); const favoritesStore = useFavoritesStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -15,22 +17,31 @@ const showCreate = ref(false);
</script> </script>
<template> <template>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<Header> <Header>
<span class="flex items-center justify-between w-full"> <span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Favorite" : "favs" }} {{ showCreate ? "Create Favorite" : "favs" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate"> <button
{{ showCreate ? "x" : "+" }} v-if="authStore.user.admin"
</button> class="text-sm px-1"
</span> @click="showCreate = !showCreate"
</Header> >
<CreateFavorite v-if="showCreate" class="w-full flex-1 p-1" @done="showCreate = false" @cancel="showCreate = false" /> {{ showCreate ? "x" : "+" }}
<AutoScroll v-if="!showCreate" class="w-full flex-1"> </button>
<LinkTable </span>
variant="table" </Header>
class="w-full" <CreateFavorite
:items="favoritesStore.favorites" v-if="showCreate"
/> class="w-full flex-1 p-1"
</AutoScroll> @done="showCreate = false"
</div> @cancel="showCreate = false"
/>
<AutoScroll v-if="!showCreate" class="w-full flex-1">
<LinkTable
variant="table"
class="w-full"
:items="favoritesStore.favorites"
/>
</AutoScroll>
</div>
</template> </template>

View File

@@ -7,7 +7,9 @@ import { ref, computed, defineAsyncComponent } from "vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { usePostsStore } from "@/stores/posts"; import { usePostsStore } from "@/stores/posts";
const CreatePost = defineAsyncComponent(() => import("@/views/admin/CreatePost.vue")); const CreatePost = defineAsyncComponent(
() => import("@/views/admin/CreatePost.vue"),
);
const authStore = useAuthStore(); const authStore = useAuthStore();
const postsStore = usePostsStore(); const postsStore = usePostsStore();
@@ -20,75 +22,70 @@ const rightCap = computed(() => idx.value === postsStore.postsCount - 1);
const post = computed(() => postsStore.posts[idx.value]); const post = computed(() => postsStore.posts[idx.value]);
const userOwnsPost = computed( const userOwnsPost = computed(
() => post.value.author.username == authStore.user.username, () => post.value.author.username == authStore.user.username,
); );
function nextPost() { function nextPost() {
if (idx.value < postsStore.postsCount - 1) { if (idx.value < postsStore.postsCount - 1) {
idx.value++; idx.value++;
} }
} }
function prevPost() { function prevPost() {
if (idx.value > 0) { if (idx.value > 0) {
idx.value--; idx.value--;
} }
} }
function deletePost() { function deletePost() {
postsStore.deletePost(post.value); postsStore.deletePost(post.value);
} }
</script> </script>
<template> <template>
<div class="flex flex-col flex-1 min-h-0"> <div class="flex flex-col flex-1 min-h-0">
<Header> <Header>
<span class="flex items-center justify-between w-full"> <span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Post" : post.title }} {{ showCreate ? "Create Post" : post.title }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate"> <button
{{ showCreate ? "x" : "+" }} v-if="authStore.user.admin"
</button> class="text-sm px-1"
</span> @click="showCreate = !showCreate"
</Header>
<CreatePost v-if="showCreate" class="flex-1 min-h-0 p-1" @done="showCreate = false" @cancel="showCreate = false" />
<div
v-if="!showCreate"
class="flex flex-col flex-1 min-h-0 p-1 overflow-auto text-left items-start justify-start"
> >
<small {{ showCreate ? "x" : "+" }}
>Created at: </button>
{{ new Date(post.createdAt).toLocaleString() }}</small </span>
> </Header>
<small>By: {{ post.author.username }}</small> <CreatePost
<Markdown v-if="showCreate"
class="flex-1 border-box text-wrap" class="flex-1 min-h-0 p-1"
:source="post.content" @done="showCreate = false"
/> @cancel="showCreate = false"
<div class="flex flex-row w-full"> />
<Button <div
class="flex-1 border-box" v-if="!showCreate"
v-if="!leftCap" class="flex flex-col flex-1 min-h-0 p-1 overflow-auto text-left items-start justify-start"
@click="prevPost" >
> <small>Created at: {{ new Date(post.createdAt).toLocaleString() }}</small>
Prev <small>By: {{ post.author.username }}</small>
</Button> <Markdown class="flex-1 border-box text-wrap" :source="post.content" />
<Button <div class="flex flex-row w-full">
class="flex-1 border-box" <Button class="flex-1 border-box" v-if="!leftCap" @click="prevPost">
v-if="!rightCap" Prev
@click="nextPost" </Button>
> <Button class="flex-1 border-box" v-if="!rightCap" @click="nextPost">
Next Next
</Button> </Button>
</div> </div>
<Button class="w-full" v-if="userOwnsPost" @click="deletePost" <Button class="w-full" v-if="userOwnsPost" @click="deletePost"
>Delete</Button >Delete</Button
> >
</div>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
img { img {
width: 100%; width: 100%;
} }
</style> </style>

View File

@@ -2,25 +2,25 @@
import Header from "@/components/text/Header.vue"; import Header from "@/components/text/Header.vue";
import LinkTable from "@/components/util/LinkTable.vue"; import LinkTable from "@/components/util/LinkTable.vue";
const gym = [ const gym = [
{ name: "Row", type: "30 min" }, { name: "Row", type: "30 min" },
{ name: "Run", type: "5k" }, { name: "Run", type: "5k" },
{ name: "Pushup & Squat", type: "50" }, { name: "Pushup & Squat", type: "50" },
]; ];
</script> </script>
<template> <template>
<div class="flex flex-col place-content-between items-center"> <div class="flex flex-col place-content-between items-center">
<Header>Gym</Header> <Header>Gym</Header>
<p>I'm not a gym geek</p> <p>I'm not a gym geek</p>
<p>4/7 days I do:</p> <p>4/7 days I do:</p>
<div class="overflow-scroll w-full border-box"> <div class="overflow-scroll w-full border-box">
<LinkTable variant="table" class="w-full" :items="gym" /> <LinkTable variant="table" class="w-full" :items="gym" />
</div>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
img { img {
width: 100%; width: 100%;
} }
</style> </style>

View File

@@ -5,7 +5,9 @@ import { useHomeDataStore } from "@/stores/homeData";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
const CreateRowing = defineAsyncComponent(() => import("@/views/admin/CreateRowing.vue")); const CreateRowing = defineAsyncComponent(
() => import("@/views/admin/CreateRowing.vue"),
);
const store = useHomeDataStore(); const store = useHomeDataStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -117,13 +119,22 @@ function formatValue(key, val) {
<Header> <Header>
<span class="flex items-center justify-between w-full"> <span class="flex items-center justify-between w-full">
{{ showCreate ? "Upload Rowing" : "Rowing" }} {{ showCreate ? "Upload Rowing" : "Rowing" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate"> <button
v-if="authStore.user.admin"
class="text-sm px-1"
@click="showCreate = !showCreate"
>
{{ showCreate ? "x" : "+" }} {{ showCreate ? "x" : "+" }}
</button> </button>
</span> </span>
</Header> </Header>
<CreateRowing v-if="showCreate" class="flex-1 p-1" @done="showCreate = false" @cancel="showCreate = false" /> <CreateRowing
v-if="showCreate"
class="flex-1 p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<div v-else-if="loading" class="flex-1 flex items-center justify-center"> <div v-else-if="loading" class="flex-1 flex items-center justify-center">
<p>Loading...</p> <p>Loading...</p>
</div> </div>

View File

@@ -25,223 +25,219 @@ import Bookmarks from "./Bookmarks.vue";
</script> </script>
<template> <template>
<main class="justify-center flex flex-row w-full h-full overflow-x-hidden"> <main class="justify-center flex flex-row w-full h-full overflow-x-hidden">
<div class="outerWrap flex flex-row"> <div class="outerWrap flex flex-row">
<div class="sidebar"> <div class="sidebar">
<Time class="time-sidebar sidebar-cell" /> <Time class="time-sidebar sidebar-cell" />
<Timer class="timer-sidebar sidebar-cell" /> <Timer class="timer-sidebar sidebar-cell" />
<Radio class="radio-sidebar sidebar-cell" /> <Radio class="radio-sidebar sidebar-cell" />
<CommitHistory class="commits-sidebar flex-1 sidebar-cell" /> <CommitHistory class="commits-sidebar flex-1 sidebar-cell" />
<!-- <Elle class="flex-1" /> --> <!-- <Elle class="flex-1" /> -->
<!-- <MusicPlayer /> --> <!-- <MusicPlayer /> -->
<img <img
src="/img/memes/fire-woman.gif" src="/img/memes/fire-woman.gif"
alt="" alt=""
width="178" width="178"
height="178" height="178"
class="border-tertiary border-2 sidebar-image box-border w-full bg-tertiary" class="border-tertiary border-2 sidebar-image box-border w-full bg-tertiary"
loading="lazy" loading="lazy"
/> />
</div> </div>
<div <div class="a4page-portrait homeGrid relative bdr-1 border-quaternary">
class="a4page-portrait homeGrid relative bdr-1 border-quaternary" <!-- <Intro class="intro" /> -->
> <Intro2 class="intro grid-cell" />
<!-- <Intro class="intro" /> --> <!-- <BadApple class="intro" /> -->
<Intro2 class="intro grid-cell" /> <Listening class="listening grid-cell" />
<!-- <BadApple class="intro" /> --> <Stamps class="stamps grid-cell" />
<Listening class="listening grid-cell" /> <Feed class="feed grid-cell" />
<Stamps class="stamps grid-cell" /> <Links class="links grid-cell" />
<Feed class="feed grid-cell" /> <Collage class="collage grid-cell" />
<Links class="links grid-cell" /> <Consumption class="consumption grid-cell" />
<Collage class="collage grid-cell" /> <Favorites class="favorites grid-cell" />
<Consumption class="consumption grid-cell" /> <!-- <Gym class="gym" /> -->
<Favorites class="favorites grid-cell" /> <Gym2 class="gym grid-cell" />
<!-- <Gym class="gym" /> --> </div>
<Gym2 class="gym grid-cell" /> <div class="sidebar">
</div> <Steam class="steam-sidebar sidebar-cell" />
<div class="sidebar"> <Bookmarks class="bookmarks-sidebar sidebar-cell" />
<Steam class="steam-sidebar sidebar-cell" /> <Chat class="chat-sidebar flex-1 min-h-0 chat-home sidebar-cell" />
<Bookmarks class="bookmarks-sidebar sidebar-cell" /> <Miku
<Chat class="sidebar-image miku-image box-border border-tertiary border-2 bg-bg_primary"
class="chat-sidebar flex-1 min-h-0 chat-home sidebar-cell" />
/> </div>
<Miku </div>
class="sidebar-image miku-image box-border border-tertiary border-2 bg-bg_primary" </main>
/>
</div>
</div>
</main>
</template> </template>
<style scoped> <style scoped>
.grid-cell { .grid-cell {
background-color: var(--bg_primary); background-color: var(--bg_primary);
border-width: 2px; border-width: 2px;
border-style: solid; border-style: solid;
border-color: var(--quaternary); border-color: var(--quaternary);
} }
.intro { .intro {
grid-area: intro; grid-area: intro;
} }
.listening { .listening {
grid-area: listening; grid-area: listening;
} }
.stamps { .stamps {
grid-area: stamps; grid-area: stamps;
} }
.feed { .feed {
grid-area: feed; grid-area: feed;
} }
.links { .links {
grid-area: links; grid-area: links;
} }
.collage { .collage {
grid-area: collage; grid-area: collage;
} }
.consumption { .consumption {
grid-area: consumption; grid-area: consumption;
} }
.gym { .gym {
grid-area: gym; grid-area: gym;
} }
.favorites { .favorites {
grid-area: favorites; grid-area: favorites;
} }
.sidebar-cell { .sidebar-cell {
background-color: var(--bg_primary); background-color: var(--bg_primary);
border-width: 2px; border-width: 2px;
border-style: solid; border-style: solid;
border-color: var(--quaternary); border-color: var(--quaternary);
} }
.sidebar { .sidebar {
margin: 0; margin: 0;
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 15rem; width: 15rem;
min-height: 0; min-height: 0;
gap: 5px; gap: 5px;
} }
.outerWrap { .outerWrap {
height: 310mm; height: 310mm;
gap: 10px; gap: 10px;
} }
.homeGrid { .homeGrid {
display: grid; display: 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);
grid-template-areas: grid-template-areas:
"intro intro intro intro intro intro listening listening listening listening" "intro intro intro intro intro intro listening listening listening listening"
"intro intro intro intro intro intro listening listening listening listening" "intro intro intro intro intro intro listening listening listening listening"
"intro intro intro intro intro intro listening listening listening listening" "intro intro intro intro intro intro listening listening listening listening"
"intro intro intro intro intro intro stamps stamps stamps stamps" "intro intro intro intro intro intro stamps stamps stamps stamps"
"feed feed feed links links collage collage collage collage collage" "feed feed feed links links collage collage collage collage collage"
"feed feed feed links links collage collage collage collage collage" "feed feed feed links links collage collage collage collage collage"
"feed feed feed links links collage collage collage collage collage" "feed feed feed links links collage collage collage collage collage"
"feed feed feed links links collage collage collage collage collage" "feed feed feed links links collage collage collage collage collage"
"consumption consumption consumption consumption gym gym gym favorites favorites favorites" "consumption consumption consumption consumption gym gym gym favorites favorites favorites"
"consumption consumption consumption consumption gym gym gym favorites favorites favorites"; "consumption consumption consumption consumption gym gym gym favorites favorites favorites";
} }
.chat-home { .chat-home {
max-height: 800px; max-height: 800px;
} }
.miku-image { .miku-image {
height: 15rem; height: 15rem;
} }
@media (max-width: 1360px) { @media (max-width: 1360px) {
.outerWrap { .outerWrap {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
height: auto; height: auto;
} }
.homeGrid { .homeGrid {
order: -1; order: -1;
width: 95vw; width: 95vw;
height: 297mm; height: 297mm;
margin-inline: 0; margin-inline: 0;
box-sizing: border-box; box-sizing: border-box;
} }
.sidebar { .sidebar {
width: 95vw; width: 95vw;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
flex: unset; flex: unset;
justify-content: space-around; justify-content: space-around;
} }
.commits-sidebar, .commits-sidebar,
.steam-sidebar, .steam-sidebar,
.bookmarks-sidebar { .bookmarks-sidebar {
width: 100%; width: 100%;
max-height: 300px; max-height: 300px;
} }
.chat-sidebar { .chat-sidebar {
width: 100%; width: 100%;
min-height: 400px; min-height: 400px;
max-height: 800px; max-height: 800px;
height: 25vh; height: 25vh;
} }
.time-sidebar, .time-sidebar,
.sidebar-image, .sidebar-image,
.radio-sidebar, .radio-sidebar,
.timer-sidebar { .timer-sidebar {
display: none; display: none;
} }
/* .sidebar-image { */ /* .sidebar-image { */
/* max-height: 150px; */ /* max-height: 150px; */
/* max-width: 150px; */ /* max-width: 150px; */
/* object-fit: contain; */ /* object-fit: contain; */
/* } */ /* } */
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.homeGrid { .homeGrid {
border-image: none; border-image: none;
border-width: 0; border-width: 0;
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: repeat(11, 1fr); grid-template-rows: repeat(11, 1fr);
height: 150vh; height: 150vh;
grid-template-areas: grid-template-areas:
"intro intro intro" "intro intro intro"
"intro intro intro" "intro intro intro"
"listening stamps stamps" "listening stamps stamps"
"listening feed feed" "listening feed feed"
"links feed feed" "links feed feed"
"links feed feed" "links feed feed"
"links feed feed" "links feed feed"
"favorites feed feed" "favorites feed feed"
"favorites consumption consumption" "favorites consumption consumption"
"favorites consumption consumption" "favorites consumption consumption"
"favorites consumption consumption"; "favorites consumption consumption";
} }
.collage { .collage {
display: none; display: none;
} }
.gym { .gym {
display: none; display: none;
} }
} }
.bg-random { .bg-random {
background-color: var(--bg_primary); background-color: var(--bg_primary);
background-image: url("/img/miku/miku2.gif"); background-image: url("/img/miku/miku2.gif");
background-size: 10px 10px; background-size: 10px 10px;
} }
</style> </style>

View File

@@ -4,27 +4,29 @@ import Paragraph from "@/components/text/Paragraph.vue";
</script> </script>
<template> <template>
<div class="flex-1 border-box flex flex-col p-1 text-left items-start justify-start"> <div
<Header>Yo</Header> class="flex-1 border-box flex flex-col p-1 text-left items-start justify-start"
<!-- <Header>Intro</Header> --> >
<!-- <Paragraph> --> <Header>Yo</Header>
<!-- Hi, I'm Adam, thank you for visiting my website. --> <!-- <Header>Intro</Header> -->
<!-- </Paragraph> --> <!-- <Paragraph> -->
<!-- <Header>Getting around</Header> --> <!-- Hi, I'm Adam, thank you for visiting my website. -->
<!-- <Paragraph> --> <!-- </Paragraph> -->
<!-- Pages available can be traversed through links below. I am hoping to --> <!-- <Header>Getting around</Header> -->
<!-- add some shrines, code-walkthoughs, live chat and page transitions --> <!-- <Paragraph> -->
<!-- at a later date. --> <!-- Pages available can be traversed through links below. I am hoping to -->
<!-- </Paragraph> --> <!-- add some shrines, code-walkthoughs, live chat and page transitions -->
<!-- <Header>Contact</Header> --> <!-- at a later date. -->
<!-- <Paragraph> --> <!-- </Paragraph> -->
<!-- Please email me <a href="mailto:adam.a.french@outlook.com">here</a>, --> <!-- <Header>Contact</Header> -->
<!-- or contact me though any of the social medias linked. --> <!-- <Paragraph> -->
<!-- </Paragraph> --> <!-- Please email me <a href="mailto:adam.a.french@outlook.com">here</a>, -->
<!-- <Header>A Quote</Header> --> <!-- or contact me though any of the social medias linked. -->
<!-- <Paragraph> --> <!-- </Paragraph> -->
<!-- One crossed wire, one wayward pinch of potassium chlorate, one --> <!-- <Header>A Quote</Header> -->
<!-- errant twitch, and KA-BLOOIE! --> <!-- <Paragraph> -->
<!-- </Paragraph> --> <!-- One crossed wire, one wayward pinch of potassium chlorate, one -->
</div> <!-- errant twitch, and KA-BLOOIE! -->
<!-- </Paragraph> -->
</div>
</template> </template>

View File

@@ -3,46 +3,46 @@ import { rand } from "@vueuse/core";
import { ref, onMounted, onUnmounted, nextTick } from "vue"; import { ref, onMounted, onUnmounted, nextTick } from "vue";
interface Item { interface Item {
x: number; x: number;
y: number; y: number;
dx: number; dx: number;
dy: number; dy: number;
content: string; content: string;
} }
const container = ref<HTMLDivElement | null>(null); const container = ref<HTMLDivElement | null>(null);
const itemEls = ref<HTMLDivElement[]>([]); const itemEls = ref<HTMLDivElement[]>([]);
const phrases = [ const phrases = [
"Welcome to my website", "Welcome to my website",
"I'm looking to do a big revamp", "I'm looking to do a big revamp",
"A4 sheets of paper are what I'm used to", "A4 sheets of paper are what I'm used to",
"I'd love to know your recommendations", "I'd love to know your recommendations",
"for very short books", "for very short books",
"Message me by any means necessary", "Message me by any means necessary",
"I like anime, all kinds of music and sci fic", "I like anime, all kinds of music and sci fic",
]; ];
// Non-reactive animation state to avoid triggering Vue re-renders every frame // Non-reactive animation state to avoid triggering Vue re-renders every frame
const animState = phrases.map((text, i) => ({ const animState = phrases.map((text, i) => ({
x: 0, x: 0,
y: i * 20, y: i * 20,
dx: rand(0, 60) / 100, dx: rand(0, 60) / 100,
dy: 1.0, dy: 1.0,
content: text, content: text,
cachedW: 0, cachedW: 0,
cachedH: 0, cachedH: 0,
})); }));
// Reactive items only for initial render // Reactive items only for initial render
const items = ref<Item[]>( const items = ref<Item[]>(
animState.map((s) => ({ animState.map((s) => ({
x: s.x, x: s.x,
y: s.y, y: s.y,
dx: s.dx, dx: s.dx,
dy: s.dy, dy: s.dy,
content: s.content, content: s.content,
})), })),
); );
let rafId = 0; let rafId = 0;
@@ -52,79 +52,76 @@ let lastFrameTime = 0;
const FRAME_INTERVAL = 1000 / 30; const FRAME_INTERVAL = 1000 / 30;
function measureSizes() { function measureSizes() {
const c = container.value; const c = container.value;
if (c) { if (c) {
cachedCW = c.clientWidth; cachedCW = c.clientWidth;
cachedCH = c.clientHeight; cachedCH = c.clientHeight;
}
itemEls.value.forEach((el, i) => {
if (el && animState[i]) {
animState[i].cachedW = el.offsetWidth;
animState[i].cachedH = el.offsetHeight;
} }
itemEls.value.forEach((el, i) => { });
if (el && animState[i]) {
animState[i].cachedW = el.offsetWidth;
animState[i].cachedH = el.offsetHeight;
}
});
} }
function animate(timestamp: number) { function animate(timestamp: number) {
if (!cachedCW || !cachedCH) { if (!cachedCW || !cachedCH) {
rafId = requestAnimationFrame(animate);
return;
}
if (timestamp - lastFrameTime < FRAME_INTERVAL) {
rafId = requestAnimationFrame(animate);
return;
}
lastFrameTime = timestamp;
for (let i = 0; i < animState.length; i++) {
const s = animState[i];
const el = itemEls.value[i];
if (!el) continue;
s.x += s.dx;
s.y += s.dy;
if (s.x < 0 || s.x > cachedCW - s.cachedW) s.dx *= -1;
if (s.y < 0 || s.y > cachedCH - s.cachedH) s.dy *= -1;
el.style.transform = `translate(${s.x}px, ${s.y}px)`;
}
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
return;
}
if (timestamp - lastFrameTime < FRAME_INTERVAL) {
rafId = requestAnimationFrame(animate);
return;
}
lastFrameTime = timestamp;
for (let i = 0; i < animState.length; i++) {
const s = animState[i];
const el = itemEls.value[i];
if (!el) continue;
s.x += s.dx;
s.y += s.dy;
if (s.x < 0 || s.x > cachedCW - s.cachedW) s.dx *= -1;
if (s.y < 0 || s.y > cachedCH - s.cachedH) s.dy *= -1;
el.style.transform = `translate(${s.x}px, ${s.y}px)`;
}
rafId = requestAnimationFrame(animate);
} }
let resizeObserver: ResizeObserver; let resizeObserver: ResizeObserver;
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
measureSizes(); measureSizes();
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
resizeObserver = new ResizeObserver(measureSizes); resizeObserver = new ResizeObserver(measureSizes);
resizeObserver.observe(container.value!); resizeObserver.observe(container.value!);
}); });
onUnmounted(() => { onUnmounted(() => {
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
resizeObserver?.disconnect(); resizeObserver?.disconnect();
}); });
</script> </script>
<template> <template>
<div ref="container" class="w-full h-full relative overflow-hidden">
<div <div
ref="container" v-for="(item, i) in items"
class="w-full h-full relative overflow-hidden" :key="i"
ref="itemEls"
class="absolute w-fit h-fit"
> >
<div <h1>
v-for="(item, i) in items" {{ item.content }}
:key="i" </h1>
ref="itemEls"
class="absolute w-fit h-fit"
>
<h1>
{{ item.content }}
</h1>
</div>
</div> </div>
</div>
</template> </template>

View File

@@ -4,31 +4,31 @@ import LinkTable from "@/components/util/LinkTable.vue";
import Header from "@/components/text/Header.vue"; import Header from "@/components/text/Header.vue";
const site_links = [ const site_links = [
{ name: "CV", link: "/cv" }, { name: "CV", link: "/cv" },
{ name: "Bookmarks", link: "/bookmarks" }, { name: "Bookmarks", link: "/bookmarks" },
{ name: "Shrines", link: "/shrines" }, { name: "Shrines", link: "/shrines" },
{ name: "Admin", link: "/admin" }, { name: "Admin", link: "/admin" },
]; ];
const social_links = [ const social_links = [
{ name: "Gitea", link: "/gitea/explore/repos" }, { name: "Gitea", link: "/gitea/explore/repos" },
{ name: "Steam", link: "https://steamcommunity.com/id/SteveThePug" }, { name: "Steam", link: "https://steamcommunity.com/id/SteveThePug" },
{ name: "Github", link: "https://github.com/SteveThePug" }, { name: "Github", link: "https://github.com/SteveThePug" },
{ name: "Spotify", link: "https://open.spotify.com/user/stevethepug" }, { name: "Spotify", link: "https://open.spotify.com/user/stevethepug" },
{ name: "Notes", link: "/notes/" }, { name: "Notes", link: "/notes/" },
]; ];
</script> </script>
<template> <template>
<div class="flex flex-col overflow-auto"> <div class="flex flex-col overflow-auto">
<Header>Links</Header> <Header>Links</Header>
<div class="flex flex-col justify-between flex-1"> <div class="flex flex-col justify-between flex-1">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<RouterTable :linkArr="site_links" /> <RouterTable :linkArr="site_links" />
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<LinkTable :items="social_links" /> <LinkTable :items="social_links" />
</div> </div>
</div>
</div> </div>
</div>
</template> </template>

View File

@@ -11,75 +11,76 @@ let nextId = null;
let refreshId = null; let refreshId = null;
function nextSong() { function nextSong() {
clearTimeout(nextId); clearTimeout(nextId);
nextId = setTimeout(nextSong, 5000); nextId = setTimeout(nextSong, 5000);
idx.value = (idx.value + 1) % songsStore.songsCount; idx.value = (idx.value + 1) % songsStore.songsCount;
} }
onMounted(() => { onMounted(() => {
songsStore.fetchSongs(); songsStore.fetchSongs();
nextId = setTimeout(nextSong, 5000); nextId = setTimeout(nextSong, 5000);
refreshId = setInterval(songsStore.fetchSongs, 120000); refreshId = setInterval(songsStore.fetchSongs, 120000);
}); });
onUnmounted(() => { onUnmounted(() => {
clearTimeout(nextId); clearTimeout(nextId);
clearInterval(refreshId); clearInterval(refreshId);
}); });
</script> </script>
<template> <template>
<div class="listening-wrapper"> <div class="listening-wrapper">
<Transition name="fade"> <Transition name="fade">
<div <div
@click="nextSong" @click="nextSong"
:key="song.track.name" :key="song.track.name"
class="flex flex-col items-center pt-2" class="flex flex-col items-center pt-2"
> >
<Header>Listening To</Header> <Header>Listening To</Header>
<img :src="song.track.album.images[0].url" :alt="song.track.album.name + ' album art'" /> <img
<p class="text-center"> :src="song.track.album.images[0].url"
<strong>Song:</strong> {{ song.track.name }} :alt="song.track.album.name + ' album art'"
</p> />
<p class="text-center"> <p class="text-center"><strong>Song:</strong> {{ song.track.name }}</p>
<strong>Artist:</strong> {{ song.track.artists[0].name }} <p class="text-center">
</p> <strong>Artist:</strong> {{ song.track.artists[0].name }}
</div> </p>
</div>
</Transition> </Transition>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.listening-wrapper { .listening-wrapper {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
overflow-y: auto; overflow-y: auto;
} }
img { img {
width: 70%; width: 70%;
max-width: 100%; max-width: 100%;
height: auto; height: auto;
} }
p { p {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
} }
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.5s ease; transition: opacity 0.5s ease;
} }
.fade-leave-active { .fade-leave-active {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
} }
.fade-enter-from, .fade-enter-from,
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
</style> </style>

View File

@@ -2,12 +2,12 @@
import Slideshow from "@/components/util/Slideshow.vue"; import Slideshow from "@/components/util/Slideshow.vue";
const images = [ const images = [
{ url: "/img/miku/miku1.gif" }, { url: "/img/miku/miku1.gif" },
{ url: "/img/miku/miku2.gif" }, { url: "/img/miku/miku2.gif" },
// { url: "/img/miku/miku2.png" }, // { url: "/img/miku/miku2.png" },
]; ];
</script> </script>
<template> <template>
<Slideshow class="p-5" :images="images" :interval="10000" /> <Slideshow class="p-5" :images="images" :interval="10000" />
</template> </template>

View File

@@ -6,20 +6,20 @@ import Link from "@/components/text/Link.vue";
import { shuffleArray } from "@/js/utils.js"; import { shuffleArray } from "@/js/utils.js";
let srcs = [ let srcs = [
"/img/stamps/portal.gif", "/img/stamps/portal.gif",
"/img/stamps/miku.gif", "/img/stamps/miku.gif",
"/img/stamps/utau.gif", "/img/stamps/utau.gif",
"/img/stamps/teto.webp", "/img/stamps/teto.webp",
"/img/stamps/3ds.jpg", "/img/stamps/3ds.jpg",
"/img/stamps/fry.png", "/img/stamps/fry.png",
"/img/stamps/ai.png", "/img/stamps/ai.png",
"/img/stamps/rei.png", "/img/stamps/rei.png",
"/img/stamps/tetris.gif", "/img/stamps/tetris.gif",
"/img/stamps/tf2.gif", "/img/stamps/tf2.gif",
"/img/stamps/demo.gif", "/img/stamps/demo.gif",
"/img/stamps/demo.gif", "/img/stamps/demo.gif",
"/img/stamps/demo.gif", "/img/stamps/demo.gif",
"/img/stamps/demo.gif", "/img/stamps/demo.gif",
]; ];
shuffleArray(srcs); shuffleArray(srcs);
@@ -31,71 +31,76 @@ let dx = 0.2;
let dy = 0.12; let dy = 0.12;
function bounce() { function bounce() {
const el = touchscreen.value?.$el; const el = touchscreen.value?.$el;
if (!el) return; if (!el) return;
const maxX = el.scrollWidth - el.clientWidth; const maxX = el.scrollWidth - el.clientWidth;
const maxY = el.scrollHeight - el.clientHeight; const maxY = el.scrollHeight - el.clientHeight;
if (maxX > 0) { if (maxX > 0) {
posX += dx; posX += dx;
if (posX <= 0) { if (posX <= 0) {
posX = 0; posX = 0;
dx = -dx; dx = -dx;
} else if (posX >= maxX) { } else if (posX >= maxX) {
posX = maxX; posX = maxX;
dx = -dx; dx = -dx;
}
el.scrollLeft = posX;
} }
if (maxY > 0) { el.scrollLeft = posX;
posY += dy; }
if (posY <= 0) { if (maxY > 0) {
posY = 0; posY += dy;
dy = -dy; if (posY <= 0) {
} else if (posY >= maxY) { posY = 0;
posY = maxY; dy = -dy;
dy = -dy; } else if (posY >= maxY) {
} posY = maxY;
el.scrollTop = posY; dy = -dy;
} }
el.scrollTop = posY;
}
animId = requestAnimationFrame(bounce); animId = requestAnimationFrame(bounce);
} }
onMounted(() => { onMounted(() => {
animId = requestAnimationFrame(bounce); animId = requestAnimationFrame(bounce);
}); });
onUnmounted(() => { onUnmounted(() => {
if (animId) cancelAnimationFrame(animId); if (animId) cancelAnimationFrame(animId);
}); });
</script> </script>
<template> <template>
<Touchscreen ref="touchscreen"> <Touchscreen ref="touchscreen">
<div class="flex flex-wrap tst"> <div class="flex flex-wrap tst">
<Link bare href="https://www.adam-french.co.uk"> <Link bare href="https://www.adam-french.co.uk">
<img src="https://www.adam-french.co.uk/img/stamps/mine.gif" alt="adam-french.co.uk" /> <img
</Link> src="https://www.adam-french.co.uk/img/stamps/mine.gif"
<Link bare href="https://jacobbarron.xyz"> alt="adam-french.co.uk"
<img />
src="https://jacobbarron.xyz/Banneh.gif" </Link>
alt="jacobbarron.xyz" <Link bare href="https://jacobbarron.xyz">
/> <img src="https://jacobbarron.xyz/Banneh.gif" alt="jacobbarron.xyz" />
</Link> </Link>
<img v-for="src in srcs" :src="src" :alt="src.split('/').pop().split('.')[0]" loading="lazy" /> <img
</div> v-for="src in srcs"
</Touchscreen> :src="src"
:alt="src.split('/').pop().split('.')[0]"
loading="lazy"
/>
</div>
</Touchscreen>
</template> </template>
<style scoped> <style scoped>
img { img {
width: 89px; width: 89px;
height: 59px; height: 59px;
} }
.tst { .tst {
min-width: calc(89px * 4); min-width: calc(89px * 4);
} }
</style> </style>

View File

@@ -17,116 +17,116 @@ let nextId = null;
let refreshId = null; let refreshId = null;
function nextGame() { function nextGame() {
clearTimeout(nextId); clearTimeout(nextId);
nextId = setTimeout(nextGame, 5000); nextId = setTimeout(nextGame, 5000);
if (steamStatus.value.recentGames.length) { if (steamStatus.value.recentGames.length) {
idx.value = (idx.value + 1) % steamStatus.value.recentGames.length; idx.value = (idx.value + 1) % steamStatus.value.recentGames.length;
} }
} }
onMounted(() => { onMounted(() => {
nextId = setTimeout(nextGame, 5000); nextId = setTimeout(nextGame, 5000);
refreshId = setInterval(() => steamStore.fetchSteam(), 5 * 60 * 1000); refreshId = setInterval(() => steamStore.fetchSteam(), 5 * 60 * 1000);
}); });
onUnmounted(() => { onUnmounted(() => {
clearTimeout(nextId); clearTimeout(nextId);
clearInterval(refreshId); clearInterval(refreshId);
}); });
function formatHours(minutes) { function formatHours(minutes) {
const hrs = (minutes / 60).toFixed(1); const hrs = (minutes / 60).toFixed(1);
return `${hrs}h`; return `${hrs}h`;
} }
</script> </script>
<template> <template>
<div class="steam-wrapper"> <div class="steam-wrapper">
<Header class="text-left"> <Header class="text-left">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
Steam Steam
<span <span
class="inline-block w-2 h-2 rounded-full" class="inline-block w-2 h-2 rounded-full"
:class="steamStatus.online ? 'bg-green-500' : 'bg-gray-400'" :class="steamStatus.online ? 'bg-green-500' : 'bg-gray-400'"
:title="steamStatus.online ? 'Online' : 'Offline'" :title="steamStatus.online ? 'Online' : 'Offline'"
/> />
</span> </span>
</Header> </Header>
<div v-if="!loaded" class="p-2 text-sm">Loading...</div> <div v-if="!loaded" class="p-2 text-sm">Loading...</div>
<div v-else-if="game" class="game-container"> <div v-else-if="game" class="game-container">
<Transition name="fade"> <Transition name="fade">
<div <div
@click="nextGame" @click="nextGame"
:key="game.appId" :key="game.appId"
class="flex flex-col items-center pt-2" class="flex flex-col items-center pt-2"
> >
<img <img
:src="game.headerImageUrl" :src="game.headerImageUrl"
:alt="game.name" :alt="game.name"
width="145" width="145"
height="68" height="68"
class="game-img" class="game-img"
/> />
<p class="text-center"> <p class="text-center">
<strong>{{ game.name }}</strong> <strong>{{ game.name }}</strong>
</p> </p>
<p class="text-center text-tertiary text-xs"> <p class="text-center text-tertiary text-xs">
{{ formatHours(game.playtime2Weeks) }} last 2 weeks {{ formatHours(game.playtime2Weeks) }} last 2 weeks
</p> </p>
</div>
</Transition>
</div> </div>
</Transition>
<div v-else class="p-2 text-sm">No recent games.</div>
</div> </div>
<div v-else class="p-2 text-sm">No recent games.</div>
</div>
</template> </template>
<style scoped> <style scoped>
.steam-wrapper { .steam-wrapper {
position: relative; position: relative;
height: 54mm; height: 54mm;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.game-container { .game-container {
position: relative; position: relative;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: scroll; overflow-y: scroll;
} }
.game-img { .game-img {
width: 90%; width: 90%;
max-width: 300px; max-width: 300px;
height: auto; height: auto;
} }
p { p {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
} }
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.5s ease; transition: opacity 0.5s ease;
} }
.fade-leave-active { .fade-leave-active {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
} }
.fade-enter-from, .fade-enter-from,
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
@media (max-width: 850px) { @media (max-width: 850px) {
.steam-wrapper { .steam-wrapper {
height: auto; height: auto;
min-height: 120px; min-height: 120px;
} }
} }
</style> </style>

View File

@@ -3,27 +3,25 @@ import VideoTable from "@/components/util/VideoTable.vue";
import Link from "@/components/text/Link.vue"; import Link from "@/components/text/Link.vue";
const videoSources = [ const videoSources = [
{ name: "demoman", link: "/img/demoman/1760582395316219.webm" }, { name: "demoman", link: "/img/demoman/1760582395316219.webm" },
{ name: "demoman", link: "/img/demoman/1761052136609718.webm" }, { name: "demoman", link: "/img/demoman/1761052136609718.webm" },
{ name: "demoman", link: "/img/demoman/1761088452011210.mp4" }, { name: "demoman", link: "/img/demoman/1761088452011210.mp4" },
{ name: "demoman", link: "/img/demoman/1761570214170465.webm" }, { name: "demoman", link: "/img/demoman/1761570214170465.webm" },
{ name: "demoman", link: "/img/demoman/1761828457509465.webm" }, { name: "demoman", link: "/img/demoman/1761828457509465.webm" },
]; ];
</script> </script>
<template> <template>
<main class="items-center flex flex-col"> <main class="items-center flex flex-col">
<div <div
class="a4page-portrait bdr-1 flex flex-row relative overflow-scroll items-center" class="a4page-portrait bdr-1 flex flex-row relative overflow-scroll items-center"
> >
<p> <p>
<Link href="https://wiki.teamfortress.com/wiki/Demoman" <Link href="https://wiki.teamfortress.com/wiki/Demoman">The goat</Link>
>The goat</Link </p>
> <div>
</p> <VideoTable :sourceArr="videoSources" />
<div> </div>
<VideoTable :sourceArr="videoSources" /> </div>
</div> </main>
</div>
</main>
</template> </template>

View File

@@ -3,11 +3,11 @@ import Wip from "@/components/util/Wip.vue";
</script> </script>
<template> <template>
<main class="items-center flex flex-col"> <main class="items-center flex flex-col">
<div <div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll" class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
> >
<Wip /> <Wip />
</div> </div>
</main> </main>
</template> </template>

View File

@@ -4,7 +4,9 @@ import Wip from "@/components/util/Wip.vue";
<template> <template>
<main class="items-center flex flex-col"> <main class="items-center flex flex-col">
<div class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"> <div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
>
<Wip /> <Wip />
</div> </div>
</main> </main>

View File

@@ -1,20 +1,20 @@
<script setup> <script setup>
import RouterTable from "@/components/util/RouterTable.vue"; import RouterTable from "@/components/util/RouterTable.vue";
const shrine_links = [ const shrine_links = [
{ name: "Demoman", link: "/shrines/demoman" }, { name: "Demoman", link: "/shrines/demoman" },
{ name: "Evangelion", link: "/shrines/evangelion" }, { name: "Evangelion", link: "/shrines/evangelion" },
{ name: "GTO", link: "/shrines/gto" }, { name: "GTO", link: "/shrines/gto" },
{ name: "Skipskipbenben", link: "/shrines/skipskipbenben" }, { name: "Skipskipbenben", link: "/shrines/skipskipbenben" },
]; ];
</script> </script>
<template> <template>
<main class="items-center flex flex-col"> <main class="items-center flex flex-col">
<div class="background" /> <div class="background" />
<div <div
class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll gap-1" class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll gap-1"
> >
<RouterTable :linkArr="shrine_links" /> <RouterTable :linkArr="shrine_links" />
</div> </div>
</main> </main>
</template> </template>

View File

@@ -3,11 +3,11 @@ import Wip from "@/components/util/Wip.vue";
</script> </script>
<template> <template>
<main class="items-center flex flex-col"> <main class="items-center flex flex-col">
<div <div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll" class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
> >
<Wip /> <Wip />
</div> </div>
</main> </main>
</template> </template>

View File

@@ -5,52 +5,51 @@ import Header from "@/components/text/Header.vue";
import Paragraph from "@/components/text/Paragraph.vue"; import Paragraph from "@/components/text/Paragraph.vue";
const links = [ const links = [
{ name: "GitHub", href: "https://github.com/SteveThePug" }, { name: "GitHub", href: "https://github.com/SteveThePug" },
{ name: "Gitea", href: "/gitea/explore/repos" }, { name: "Gitea", href: "/gitea/explore/repos" },
{ name: "Spotify", href: "https://open.spotify.com/user/stevethepug" }, { name: "Spotify", href: "https://open.spotify.com/user/stevethepug" },
]; ];
</script> </script>
<template> <template>
<main class="flex justify-center px-4 py-16"> <main class="flex justify-center px-4 py-16">
<div class="max-w-xl w-full flex flex-col gap-12"> <div class="max-w-xl w-full flex flex-col gap-12">
<section> <section>
<Header>Adam French</Header> <Header>Adam French</Header>
<Paragraph> <Paragraph>
Junior software engineer focused on full-stack development, Junior software engineer focused on full-stack development, systems
systems programming, and infrastructure. First Class Honours programming, and infrastructure. First Class Honours in Computer
in Computer Science with Mathematics from Leeds and Science with Mathematics from Leeds and Waterloo.
Waterloo. </Paragraph>
</Paragraph> </section>
</section>
<section> <section>
<Header>About</Header> <Header>About</Header>
<Paragraph> <Paragraph>
This website is self-hosted and has a lot more on it than it This website is self-hosted and has a lot more on it than it needs to.
needs to. Please have a look at my Please have a look at my
<InlineLink to="/cv">CV</InlineLink> for a full breakdown of <InlineLink to="/cv">CV</InlineLink> for a full breakdown of my
my experience, projects, and skills. Please visit experience, projects, and skills. Please visit
<InlineLink to="/stp">STP</InlineLink> for the prefered but <InlineLink to="/stp">STP</InlineLink> for the prefered but less
less professional experience. professional experience.
</Paragraph> </Paragraph>
</section> </section>
<nav class="navRow flex flex-row flex-wrap gap-4 justify-around"> <nav class="navRow flex flex-row flex-wrap gap-4 justify-around">
<Link to="/cv"> CV </Link> <Link to="/cv"> CV </Link>
<Link to="/stp"> STP </Link> <Link to="/stp"> STP </Link>
<Link href="mailto:adam.a.french@outlook.com"> Email </Link> <Link href="mailto:adam.a.french@outlook.com"> Email </Link>
<Link v-for="link in links" :key="link.name" :href="link.href"> <Link v-for="link in links" :key="link.name" :href="link.href">
{{ link.name }} {{ link.name }}
</Link> </Link>
</nav> </nav>
</div> </div>
</main> </main>
</template> </template>
<style scoped> <style scoped>
.navRow > a { .navRow > a {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: 1px solid currentColor; border: 1px solid currentColor;
} }
</style> </style>

View File

@@ -1,72 +1,70 @@
<template> <template>
<main> <main>
<table id="cover-nav" class="cover-nav no-print"> <table id="cover-nav" class="cover-nav no-print">
<thead> <thead>
<tr> <tr>
<th>Companies</th> <th>Companies</th>
<th>Completed</th> <th>Completed</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><a href="#LloydsBank">Lloyds</a></td> <td><a href="#LloydsBank">Lloyds</a></td>
<td>YES</td> <td>YES</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="no-print m-1 w-full text-center"></div> <div class="no-print m-1 w-full text-center"></div>
<div id="LloydsBank" class="a5page"> <div id="LloydsBank" class="a5page">
<div class="contact"> <div class="contact">
<h1>Adam French</h1> <h1>Adam French</h1>
<!-- <a href="index.html"><img width=25 height=50 src="img/rune.png"></a> --> <!-- <a href="index.html"><img width=25 height=50 src="img/rune.png"></a> -->
<div class="contact-details"> <div class="contact-details">
<p>+447563266931</p> <p>+447563266931</p>
<p>adam.a.french@outlook.com</p> <p>adam.a.french@outlook.com</p>
</div>
</div>
<h2>BAE graduate digital intelligence software engineer</h2>
<p>
I am writing to express my interest in your software engineering
position. BAE Systems has hosted multiple stools at the
University of Leeds and have always exhibited their development
of leading-edge software and technology. This is where the
origin of my interest in BAE systems emerged and I'm hopeful
that this interest shall continue.
</p>
<p>
I'm confidient im a strong fit for this role. My technical
background includes extensive experience with frontend
frameworks such as React. My devotion however lies more in
backend development as has more potential to graple problems
related to optimisation and designing coherent interfaces.
</p>
<p>
<em> The C# Programming Yellow Book </em> was my first
introduction to C# during A-Level, Java was our vessel for
teaching object-orientated programming at university. I am
confident I have the relevant experience to grasp the languages
stated for the role I am applying for.
</p>
<p>
My academic background in Computer Science and Mathematics has
honed my ability to translate abstract concepts into structured,
logical solutions. Just as I have transformed theoretical
hypotheses into formal proofs, I aim to transform business
requirements into robust, maintainable software systems through
collaboration and rigorous reasoning.
</p>
<p>Thank you for reading - Adam F</p>
</div> </div>
</main> </div>
<h2>BAE graduate digital intelligence software engineer</h2>
<p>
I am writing to express my interest in your software engineering
position. BAE Systems has hosted multiple stools at the University of
Leeds and have always exhibited their development of leading-edge
software and technology. This is where the origin of my interest in BAE
systems emerged and I'm hopeful that this interest shall continue.
</p>
<p>
I'm confidient im a strong fit for this role. My technical background
includes extensive experience with frontend frameworks such as React. My
devotion however lies more in backend development as has more potential
to graple problems related to optimisation and designing coherent
interfaces.
</p>
<p>
<em> The C# Programming Yellow Book </em> was my first introduction to
C# during A-Level, Java was our vessel for teaching object-orientated
programming at university. I am confident I have the relevant experience
to grasp the languages stated for the role I am applying for.
</p>
<p>
My academic background in Computer Science and Mathematics has honed my
ability to translate abstract concepts into structured, logical
solutions. Just as I have transformed theoretical hypotheses into formal
proofs, I aim to transform business requirements into robust,
maintainable software systems through collaboration and rigorous
reasoning.
</p>
<p>Thank you for reading - Adam F</p>
</div>
</main>
</template> </template>
<style scoped> <style scoped>
@import "@/assets/css/cv_styles.css"; @import "@/assets/css/cv_styles.css";
@media print { @media print {
@page { @page {
size: A5 landscape; size: A5 landscape;
margin: 0; margin: 0;
} }
} }
</style> </style>

View File

@@ -16,60 +16,60 @@ const path = Array.isArray(pathArray) ? pathArray.join("/") : pathArray;
const url = `/api/notes/${path}`; const url = `/api/notes/${path}`;
function getFilename(headers) { function getFilename(headers) {
const disposition = headers["content-disposition"]; const disposition = headers["content-disposition"];
if (!disposition) return null; if (!disposition) return null;
const match = disposition.match(/filename="?([^"]+)"?/); const match = disposition.match(/filename="?([^"]+)"?/);
return match ? match[1] : null; return match ? match[1] : null;
} }
async function fetchFile() { async function fetchFile() {
const response = await axios.get(url, { responseType: "blob" }); const response = await axios.get(url, { responseType: "blob" });
filename.value = getFilename(response.headers); filename.value = getFilename(response.headers);
const lastModified = response.headers["last-modified"]; const lastModified = response.headers["last-modified"];
last_edited.value = lastModified ? new Date(lastModified) : null; last_edited.value = lastModified ? new Date(lastModified) : null;
if (filename.value.toLowerCase().endsWith(".md")) { if (filename.value.toLowerCase().endsWith(".md")) {
const text = await response.data.text(); const text = await response.data.text();
file.value = fixLinks(text); file.value = fixLinks(text);
} else { } else {
file.value = response.data; file.value = response.data;
} }
} }
function fixLinks(filedata) { function fixLinks(filedata) {
return filedata.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { return filedata.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
if ( if (
url.startsWith("http://") || url.startsWith("http://") ||
url.startsWith("https://") || url.startsWith("https://") ||
url.startsWith("#") || url.startsWith("#") ||
url.startsWith("./") || url.startsWith("./") ||
url.startsWith("../") || url.startsWith("../") ||
url.startsWith("//") url.startsWith("//")
) { ) {
return match; return match;
} }
return `[${text}](/notes/${url})`; return `[${text}](/notes/${url})`;
}); });
} }
onMounted(fetchFile); onMounted(fetchFile);
</script> </script>
<template> <template>
<main class="items-center flex flex-col"> <main class="items-center flex flex-col">
<div class="background" /> <div class="background" />
<div <div
v-if="file" v-if="file"
class="a4page-portrait border-primary-1 flex flex-col relative overflow-scroll gap-1 bg-bg_primary" class="a4page-portrait border-primary-1 flex flex-col relative overflow-scroll gap-1 bg-bg_primary"
> >
<h1>{{ filename }}</h1> <h1>{{ filename }}</h1>
<small>{{ last_edited }}</small> <small>{{ last_edited }}</small>
<Markdown class="flex-1 border-box text-wrap" :source="file" /> <Markdown class="flex-1 border-box text-wrap" :source="file" />
</div> </div>
<div v-else>Loading</div> <div v-else>Loading</div>
</main> </main>
</template> </template>

View File

@@ -9,25 +9,25 @@ import topLevelAwait from "vite-plugin-top-level-await";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
...(process.env.NODE_ENV !== "production" ? [vueDevTools()] : []), ...(process.env.NODE_ENV !== "production" ? [vueDevTools()] : []),
tailwindcss(), tailwindcss(),
wasm(), wasm(),
topLevelAwait(), topLevelAwait(),
], ],
resolve: { resolve: {
alias: { alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),
},
}, },
server: { },
host: "0.0.0.0", server: {
proxy: { host: "0.0.0.0",
"/api": "http://localhost:8080", proxy: {
"/gitea": "http://localhost:3000", "/api": "http://localhost:8080",
"/radio": "http://localhost:8000", "/gitea": "http://localhost:3000",
"/searxng": "http://localhost:8080", "/radio": "http://localhost:8000",
}, "/searxng": "http://localhost:8080",
}, },
},
}); });