Big formatting spree
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m50s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m50s
This commit is contained in:
@@ -51,12 +51,12 @@ type graphMessagesResponse struct {
|
||||
}
|
||||
|
||||
type graphMessage struct {
|
||||
ID string `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
ReceivedDateTime string `json:"receivedDateTime"`
|
||||
From graphFrom `json:"from"`
|
||||
Body graphBody `json:"body"`
|
||||
BodyPreview string `json:"bodyPreview"`
|
||||
ID string `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
ReceivedDateTime string `json:"receivedDateTime"`
|
||||
From graphFrom `json:"from"`
|
||||
Body graphBody `json:"body"`
|
||||
BodyPreview string `json:"bodyPreview"`
|
||||
}
|
||||
|
||||
type graphFrom struct {
|
||||
|
||||
@@ -41,8 +41,8 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
rateLimitWindow = time.Second
|
||||
rateLimitMaxMsgs = 10
|
||||
rateLimitWindow = time.Second
|
||||
rateLimitMaxMsgs = 10
|
||||
)
|
||||
|
||||
func InitWebSocket(database *gorm.DB, domain string) {
|
||||
|
||||
@@ -1,198 +1,201 @@
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
app-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
|
||||
volumes:
|
||||
dbdata:
|
||||
uploads:
|
||||
vue_dist:
|
||||
|
||||
searxng_data:
|
||||
# Postgres database
|
||||
dbdata:
|
||||
# File upload
|
||||
uploads:
|
||||
# Vue build
|
||||
vue_dist:
|
||||
# Searxng data
|
||||
searxng_data:
|
||||
|
||||
services:
|
||||
vue:
|
||||
build:
|
||||
context: ./vue
|
||||
dockerfile: Dockerfile
|
||||
container_name: vue
|
||||
volumes:
|
||||
- vue_dist:/output
|
||||
networks:
|
||||
- app-network
|
||||
vue:
|
||||
build:
|
||||
context: ./vue
|
||||
dockerfile: Dockerfile
|
||||
container_name: vue
|
||||
volumes:
|
||||
- vue_dist:/output
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: ./nginx
|
||||
dockerfile: Dockerfile
|
||||
container_name: nginx
|
||||
env_file: ./.env
|
||||
restart: always
|
||||
depends_on:
|
||||
- vue
|
||||
- backend
|
||||
- icecast2
|
||||
- gitea
|
||||
- hasura
|
||||
- quartz
|
||||
- searxng
|
||||
networks:
|
||||
- app-network
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
- uploads:/uploads
|
||||
- vue_dist:/etc/nginx/html
|
||||
nginx:
|
||||
build:
|
||||
context: ./nginx
|
||||
dockerfile: Dockerfile
|
||||
container_name: nginx
|
||||
env_file: ./.env
|
||||
restart: always
|
||||
depends_on:
|
||||
- vue
|
||||
- backend
|
||||
- icecast2
|
||||
- gitea
|
||||
- hasura
|
||||
- quartz
|
||||
- searxng
|
||||
networks:
|
||||
- app-network
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
- uploads:/uploads
|
||||
- vue_dist:/etc/nginx/html
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot:v3.1.0
|
||||
container_name: certbot
|
||||
volumes:
|
||||
- ./certbot/entrypoint.sh:/entrypoint.sh
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
entrypoint: ["/entrypoint.sh"]
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- app-network
|
||||
certbot:
|
||||
image: certbot/certbot:v3.1.0
|
||||
container_name: certbot
|
||||
volumes:
|
||||
- ./certbot/entrypoint.sh:/entrypoint.sh
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
entrypoint: ["/entrypoint.sh"]
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: "${BACKEND_HOST}"
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- app-network
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./backend/token/:/backend/token
|
||||
- ${OBSIDIAN_DIR}:/backend/notes
|
||||
- ./logs:/backend/logs
|
||||
- uploads:/backend/uploads
|
||||
- ./icecast2/fallback_music:/backend/fallback_music
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: "${BACKEND_HOST}"
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- app-network
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./backend/token/:/backend/token
|
||||
- ${OBSIDIAN_DIR}:/backend/notes
|
||||
- ./logs:/backend/logs
|
||||
- uploads:/backend/uploads
|
||||
- ./icecast2/fallback_music:/backend/fallback_music
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
autoheal:
|
||||
image: willfarrell/autoheal:latest
|
||||
container_name: autoheal
|
||||
restart: always
|
||||
environment:
|
||||
- AUTOHEAL_CONTAINER_LABEL=all
|
||||
- AUTOHEAL_INTERVAL=30
|
||||
- AUTOHEAL_START_PERIOD=60
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
autoheal:
|
||||
image: willfarrell/autoheal:latest
|
||||
container_name: autoheal
|
||||
restart: always
|
||||
environment:
|
||||
- AUTOHEAL_CONTAINER_LABEL=all
|
||||
- AUTOHEAL_INTERVAL=30
|
||||
- AUTOHEAL_START_PERIOD=60
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
container_name: "${POSTGRES_HOST}"
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
networks:
|
||||
- app-network
|
||||
volumes:
|
||||
- dbdata:/var/lib/postgresql/data
|
||||
db:
|
||||
image: postgres:16
|
||||
container_name: "${POSTGRES_HOST}"
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
networks:
|
||||
- app-network
|
||||
volumes:
|
||||
- dbdata:/var/lib/postgresql/data
|
||||
|
||||
hasura:
|
||||
image: hasura/graphql-engine:v2.44.0
|
||||
container_name: "${HASURA_HOST}"
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- app-network
|
||||
environment:
|
||||
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_ENABLE_CONSOLE: "false"
|
||||
HASURA_GRAPHQL_DEV_MODE: "false"
|
||||
HASURA_GRAPHQL_ENABLED_LOG_TYPES: "startup, http-log, webhook-log, websocket-log, query-log"
|
||||
hasura:
|
||||
image: hasura/graphql-engine:v2.44.0
|
||||
container_name: "${HASURA_HOST}"
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- app-network
|
||||
environment:
|
||||
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_ENABLE_CONSOLE: "false"
|
||||
HASURA_GRAPHQL_DEV_MODE: "false"
|
||||
HASURA_GRAPHQL_ENABLED_LOG_TYPES: "startup, http-log, webhook-log, websocket-log, query-log"
|
||||
|
||||
icecast2:
|
||||
build:
|
||||
context: ./icecast2
|
||||
dockerfile: Dockerfile
|
||||
container_name: "${ICECAST_HOST}"
|
||||
restart: always
|
||||
networks:
|
||||
- app-network
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./icecast2/fallback_music:/music:ro
|
||||
ports:
|
||||
- "${LIQUIDSOAP_HARBOR_PORT:-8001}:${LIQUIDSOAP_HARBOR_PORT:-8001}"
|
||||
icecast2:
|
||||
build:
|
||||
context: ./icecast2
|
||||
dockerfile: Dockerfile
|
||||
container_name: "${ICECAST_HOST}"
|
||||
restart: always
|
||||
networks:
|
||||
- app-network
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./icecast2/fallback_music:/music:ro
|
||||
ports:
|
||||
- "${LIQUIDSOAP_HARBOR_PORT:-8001}:${LIQUIDSOAP_HARBOR_PORT:-8001}"
|
||||
|
||||
quartz:
|
||||
build:
|
||||
context: ./quartz
|
||||
dockerfile: Dockerfile
|
||||
container_name: "${QUARTZ_HOST}"
|
||||
restart: always
|
||||
networks:
|
||||
- app-network
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ${OBSIDIAN_DIR}:/quartz/content:ro
|
||||
quartz:
|
||||
build:
|
||||
context: ./quartz
|
||||
dockerfile: Dockerfile
|
||||
container_name: "${QUARTZ_HOST}"
|
||||
restart: always
|
||||
networks:
|
||||
- app-network
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ${OBSIDIAN_DIR}:/quartz/content:ro
|
||||
|
||||
searxng:
|
||||
build:
|
||||
context: ./searxng
|
||||
dockerfile: Dockerfile
|
||||
container_name: "${SEARXNG_HOST}"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
environment:
|
||||
- BASE_URL=https://www.${DOMAIN}/searxng/
|
||||
- INSTANCE_NAME=searxng
|
||||
- SEARXNG_SECRET_KEY=${SEARXNG_SECRET_KEY}
|
||||
volumes:
|
||||
- searxng_data:/etc/searxng
|
||||
searxng:
|
||||
build:
|
||||
context: ./searxng
|
||||
dockerfile: Dockerfile
|
||||
container_name: "${SEARXNG_HOST}"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
environment:
|
||||
- BASE_URL=https://www.${DOMAIN}/searxng/
|
||||
- INSTANCE_NAME=searxng
|
||||
- SEARXNG_SECRET_KEY=${SEARXNG_SECRET_KEY}
|
||||
volumes:
|
||||
- searxng_data:/etc/searxng
|
||||
|
||||
gitea:
|
||||
image: docker.gitea.com/gitea:1.25.4-rootless
|
||||
container_name: "${GITEA_HOST}"
|
||||
entrypoint: ["/usr/bin/dumb-init", "--", "/etc/gitea/entrypoint.sh"]
|
||||
networks:
|
||||
- app-network
|
||||
environment:
|
||||
- GITEA__database__DB_TYPE=postgres
|
||||
- GITEA__database__HOST=${POSTGRES_HOST}
|
||||
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
|
||||
- GITEA__database__USER=${POSTGRES_USER}
|
||||
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
|
||||
- GITEA__server__LFS_JWT_SECRET=${GITEA_LFS_JWT_SECRET}
|
||||
- GITEA__security__INTERNAL_TOKEN=${GITEA_INTERNAL_TOKEN}
|
||||
- GITEA__oauth2__JWT_SECRET=${GITEA_OAUTH2_JWT_SECRET}
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
restart: always
|
||||
volumes:
|
||||
- ./gitea/data:/var/lib/gitea
|
||||
- ./gitea/config:/etc/gitea
|
||||
- ./gitea/entrypoint.sh:/etc/gitea/entrypoint.sh:ro
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "2222:2222"
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- db
|
||||
gitea:
|
||||
image: docker.gitea.com/gitea:1.25.4-rootless
|
||||
container_name: "${GITEA_HOST}"
|
||||
entrypoint: ["/usr/bin/dumb-init", "--", "/etc/gitea/entrypoint.sh"]
|
||||
networks:
|
||||
- app-network
|
||||
environment:
|
||||
- GITEA__database__DB_TYPE=postgres
|
||||
- GITEA__database__HOST=${POSTGRES_HOST}
|
||||
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
|
||||
- GITEA__database__USER=${POSTGRES_USER}
|
||||
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
|
||||
- GITEA__server__LFS_JWT_SECRET=${GITEA_LFS_JWT_SECRET}
|
||||
- GITEA__security__INTERNAL_TOKEN=${GITEA_INTERNAL_TOKEN}
|
||||
- GITEA__oauth2__JWT_SECRET=${GITEA_OAUTH2_JWT_SECRET}
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
restart: always
|
||||
volumes:
|
||||
- ./gitea/data:/var/lib/gitea
|
||||
- ./gitea/config:/etc/gitea
|
||||
- ./gitea/entrypoint.sh:/etc/gitea/entrypoint.sh:ro
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "2222:2222"
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Check if dev mode, certificate exists, or setup mode
|
||||
# Check if DEV_MODE
|
||||
if [ "$DEV_MODE" = "true" ]; then
|
||||
echo "Dev mode. Generating self-signed certificate for HTTPS."
|
||||
CERT_DIR="/etc/letsencrypt/live/localhost"
|
||||
@@ -12,16 +12,19 @@ if [ "$DEV_MODE" = "true" ]; then
|
||||
-out "$CERT_DIR/fullchain.pem" \
|
||||
-subj "/CN=localhost" 2>/dev/null
|
||||
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}' \
|
||||
</etc/nginx/nginx_dev.conf.template \
|
||||
>/etc/nginx/nginx.conf
|
||||
elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
|
||||
echo "Certificates found. Using production nginx config."
|
||||
# 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}' \
|
||||
</etc/nginx/nginx.conf.template \
|
||||
>/etc/nginx/nginx.conf
|
||||
else
|
||||
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
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Adam French's personal website">
|
||||
<title>AF</title>
|
||||
<link rel="preconnect" href="https://i.scdn.co" crossorigin>
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://cdn.akamai.steamstatic.com"
|
||||
crossorigin
|
||||
>
|
||||
<link rel="icon" type="/img/x-icon" href="/img/favicon.ico">
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/big_noodle_titling.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/CreatoDisplay-Bold.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
>
|
||||
</head>
|
||||
<body id="app">
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Adam French's personal website" />
|
||||
<title>AF</title>
|
||||
<link rel="preconnect" href="https://i.scdn.co" crossorigin />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://cdn.akamai.steamstatic.com"
|
||||
crossorigin
|
||||
/>
|
||||
<link rel="icon" type="/img/x-icon" href="/img/favicon.ico" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/big_noodle_titling.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/CreatoDisplay-Bold.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
</head>
|
||||
<body id="app">
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,5 +3,5 @@ import { RouterView } from "vue-router";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
@@ -5,128 +5,128 @@ const clock = ref("");
|
||||
let timer;
|
||||
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
clock.value = now.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const now = new Date();
|
||||
clock.value = now.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateClock();
|
||||
timer = setInterval(updateClock, 1000);
|
||||
updateClock();
|
||||
timer = setInterval(updateClock, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer);
|
||||
clearInterval(timer);
|
||||
});
|
||||
|
||||
const user = "visitor";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="waybar">
|
||||
<div class="modules-left">
|
||||
<span class="workspace active">ツ</span>
|
||||
</div>
|
||||
<footer class="waybar">
|
||||
<div class="modules-left">
|
||||
<span class="workspace active">ツ</span>
|
||||
</div>
|
||||
|
||||
<div class="modules-right">
|
||||
<span class="module greeting">Hi, {{ user }}!</span>
|
||||
<span class="module cpu hide-sm">CPU 3%</span>
|
||||
<span class="module mem hide-sm">MEM 42%</span>
|
||||
<span class="module disk hide-sm">DISK 67%</span>
|
||||
<span class="module network hide-sm">↑ 12K ↓ 84K</span>
|
||||
<span class="module battery hide-sm">BAT 98%</span>
|
||||
<span class="module clock">{{ clock }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
<div class="modules-right">
|
||||
<span class="module greeting">Hi, {{ user }}!</span>
|
||||
<span class="module cpu hide-sm">CPU 3%</span>
|
||||
<span class="module mem hide-sm">MEM 42%</span>
|
||||
<span class="module disk hide-sm">DISK 67%</span>
|
||||
<span class="module network hide-sm">↑ 12K ↓ 84K</span>
|
||||
<span class="module battery hide-sm">BAT 98%</span>
|
||||
<span class="module clock">{{ clock }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.waybar {
|
||||
font-family: "URWGothic-Book", monospace;
|
||||
background-color: var(--bg_primary);
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
min-height: 36px;
|
||||
flex-shrink: 0;
|
||||
font-family: "URWGothic-Book", monospace;
|
||||
background-color: var(--bg_primary);
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
min-height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modules-left {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
background: var(--quaternary);
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--secondary);
|
||||
color: var(--secondary);
|
||||
padding: 2px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
background: var(--quaternary);
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--secondary);
|
||||
color: var(--secondary);
|
||||
padding: 2px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modules-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.module {
|
||||
padding: 2px 12px;
|
||||
border-left: 1px solid var(--tertiary);
|
||||
padding: 2px 12px;
|
||||
border-left: 1px solid var(--tertiary);
|
||||
}
|
||||
|
||||
.module:first-child {
|
||||
border-left: none;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.clock {
|
||||
color: var(--tertiary);
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
.cpu,
|
||||
.mem,
|
||||
.disk {
|
||||
color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.network {
|
||||
color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.battery {
|
||||
color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.waybar {
|
||||
font-size: 11px;
|
||||
padding: 4px 4px;
|
||||
}
|
||||
.waybar {
|
||||
font-size: 11px;
|
||||
padding: 4px 4px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.workspace {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.module {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.module {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.hide-sm {
|
||||
display: none;
|
||||
}
|
||||
.hide-sm {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,55 +6,55 @@ import { useRoute } from "vue-router";
|
||||
const route = useRoute();
|
||||
|
||||
const parentPath = computed(() => {
|
||||
const segments = route.path.split("/").filter(Boolean);
|
||||
if (segments.length == 1) {
|
||||
return "/";
|
||||
} else {
|
||||
segments.pop();
|
||||
return segments.length ? "/" + segments.join("/") : null;
|
||||
}
|
||||
const segments = route.path.split("/").filter(Boolean);
|
||||
if (segments.length == 1) {
|
||||
return "/";
|
||||
} else {
|
||||
segments.pop();
|
||||
return segments.length ? "/" + segments.join("/") : null;
|
||||
}
|
||||
});
|
||||
|
||||
const faces = [
|
||||
"^_^",
|
||||
"¯\\_(ツ)_/¯",
|
||||
"(◕‿◕✿)",
|
||||
"ಠ_ಠ",
|
||||
"ʘ‿ʘ",
|
||||
"^̮^",
|
||||
">_>",
|
||||
"¬_¬",
|
||||
"˙ ͜ʟ˙",
|
||||
"( ͡° ͜ʖ ͡°)",
|
||||
"[̲̅$̲̅(̲̅5̲̅)̲̅$̲̅]",
|
||||
"(ง'̀-'́)ง",
|
||||
"\ (•◡•) /",
|
||||
"( ͡ᵔ ͜ʖ ͡ᵔ )",
|
||||
"ᕙ(⇀‸↼‶)ᕗ",
|
||||
"⚆ _ ⚆",
|
||||
"(。◕‿◕。)",
|
||||
"(╯°□°)╯︵ ʞooqǝɔɐɟ",
|
||||
"̿ ̿ ̿'̿'\̵͇̿̿\з=(•_•)=ε/̵͇̿̿/'̿'̿ ̿",
|
||||
"(☞゚ヮ゚)☞ ☜(゚ヮ゚☜)",
|
||||
"^_^",
|
||||
"¯\\_(ツ)_/¯",
|
||||
"(◕‿◕✿)",
|
||||
"ಠ_ಠ",
|
||||
"ʘ‿ʘ",
|
||||
"^̮^",
|
||||
">_>",
|
||||
"¬_¬",
|
||||
"˙ ͜ʟ˙",
|
||||
"( ͡° ͜ʖ ͡°)",
|
||||
"[̲̅$̲̅(̲̅5̲̅)̲̅$̲̅]",
|
||||
"(ง'̀-'́)ง",
|
||||
"\ (•◡•) /",
|
||||
"( ͡ᵔ ͜ʖ ͡ᵔ )",
|
||||
"ᕙ(⇀‸↼‶)ᕗ",
|
||||
"⚆ _ ⚆",
|
||||
"(。◕‿◕。)",
|
||||
"(╯°□°)╯︵ ʞooqǝɔɐɟ",
|
||||
"̿ ̿ ̿'̿'\̵͇̿̿\з=(•_•)=ε/̵͇̿̿/'̿'̿ ̿",
|
||||
"(☞゚ヮ゚)☞ ☜(゚ヮ゚☜)",
|
||||
];
|
||||
const faces_string = faces.join(" ");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<span>UP</span>
|
||||
</RouterLink>
|
||||
<Headline class="border flex-1 max-w-full">
|
||||
<code class="whitespace-pre">{{ faces_string }}</code>
|
||||
</Headline>
|
||||
</nav>
|
||||
<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">
|
||||
<span>UP</span>
|
||||
</RouterLink>
|
||||
<Headline class="border flex-1 max-w-full">
|
||||
<code class="whitespace-pre">{{ faces_string }}</code>
|
||||
</Headline>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.left {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<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 bl rotate" />
|
||||
<img src="/img/borders/utena.png" class="flower br antirotate" />
|
||||
<slot />
|
||||
</div>
|
||||
<div class="container">
|
||||
<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 bl rotate" />
|
||||
<img src="/img/borders/utena.png" class="flower br antirotate" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
position: relative;
|
||||
margin: 100px;
|
||||
position: relative;
|
||||
margin: 100px;
|
||||
}
|
||||
|
||||
.flower {
|
||||
position: absolute;
|
||||
width: 150px;
|
||||
position: absolute;
|
||||
width: 150px;
|
||||
}
|
||||
.tl {
|
||||
top: -80px;
|
||||
left: -80px;
|
||||
--start: 0deg;
|
||||
top: -80px;
|
||||
left: -80px;
|
||||
--start: 0deg;
|
||||
}
|
||||
.tr {
|
||||
top: -80px;
|
||||
right: -80px;
|
||||
--start: 90deg;
|
||||
top: -80px;
|
||||
right: -80px;
|
||||
--start: 90deg;
|
||||
}
|
||||
.bl {
|
||||
bottom: -80px;
|
||||
left: -80px;
|
||||
--start: 180deg;
|
||||
bottom: -80px;
|
||||
left: -80px;
|
||||
--start: 180deg;
|
||||
}
|
||||
.br {
|
||||
bottom: -80px;
|
||||
right: -80px;
|
||||
--start: 270deg;
|
||||
bottom: -80px;
|
||||
right: -80px;
|
||||
--start: 270deg;
|
||||
}
|
||||
.rotate {
|
||||
animation: spin 3s linear infinite;
|
||||
animation: spin 3s linear infinite;
|
||||
}
|
||||
|
||||
.antirotate {
|
||||
animation: spin 3s linear infinite reverse;
|
||||
animation: spin 3s linear infinite reverse;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(var(--start));
|
||||
}
|
||||
to {
|
||||
transform: rotate(calc(var(--start) + 360deg));
|
||||
}
|
||||
from {
|
||||
transform: rotate(var(--start));
|
||||
}
|
||||
to {
|
||||
transform: rotate(calc(var(--start) + 360deg));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,11 +6,11 @@ const container = ref(null);
|
||||
|
||||
// List of (offset, width)
|
||||
function generateOffsets(width = 100, step = 10, n = 20) {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
width,
|
||||
offset: step * i,
|
||||
color: getRandomColor(),
|
||||
}));
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
width,
|
||||
offset: step * i,
|
||||
color: getRandomColor(),
|
||||
}));
|
||||
}
|
||||
const offsets = ref(generateOffsets((150, 15, 10)));
|
||||
let rafId;
|
||||
@@ -18,71 +18,71 @@ let rafId;
|
||||
const speed = 0.5; // pixels per frame
|
||||
|
||||
function animate() {
|
||||
const ctnr = container.value;
|
||||
for (const item of offsets.value) {
|
||||
const width = Math.max(ctnr.offsetWidth, item.width);
|
||||
const ctnr = container.value;
|
||||
for (const item of offsets.value) {
|
||||
const width = Math.max(ctnr.offsetWidth, item.width);
|
||||
|
||||
console.log(ctnr.offsetWidth);
|
||||
console.log(ctnr.offsetWidth);
|
||||
|
||||
item.offset -= speed;
|
||||
if (item.offset <= -width) {
|
||||
item.color = getRandomColor();
|
||||
item.offset = 0;
|
||||
}
|
||||
item.offset -= speed;
|
||||
if (item.offset <= -width) {
|
||||
item.color = getRandomColor();
|
||||
item.offset = 0;
|
||||
}
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
rafId = requestAnimationFrame(animate);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(rafId);
|
||||
cancelAnimationFrame(rafId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-primary container" ref="container">
|
||||
<div :key="index" v-for="(item, index) in offsets">
|
||||
<div
|
||||
:style="{
|
||||
width: item.width + 'px',
|
||||
translate: item.offset + 'px',
|
||||
backgroundColor: item.color,
|
||||
}"
|
||||
class="item item1"
|
||||
/>
|
||||
<div
|
||||
:style="{
|
||||
width: item.width + 'px',
|
||||
right: -item.width + 'px',
|
||||
translate: item.offset + 'px',
|
||||
backgroundColor: item.color,
|
||||
}"
|
||||
class="item item2"
|
||||
/>
|
||||
</div>
|
||||
<div class="bg-primary container" ref="container">
|
||||
<div :key="index" v-for="(item, index) in offsets">
|
||||
<div
|
||||
:style="{
|
||||
width: item.width + 'px',
|
||||
translate: item.offset + 'px',
|
||||
backgroundColor: item.color,
|
||||
}"
|
||||
class="item item1"
|
||||
/>
|
||||
<div
|
||||
:style="{
|
||||
width: item.width + 'px',
|
||||
right: -item.width + 'px',
|
||||
translate: item.offset + 'px',
|
||||
backgroundColor: item.color,
|
||||
}"
|
||||
class="item item2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
will-change: transform;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
.item {
|
||||
opacity: 40%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
opacity: 40%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
.item1 {
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
}
|
||||
.item2 {
|
||||
top: 0px;
|
||||
top: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
<slot />
|
||||
</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"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -2,30 +2,30 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
function toggle() {
|
||||
emit("update:modelValue", !props.modelValue);
|
||||
emit("update:modelValue", !props.modelValue);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<button
|
||||
@click="toggle"
|
||||
class="box-content border-2 border-primary w-10 h-fit rounded-full cursor-pointer"
|
||||
:class="[props.modelValue ? 'bg-bg_secondary' : 'bg-bg_primary']"
|
||||
<button
|
||||
@click="toggle"
|
||||
class="box-content border-2 border-primary w-10 h-fit rounded-full cursor-pointer"
|
||||
: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
|
||||
viewBox="0 0 40 40"
|
||||
class="w-5 h-5 transition-all duration-300 ease-in-out"
|
||||
:class="[props.modelValue ? 'ml-5' : 'ml-0']"
|
||||
>
|
||||
<circle class="fill-primary" cx="20" cy="20" r="20" />
|
||||
</svg>
|
||||
</button>
|
||||
<circle class="fill-primary" cx="20" cy="20" r="20" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full border-b border-primary pb-0.5">
|
||||
<h1 class="p-0 m-0">
|
||||
<slot />
|
||||
</h1>
|
||||
</div>
|
||||
<div class="w-full border-b border-primary pb-0.5">
|
||||
<h1 class="p-0 m-0">
|
||||
<slot />
|
||||
</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,74 +12,74 @@ let rafId;
|
||||
const speed = 0.5; // pixels per frame
|
||||
|
||||
function measureWidth() {
|
||||
const ctnr = container.value;
|
||||
const it1 = item1.value;
|
||||
if (ctnr && it1) {
|
||||
cachedWidth = Math.max(ctnr.offsetWidth, it1.scrollWidth);
|
||||
}
|
||||
const ctnr = container.value;
|
||||
const it1 = item1.value;
|
||||
if (ctnr && it1) {
|
||||
cachedWidth = Math.max(ctnr.offsetWidth, it1.scrollWidth);
|
||||
}
|
||||
}
|
||||
|
||||
function animate() {
|
||||
const ctnr = container.value;
|
||||
if (!ctnr || cachedWidth === 0) {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
offset -= speed;
|
||||
|
||||
if (offset <= -cachedWidth) {
|
||||
offset += cachedWidth;
|
||||
}
|
||||
|
||||
ctnr.style.transform = `translateX(${offset}px)`;
|
||||
|
||||
const ctnr = container.value;
|
||||
if (!ctnr || cachedWidth === 0) {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
offset -= speed;
|
||||
|
||||
if (offset <= -cachedWidth) {
|
||||
offset += cachedWidth;
|
||||
}
|
||||
|
||||
ctnr.style.transform = `translateX(${offset}px)`;
|
||||
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
let resizeObserver;
|
||||
|
||||
onMounted(() => {
|
||||
measureWidth();
|
||||
rafId = requestAnimationFrame(animate);
|
||||
measureWidth();
|
||||
rafId = requestAnimationFrame(animate);
|
||||
|
||||
resizeObserver = new ResizeObserver(measureWidth);
|
||||
resizeObserver.observe(container.value);
|
||||
resizeObserver = new ResizeObserver(measureWidth);
|
||||
resizeObserver.observe(container.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(rafId);
|
||||
resizeObserver?.disconnect();
|
||||
cancelAnimationFrame(rafId);
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="root">
|
||||
<div class="container" ref="container">
|
||||
<div ref="item1">
|
||||
<slot />
|
||||
</div>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div class="root">
|
||||
<div class="container" ref="container">
|
||||
<div ref="item1">
|
||||
<slot />
|
||||
</div>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.root {
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: max-content;
|
||||
/* Each column fits its content */
|
||||
overflow-x: visible;
|
||||
will-change: transform;
|
||||
gap: 10em;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: max-content;
|
||||
/* Each column fits its content */
|
||||
overflow-x: visible;
|
||||
will-change: transform;
|
||||
gap: 10em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,38 +2,44 @@
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
href: { type: String, default: "" },
|
||||
to: { type: String, default: "" },
|
||||
target: { type: String, default: undefined },
|
||||
rel: { type: String, default: undefined },
|
||||
href: { type: String, default: "" },
|
||||
to: { type: String, default: "" },
|
||||
target: { type: String, default: undefined },
|
||||
rel: { type: String, default: undefined },
|
||||
});
|
||||
|
||||
const computedRel = computed(() => {
|
||||
if (props.rel !== undefined) return props.rel;
|
||||
if (props.target === "_blank") return "noopener noreferrer";
|
||||
return undefined;
|
||||
if (props.rel !== undefined) return props.rel;
|
||||
if (props.target === "_blank") return "noopener noreferrer";
|
||||
return undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink v-if="to" :to="to" class="inline-link">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
<a v-else :href="href" :target="target" :rel="computedRel" class="inline-link">
|
||||
<slot />
|
||||
</a>
|
||||
<RouterLink v-if="to" :to="to" class="inline-link">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
<a
|
||||
v-else
|
||||
:href="href"
|
||||
:target="target"
|
||||
:rel="computedRel"
|
||||
class="inline-link"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inline-link {
|
||||
color: var(--primary);
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
color: var(--primary);
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.inline-link:hover {
|
||||
color: var(--tertiary);
|
||||
color: var(--tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,37 +2,43 @@
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
href: { type: String, default: "" },
|
||||
to: { type: String, default: "" },
|
||||
target: { type: String, default: undefined },
|
||||
rel: { type: String, default: undefined },
|
||||
bare: { type: Boolean, default: false },
|
||||
href: { type: String, default: "" },
|
||||
to: { type: String, default: "" },
|
||||
target: { type: String, default: undefined },
|
||||
rel: { type: String, default: undefined },
|
||||
bare: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const computedRel = computed(() => {
|
||||
if (props.rel !== undefined) return props.rel;
|
||||
if (props.target === "_blank") return "noopener noreferrer";
|
||||
return undefined;
|
||||
if (props.rel !== undefined) return props.rel;
|
||||
if (props.target === "_blank") return "noopener noreferrer";
|
||||
return undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink v-if="to" :to="to" :class="{ link: !bare }">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
<a v-else :href="href" :target="target" :rel="computedRel" :class="{ link: !bare }">
|
||||
<slot />
|
||||
</a>
|
||||
<RouterLink v-if="to" :to="to" :class="{ link: !bare }">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
<a
|
||||
v-else
|
||||
:href="href"
|
||||
:target="target"
|
||||
:rel="computedRel"
|
||||
:class="{ link: !bare }"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.link {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--tertiary);
|
||||
color: var(--tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<p class="p-1">
|
||||
<slot />
|
||||
</p>
|
||||
<p class="p-1">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -3,36 +3,36 @@ import { ref } from "vue";
|
||||
import ToggleButton from "@/components/input/ToggleButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
const toggleButtonRef = ref(null);
|
||||
|
||||
const updateValue = (newValue) => {
|
||||
emit("update:modelValue", newValue);
|
||||
emit("update:modelValue", newValue);
|
||||
};
|
||||
const handleClick = () => {
|
||||
toggleButtonRef.value?.$el?.click();
|
||||
toggleButtonRef.value?.$el?.click();
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="w-full border-b border-primary cursor-pointer"
|
||||
@click="handleClick"
|
||||
>
|
||||
<h3 class="pl-2 m-0">
|
||||
<slot />
|
||||
</h3>
|
||||
<ToggleButton
|
||||
class="pointer-events-none"
|
||||
:model-value="props.modelValue"
|
||||
@update:model-value="updateValue"
|
||||
@click.stop
|
||||
ref="toggleButtonRef"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="w-full border-b border-primary cursor-pointer"
|
||||
@click="handleClick"
|
||||
>
|
||||
<h3 class="pl-2 m-0">
|
||||
<slot />
|
||||
</h3>
|
||||
<ToggleButton
|
||||
class="pointer-events-none"
|
||||
:model-value="props.modelValue"
|
||||
@update:model-value="updateValue"
|
||||
@click.stop
|
||||
ref="toggleButtonRef"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div ref="container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" class="overflow-y-auto">
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
ref="container"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
class="overflow-y-auto"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -20,89 +25,89 @@ let pauseTimeoutId = null;
|
||||
let cachedScrollHeight = 0;
|
||||
|
||||
function measureScrollHeight() {
|
||||
const el = container.value;
|
||||
if (el) cachedScrollHeight = el.scrollHeight;
|
||||
const el = container.value;
|
||||
if (el) cachedScrollHeight = el.scrollHeight;
|
||||
}
|
||||
|
||||
function stopLoop() {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
if (pauseTimeoutId !== null) {
|
||||
clearTimeout(pauseTimeoutId);
|
||||
pauseTimeoutId = null;
|
||||
}
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
if (pauseTimeoutId !== null) {
|
||||
clearTimeout(pauseTimeoutId);
|
||||
pauseTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startLoop() {
|
||||
stopLoop();
|
||||
rafId = requestAnimationFrame(tick);
|
||||
stopLoop();
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
hovered = true;
|
||||
stopLoop();
|
||||
hovered = true;
|
||||
stopLoop();
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hovered = false;
|
||||
const el = container.value;
|
||||
if (el && cachedScrollHeight > 0) {
|
||||
pos = el.scrollTop / cachedScrollHeight;
|
||||
}
|
||||
startLoop();
|
||||
hovered = false;
|
||||
const el = container.value;
|
||||
if (el && cachedScrollHeight > 0) {
|
||||
pos = el.scrollTop / cachedScrollHeight;
|
||||
}
|
||||
startLoop();
|
||||
}
|
||||
|
||||
function schedulePause(callback) {
|
||||
stopLoop();
|
||||
pauseTimeoutId = setTimeout(callback, PAUSE);
|
||||
stopLoop();
|
||||
pauseTimeoutId = setTimeout(callback, PAUSE);
|
||||
}
|
||||
|
||||
function tick() {
|
||||
rafId = null;
|
||||
const el = container.value;
|
||||
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;
|
||||
rafId = null;
|
||||
const el = container.value;
|
||||
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;
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
let resizeObserver;
|
||||
|
||||
onMounted(() => {
|
||||
measureScrollHeight();
|
||||
schedulePause(startLoop);
|
||||
measureScrollHeight();
|
||||
schedulePause(startLoop);
|
||||
|
||||
resizeObserver = new ResizeObserver(measureScrollHeight);
|
||||
resizeObserver.observe(container.value);
|
||||
resizeObserver = new ResizeObserver(measureScrollHeight);
|
||||
resizeObserver.observe(container.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopLoop();
|
||||
resizeObserver?.disconnect();
|
||||
stopLoop();
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -19,210 +19,205 @@ const SCROLL_THRESHOLD = 100;
|
||||
let resizeObserver = null;
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop =
|
||||
messagesContainer.value.scrollHeight;
|
||||
}
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottomIfNear() {
|
||||
if (isNearBottom.value) {
|
||||
scrollToBottom();
|
||||
}
|
||||
if (isNearBottom.value) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (!messagesContainer.value) return;
|
||||
const { scrollHeight, scrollTop, clientHeight } = messagesContainer.value;
|
||||
isNearBottom.value =
|
||||
scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
|
||||
if (!messagesContainer.value) return;
|
||||
const { scrollHeight, scrollTop, clientHeight } = messagesContainer.value;
|
||||
isNearBottom.value =
|
||||
scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
|
||||
}
|
||||
|
||||
function goToBottom() {
|
||||
isNearBottom.value = true;
|
||||
scrollToBottom();
|
||||
isNearBottom.value = true;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
() => {
|
||||
nextTick(scrollToBottomIfNear);
|
||||
},
|
||||
() => messages.value.length,
|
||||
() => {
|
||||
nextTick(scrollToBottomIfNear);
|
||||
},
|
||||
);
|
||||
|
||||
function sendMessage() {
|
||||
const text = messageInput.value.trim();
|
||||
if (!text) return;
|
||||
isNearBottom.value = true;
|
||||
messagesStore.sendMessage(text);
|
||||
messageInput.value = "";
|
||||
const text = messageInput.value.trim();
|
||||
if (!text) return;
|
||||
isNearBottom.value = true;
|
||||
messagesStore.sendMessage(text);
|
||||
messageInput.value = "";
|
||||
}
|
||||
|
||||
async function onFileSelected(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
isNearBottom.value = true;
|
||||
await messagesStore.uploadAndSendFile(file);
|
||||
fileInput.value.value = "";
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
isNearBottom.value = true;
|
||||
await messagesStore.uploadAndSendFile(file);
|
||||
fileInput.value.value = "";
|
||||
}
|
||||
|
||||
function isImageUrl(url) {
|
||||
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
|
||||
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
|
||||
}
|
||||
|
||||
function isVideoUrl(url) {
|
||||
return /\.(mp4|webm|ogg|mov)$/i.test(url);
|
||||
return /\.(mp4|webm|ogg|mov)$/i.test(url);
|
||||
}
|
||||
|
||||
function isSafeFileUrl(url) {
|
||||
return typeof url === "string" && url.startsWith("/uploads/");
|
||||
return typeof url === "string" && url.startsWith("/uploads/");
|
||||
}
|
||||
|
||||
const urlRegex = /(https?:\/\/[^\s<]+)/g;
|
||||
|
||||
function parseMessageParts(text) {
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = urlRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
value: text.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
parts.push({ type: "link", value: match[1] });
|
||||
lastIndex = urlRegex.lastIndex;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = urlRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
value: text.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: "text", value: text.slice(lastIndex) });
|
||||
}
|
||||
return parts;
|
||||
parts.push({ type: "link", value: match[1] });
|
||||
lastIndex = urlRegex.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: "text", value: text.slice(lastIndex) });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
messagesStore.connect();
|
||||
messagesStore.connect();
|
||||
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.addEventListener("scroll", onScroll, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.addEventListener("scroll", onScroll, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (messagesInner.value) {
|
||||
resizeObserver = new ResizeObserver(scrollToBottomIfNear);
|
||||
resizeObserver.observe(messagesInner.value);
|
||||
}
|
||||
if (messagesInner.value) {
|
||||
resizeObserver = new ResizeObserver(scrollToBottomIfNear);
|
||||
resizeObserver.observe(messagesInner.value);
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
messagesStore.disconnect();
|
||||
messagesStore.disconnect();
|
||||
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.removeEventListener("scroll", onScroll);
|
||||
}
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.removeEventListener("scroll", onScroll);
|
||||
}
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-root flex-col flex min-h-0">
|
||||
<Header>Chat</Header>
|
||||
<div
|
||||
ref="messagesContainer"
|
||||
class="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2 min-w-0"
|
||||
<div class="chat-root flex-col flex min-h-0">
|
||||
<Header>Chat</Header>
|
||||
<div
|
||||
ref="messagesContainer"
|
||||
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">
|
||||
<p
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="break-words min-w-0 w-full"
|
||||
>
|
||||
<span class="text-tertiary">{{ message.authorId }}:</span>
|
||||
<template
|
||||
v-for="(part, i) in parseMessageParts(
|
||||
message.text || '',
|
||||
)"
|
||||
:key="i"
|
||||
>
|
||||
<Link
|
||||
v-if="part.type === 'link'"
|
||||
bare
|
||||
:href="part.value"
|
||||
target="_blank"
|
||||
class="text-primary underline break-all"
|
||||
>{{ part.value }}</Link
|
||||
>
|
||||
<span v-else>{{ part.value }}</span>
|
||||
</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"
|
||||
<span class="text-tertiary">{{ message.authorId }}:</span>
|
||||
<template
|
||||
v-for="(part, i) in parseMessageParts(message.text || '')"
|
||||
:key="i"
|
||||
>
|
||||
<Link
|
||||
v-if="part.type === 'link'"
|
||||
bare
|
||||
:href="part.value"
|
||||
target="_blank"
|
||||
class="text-primary underline break-all"
|
||||
>{{ part.value }}</Link
|
||||
>
|
||||
<span v-else>{{ part.value }}</span>
|
||||
</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"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="onFileSelected"
|
||||
<video
|
||||
v-else-if="isVideoUrl(message.fileUrl)"
|
||||
:src="message.fileUrl"
|
||||
controls
|
||||
preload="none"
|
||||
class="w-full max-w-full max-h-48 rounded block"
|
||||
/>
|
||||
<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>
|
||||
<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
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@media (max-width: 850px) {
|
||||
.chat-root {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
.chat-root {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,37 +9,42 @@ const { gitFeed: feed, loaded } = storeToRefs(homeData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col text-center min-h-0 h-full overflow-x-hidden">
|
||||
<Header class="text-left">Commits</Header>
|
||||
<div class="flex flex-col text-center min-h-0 h-full overflow-x-hidden">
|
||||
<Header class="text-left">Commits</Header>
|
||||
|
||||
<div v-if="!loaded" class="flex-1 overflow-y-auto">
|
||||
<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 v-if="!loaded" class="flex-1 overflow-y-auto">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar {
|
||||
max-width: 200px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 200px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,70 +4,66 @@ import Link from "@/components/text/Link.vue";
|
||||
import ToggleHeader from "@/components/text/ToggleHeader.vue";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: "list",
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: "list",
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const show = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="title" class="h-fit w-full">
|
||||
<ToggleHeader v-model="show" class="justify-between flex items-center">
|
||||
{{ title }}
|
||||
</ToggleHeader>
|
||||
<template v-if="show">
|
||||
<Link
|
||||
v-if="variant === 'list'"
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
:href="item.link"
|
||||
>
|
||||
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
|
||||
</Link>
|
||||
<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>
|
||||
</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>
|
||||
<div v-if="title" class="h-fit w-full">
|
||||
<ToggleHeader v-model="show" class="justify-between flex items-center">
|
||||
{{ title }}
|
||||
</ToggleHeader>
|
||||
<template v-if="show">
|
||||
<Link
|
||||
v-if="variant === 'list'"
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
:href="item.link"
|
||||
>
|
||||
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
|
||||
</Link>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -6,19 +6,19 @@ import DOMPurify from "dompurify";
|
||||
const mdIt = MarkdownIt().use(katex);
|
||||
|
||||
const props = defineProps({
|
||||
source: String,
|
||||
source: String,
|
||||
});
|
||||
|
||||
function renderMarkdown(source) {
|
||||
return DOMPurify.sanitize(mdIt.render(source));
|
||||
return DOMPurify.sanitize(mdIt.render(source));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-html="renderMarkdown(props.source)"
|
||||
class="flex flex-col items-center"
|
||||
></div>
|
||||
<div
|
||||
v-html="renderMarkdown(props.source)"
|
||||
class="flex flex-col items-center"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,93 +1,92 @@
|
||||
<script setup>
|
||||
import Button from "@/components/input/Button.vue";
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<audio/>
|
||||
<div class="musicPlayerGrid">
|
||||
<div class="album_cover">
|
||||
<img src="/img/Untitled.png" alt=""></img>
|
||||
</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>
|
||||
<audio />
|
||||
<div class="musicPlayerGrid">
|
||||
<div class="album_cover">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.musicPlayerGrid {
|
||||
display: grid;
|
||||
grid-gap: 5px;
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
background-color: blue;
|
||||
align-items: stretch; /* rows (block axis) */
|
||||
justify-items: stretch; /* columns (inline axis) */
|
||||
padding: 5px;
|
||||
display: grid;
|
||||
grid-gap: 5px;
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
background-color: blue;
|
||||
align-items: stretch; /* rows (block axis) */
|
||||
justify-items: stretch; /* columns (inline axis) */
|
||||
padding: 5px;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.album_cover {
|
||||
grid-row: 1 / span 3;
|
||||
background-color: grey;
|
||||
box-sizing: border-box;
|
||||
grid-row: 1 / span 3;
|
||||
background-color: grey;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.controls {
|
||||
width: 100%;
|
||||
grid-row: 4 / span 1;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
grid-row: 4 / span 1;
|
||||
box-sizing: border-box;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
grid-gap: 5px;
|
||||
display: grid;
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
grid-gap: 5px;
|
||||
}
|
||||
|
||||
.sliders {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-gap: 5px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-gap: 5px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
grid-column: 1;
|
||||
background-color: white;
|
||||
grid-column: 1;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.volume {
|
||||
grid-column: 2;
|
||||
background-color: white;
|
||||
grid-column: 2;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
background-color: black;
|
||||
grid-row: 2 / -1;
|
||||
background-color: black;
|
||||
grid-row: 2 / -1;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-gap: 5px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-gap: 5px;
|
||||
}
|
||||
|
||||
.rewind {
|
||||
grid-column: 1;
|
||||
background-color: grey;
|
||||
grid-column: 1;
|
||||
background-color: grey;
|
||||
}
|
||||
.fastforward {
|
||||
grid-column: 4;
|
||||
background-color: grey;
|
||||
grid-column: 4;
|
||||
background-color: grey;
|
||||
}
|
||||
.playPause {
|
||||
grid-column: 2/span 2;
|
||||
background-color: grey;
|
||||
grid-column: 2 / span 2;
|
||||
background-color: grey;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,42 +2,42 @@
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
objArr: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
objArr: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const resolvedColumns = computed(() => {
|
||||
const keys = new Set();
|
||||
const keys = new Set();
|
||||
|
||||
for (const obj of props.objArr) {
|
||||
Object.keys(obj).forEach((key) => keys.add(key));
|
||||
}
|
||||
for (const obj of props.objArr) {
|
||||
Object.keys(obj).forEach((key) => keys.add(key));
|
||||
}
|
||||
|
||||
return Array.from(keys).map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
}));
|
||||
return Array.from(keys).map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="col in resolvedColumns" :key="col.key">
|
||||
{{ col.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="col in resolvedColumns" :key="col.key">
|
||||
{{ col.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in objArr" :key="rowIndex">
|
||||
<td v-for="col in resolvedColumns" :key="col.key">
|
||||
{{ row[col.key] ?? "" }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in objArr" :key="rowIndex">
|
||||
<td v-for="col in resolvedColumns" :key="col.key">
|
||||
{{ row[col.key] ?? "" }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div v-if="streamLive" class="overflow-auto">
|
||||
<Header>Radio</Header>
|
||||
<img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" />
|
||||
<audio controls :src="streamUrl" ref="audio"></audio>
|
||||
</div>
|
||||
<div v-else class="overflow-auto">
|
||||
<Header>Radio</Header>
|
||||
<img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" />
|
||||
<div class="m-1 text-center">
|
||||
<p>Radio is offline. Message for info!</p>
|
||||
<Button class="w-full" @click="checkStream()">Check Stream</Button>
|
||||
</div>
|
||||
<div v-if="streamLive" class="overflow-auto">
|
||||
<Header>Radio</Header>
|
||||
<img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" />
|
||||
<audio controls :src="streamUrl" ref="audio"></audio>
|
||||
</div>
|
||||
<div v-else class="overflow-auto">
|
||||
<Header>Radio</Header>
|
||||
<img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" />
|
||||
<div class="m-1 text-center">
|
||||
<p>Radio is offline. Message for info!</p>
|
||||
<Button class="w-full" @click="checkStream()">Check Stream</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -25,32 +25,32 @@ const streamLive = ref(false);
|
||||
const audio = useTemplateRef("audio");
|
||||
|
||||
async function checkStream() {
|
||||
try {
|
||||
await axios.head("/radio/stream");
|
||||
if (!streamLive.value) {
|
||||
streamLive.value = true;
|
||||
streamUrl.value = "/radio/stream";
|
||||
await nextTick();
|
||||
if (audio.value) {
|
||||
audio.value.load();
|
||||
audio.value.volume = 0.2;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
streamLive.value = false;
|
||||
try {
|
||||
await axios.head("/radio/stream");
|
||||
if (!streamLive.value) {
|
||||
streamLive.value = true;
|
||||
streamUrl.value = "/radio/stream";
|
||||
await nextTick();
|
||||
if (audio.value) {
|
||||
audio.value.load();
|
||||
audio.value.volume = 0.2;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
streamLive.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkStream();
|
||||
setInterval(checkStream, 120000);
|
||||
checkStream();
|
||||
setInterval(checkStream, 120000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
img {
|
||||
width: 100%;
|
||||
max-height: 150px;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
max-height: 150px;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
linkArr: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
linkArr: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const keys = ["name", "link"];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
class="bdr-2 bg-bg_primary"
|
||||
v-for="(row, rowIndex) in linkArr"
|
||||
:key="rowIndex"
|
||||
:to="row.link"
|
||||
>
|
||||
{{ row.name }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
class="bdr-2 bg-bg_primary"
|
||||
v-for="(row, rowIndex) in linkArr"
|
||||
:key="rowIndex"
|
||||
:to="row.link"
|
||||
>
|
||||
{{ row.name }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
@@ -3,14 +3,14 @@ import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import Header from "@/components/text/Header.vue";
|
||||
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
interval: {
|
||||
type: Number,
|
||||
default: 10000,
|
||||
},
|
||||
images: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
interval: {
|
||||
type: Number,
|
||||
default: 10000,
|
||||
},
|
||||
});
|
||||
|
||||
const currentIndex = ref(0);
|
||||
@@ -20,58 +20,58 @@ const currentUrl = computed(() => props.images[currentIndex.value].url);
|
||||
let nextId;
|
||||
|
||||
function nextImage() {
|
||||
clearTimeout(nextId);
|
||||
currentIndex.value = (currentIndex.value + 1) % props.images.length;
|
||||
nextId = setTimeout(nextImage, props.interval);
|
||||
clearTimeout(nextId);
|
||||
currentIndex.value = (currentIndex.value + 1) % props.images.length;
|
||||
nextId = setTimeout(nextImage, props.interval);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextId = setTimeout(nextImage, props.interval);
|
||||
nextId = setTimeout(nextImage, props.interval);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(nextId);
|
||||
clearTimeout(nextId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="slideshow-wrapper">
|
||||
<Transition name="fade">
|
||||
<div class="image-viewer" @click="nextImage" :key="currentIndex">
|
||||
<Header v-if="currentComment">
|
||||
{{ currentComment }}
|
||||
</Header>
|
||||
<img :src="currentUrl" alt="Image Viewer" fetchpriority="high" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="slideshow-wrapper">
|
||||
<Transition name="fade">
|
||||
<div class="image-viewer" @click="nextImage" :key="currentIndex">
|
||||
<Header v-if="currentComment">
|
||||
{{ currentComment }}
|
||||
</Header>
|
||||
<img :src="currentUrl" alt="Image Viewer" fetchpriority="high" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slideshow-wrapper {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-viewer {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,14 +8,14 @@ const day = ref("");
|
||||
const month = ref("");
|
||||
|
||||
function updateDateTime() {
|
||||
const date = new Date();
|
||||
day.value = date.getDate();
|
||||
time.value = date.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
weekday.value = date.toLocaleDateString("en-GB", { weekday: "long" });
|
||||
month.value = date.toLocaleDateString("en-GB", { month: "long" });
|
||||
const date = new Date();
|
||||
day.value = date.getDate();
|
||||
time.value = date.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
weekday.value = date.toLocaleDateString("en-GB", { weekday: "long" });
|
||||
month.value = date.toLocaleDateString("en-GB", { month: "long" });
|
||||
}
|
||||
|
||||
updateDateTime();
|
||||
@@ -24,15 +24,15 @@ setInterval(updateDateTime, 60000);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<Header>{{ weekday }} {{ day }}, {{ month }}</Header>
|
||||
<h1>{{ time }}</h1>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<Header>{{ weekday }} {{ day }}, {{ month }}</Header>
|
||||
<h1>{{ time }}</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,109 +18,109 @@ const seconds = ref(0);
|
||||
const audio = new Audio("/sound/auughhh.mp3");
|
||||
|
||||
function tick() {
|
||||
seconds.value++;
|
||||
if (seconds.value === 60) {
|
||||
minutes.value++;
|
||||
seconds.value = 0;
|
||||
}
|
||||
seconds.value++;
|
||||
if (seconds.value === 60) {
|
||||
minutes.value++;
|
||||
seconds.value = 0;
|
||||
}
|
||||
|
||||
if (minutes.value >= minutesInput.value) {
|
||||
if (seconds.value >= secondsInput.value) {
|
||||
finished.value = true;
|
||||
playFinishedSound();
|
||||
clearInterval(timer.value);
|
||||
}
|
||||
if (minutes.value >= minutesInput.value) {
|
||||
if (seconds.value >= secondsInput.value) {
|
||||
finished.value = true;
|
||||
playFinishedSound();
|
||||
clearInterval(timer.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
finished.value = false;
|
||||
paused.value = false;
|
||||
timer.value = setInterval(tick, 1000);
|
||||
finished.value = false;
|
||||
paused.value = false;
|
||||
timer.value = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
function pauseTimer() {
|
||||
if (finished.value) return;
|
||||
if (finished.value) return;
|
||||
|
||||
if (paused.value) {
|
||||
timer.value = setInterval(tick, 1000);
|
||||
paused.value = false;
|
||||
} else {
|
||||
clearInterval(timer.value);
|
||||
paused.value = true;
|
||||
}
|
||||
if (paused.value) {
|
||||
timer.value = setInterval(tick, 1000);
|
||||
paused.value = false;
|
||||
} else {
|
||||
clearInterval(timer.value);
|
||||
paused.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function resetTimer() {
|
||||
finished.value = true;
|
||||
paused.value = true;
|
||||
clearInterval(timer.value);
|
||||
minutes.value = 0;
|
||||
seconds.value = 0;
|
||||
finished.value = true;
|
||||
paused.value = true;
|
||||
clearInterval(timer.value);
|
||||
minutes.value = 0;
|
||||
seconds.value = 0;
|
||||
}
|
||||
|
||||
function playFinishedSound() {
|
||||
audio.play();
|
||||
audio.play();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timer-root flex flex-col gap-1 p-1 items-center">
|
||||
<Header>Timer</Header>
|
||||
<div v-if="finished && paused" class="flex flex-col">
|
||||
<div class="flex flex-row p-2 place-content-around">
|
||||
<input
|
||||
class="w-2/3"
|
||||
v-model="minutesInput"
|
||||
type="range"
|
||||
min="0"
|
||||
max="59"
|
||||
aria-label="Minutes"
|
||||
/>
|
||||
<p>{{ minutesInput }}m</p>
|
||||
</div>
|
||||
<div class="flex flex-row p-2 place-content-around">
|
||||
<input
|
||||
class="w-2/3"
|
||||
v-model="secondsInput"
|
||||
type="range"
|
||||
min="0"
|
||||
max="59"
|
||||
aria-label="Seconds"
|
||||
/>
|
||||
<p>{{ secondsInput }}s</p>
|
||||
</div>
|
||||
<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 class="timer-root flex flex-col gap-1 p-1 items-center">
|
||||
<Header>Timer</Header>
|
||||
<div v-if="finished && paused" class="flex flex-col">
|
||||
<div class="flex flex-row p-2 place-content-around">
|
||||
<input
|
||||
class="w-2/3"
|
||||
v-model="minutesInput"
|
||||
type="range"
|
||||
min="0"
|
||||
max="59"
|
||||
aria-label="Minutes"
|
||||
/>
|
||||
<p>{{ minutesInput }}m</p>
|
||||
</div>
|
||||
<div class="flex flex-row p-2 place-content-around">
|
||||
<input
|
||||
class="w-2/3"
|
||||
v-model="secondsInput"
|
||||
type="range"
|
||||
min="0"
|
||||
max="59"
|
||||
aria-label="Seconds"
|
||||
/>
|
||||
<p>{{ secondsInput }}s</p>
|
||||
</div>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (max-width: 850px) {
|
||||
.timer-root {
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
.timer-root {
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="overflow-auto w-full h-full"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseLeave"
|
||||
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
ref="container"
|
||||
class="overflow-auto w-full h-full"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseLeave"
|
||||
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -23,45 +23,45 @@ const scrollLeft = ref(0);
|
||||
const scrollTop = ref(0);
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
isDragging.value = true;
|
||||
startX.value = e.pageX - container.value.offsetLeft;
|
||||
startY.value = e.pageY - container.value.offsetTop;
|
||||
scrollLeft.value = container.value.scrollLeft;
|
||||
scrollTop.value = container.value.scrollTop;
|
||||
isDragging.value = true;
|
||||
startX.value = e.pageX - container.value.offsetLeft;
|
||||
startY.value = e.pageY - container.value.offsetTop;
|
||||
scrollLeft.value = container.value.scrollLeft;
|
||||
scrollTop.value = container.value.scrollTop;
|
||||
|
||||
// Prevent text selection while dragging
|
||||
e.preventDefault();
|
||||
// Prevent text selection while dragging
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isDragging.value) return;
|
||||
if (!isDragging.value) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.preventDefault();
|
||||
|
||||
const x = e.pageX - container.value.offsetLeft;
|
||||
const y = e.pageY - container.value.offsetTop;
|
||||
const walkX = (x - startX.value) * 1; // Multiply by scroll speed factor
|
||||
const walkY = (y - startY.value) * 1;
|
||||
const x = e.pageX - container.value.offsetLeft;
|
||||
const y = e.pageY - container.value.offsetTop;
|
||||
const walkX = (x - startX.value) * 1; // Multiply by scroll speed factor
|
||||
const walkY = (y - startY.value) * 1;
|
||||
|
||||
container.value.scrollLeft = scrollLeft.value - walkX;
|
||||
container.value.scrollTop = scrollTop.value - walkY;
|
||||
container.value.scrollLeft = scrollLeft.value - walkX;
|
||||
container.value.scrollTop = scrollTop.value - walkY;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.value = false;
|
||||
isDragging.value = false;
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isDragging.value = false;
|
||||
isDragging.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Prevent text selection while dragging */
|
||||
div[style*="cursor: grabbing"] {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,29 +2,29 @@
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
sourceArr: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
sourceArr: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
function sourceType(link) {
|
||||
return "video/" + link.split(".").pop();
|
||||
return "video/" + link.split(".").pop();
|
||||
}
|
||||
|
||||
const keys = ["name", "link"];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<video
|
||||
v-for="(source, rowIndex) in sourceArr"
|
||||
:key="rowIndex"
|
||||
class="bdr-1"
|
||||
width="300"
|
||||
height="400"
|
||||
controls
|
||||
preload="none"
|
||||
>
|
||||
<source :src="source.link" :type="sourceType(source.link)" />
|
||||
</video>
|
||||
<video
|
||||
v-for="(source, rowIndex) in sourceArr"
|
||||
:key="rowIndex"
|
||||
class="bdr-1"
|
||||
width="300"
|
||||
height="400"
|
||||
controls
|
||||
preload="none"
|
||||
>
|
||||
<source :src="source.link" :type="sourceType(source.link)" />
|
||||
</video>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll">
|
||||
<RouterLink to="/" class="bdr-2">
|
||||
<img src="/img/memes/epic.jpeg" alt="" loading="lazy" />
|
||||
</RouterLink>
|
||||
<h1>Click her, she will take you home</h1>
|
||||
<span style="height: 100px"></span>
|
||||
<h1>WIP</h1>
|
||||
<h4>Sorry for taking you here</h4>
|
||||
</div>
|
||||
<div class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll">
|
||||
<RouterLink to="/" class="bdr-2">
|
||||
<img src="/img/memes/epic.jpeg" alt="" loading="lazy" />
|
||||
</RouterLink>
|
||||
<h1>Click her, she will take you home</h1>
|
||||
<span style="height: 100px"></span>
|
||||
<h1>WIP</h1>
|
||||
<h4>Sorry for taking you here</h4>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div ref="container" class="overflow-y-auto">
|
||||
<slot />
|
||||
</div>
|
||||
<div ref="container" class="overflow-y-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -13,14 +13,14 @@ const container = useTemplateRef("container");
|
||||
let scroller = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (!container.value) return;
|
||||
scroller = new AutoScroller(container.value);
|
||||
scroller.start();
|
||||
if (!container.value) return;
|
||||
scroller = new AutoScroller(container.value);
|
||||
scroller.start();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
scroller?.destroy();
|
||||
scroller?.free();
|
||||
scroller = null;
|
||||
scroller?.destroy();
|
||||
scroller?.free();
|
||||
scroller = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from "axios";
|
||||
|
||||
export async function gql(query, variables = {}) {
|
||||
const res = await axios.post("/api/graphql", { query, variables });
|
||||
if (res.data.errors && !res.data.data) throw new Error(res.data.errors[0].message);
|
||||
if (res.data.errors && !res.data.data)
|
||||
throw new Error(res.data.errors[0].message);
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
@@ -3,41 +3,69 @@ import { RouterView } from "vue-router";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cv-layout">
|
||||
<RouterView />
|
||||
</div>
|
||||
<div class="cv-layout">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cv-layout {
|
||||
min-height: 100vh;
|
||||
background: white;
|
||||
color: #111;
|
||||
min-height: 100vh;
|
||||
background: white;
|
||||
color: #111;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.cv-layout h1, .cv-layout h2, .cv-layout h3, .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,
|
||||
.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 {
|
||||
color: #111;
|
||||
background-color: transparent !important;
|
||||
letter-spacing: normal;
|
||||
color: #111;
|
||||
background-color: transparent !important;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.cv-layout input, .cv-layout textarea {
|
||||
color: #111;
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
.cv-layout input,
|
||||
.cv-layout textarea {
|
||||
color: #111;
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
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>
|
||||
|
||||
@@ -5,28 +5,28 @@ import Footer from "@/components/Footer.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="default-layout halftone">
|
||||
<Navbar class="no-print sticky top-0 z-50" />
|
||||
<main class="default-content">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="slide" mode="out-in">
|
||||
<component :is="Component" :key="$route.path" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</main>
|
||||
<Footer class="no-print sticky bottom-0 z-50" />
|
||||
</div>
|
||||
<div class="default-layout halftone">
|
||||
<Navbar class="no-print sticky top-0 z-50" />
|
||||
<main class="default-content">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="slide" mode="out-in">
|
||||
<component :is="Component" :key="$route.path" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</main>
|
||||
<Footer class="no-print sticky bottom-0 z-50" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.default-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.default-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,96 +7,103 @@ import { useHomeDataStore } from "@/stores/homeData";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
component: DefaultLayout,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
component: DefaultLayout,
|
||||
children: [
|
||||
{
|
||||
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: "",
|
||||
name: "landing",
|
||||
component: Landing,
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
],
|
||||
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",
|
||||
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) => {
|
||||
if (!to.meta.requiresAdmin) return;
|
||||
const homeData = useHomeDataStore();
|
||||
if (!homeData.loaded) {
|
||||
await new Promise((resolve) => {
|
||||
const stop = watch(() => homeData.loaded, (val) => {
|
||||
if (val) { stop(); resolve(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
if (!useAuthStore().user.admin) return { path: "/admin/login", query: { redirect: to.fullPath } };
|
||||
if (!to.meta.requiresAdmin) return;
|
||||
const homeData = useHomeDataStore();
|
||||
if (!homeData.loaded) {
|
||||
await new Promise((resolve) => {
|
||||
const stop = watch(
|
||||
() => homeData.loaded,
|
||||
(val) => {
|
||||
if (val) {
|
||||
stop();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!useAuthStore().user.admin)
|
||||
return { path: "/admin/login", query: { redirect: to.fullPath } };
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<main class="flex flex-col items-center">
|
||||
<div
|
||||
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
|
||||
>
|
||||
<h1>404</h1>
|
||||
<RouterLink to="/" class="bdr-2">
|
||||
<img src="/img/memes/epic.jpeg" loading="lazy" />
|
||||
</RouterLink>
|
||||
<h1>Click her, she will take you home</h1>
|
||||
</div>
|
||||
</main>
|
||||
<main class="flex flex-col items-center">
|
||||
<div
|
||||
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
|
||||
>
|
||||
<h1>404</h1>
|
||||
<RouterLink to="/" class="bdr-2">
|
||||
<img src="/img/memes/epic.jpeg" loading="lazy" />
|
||||
</RouterLink>
|
||||
<h1>Click her, she will take you home</h1>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,17 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<div class="sm:w-2/7 mt-auto mb-auto ml-0">
|
||||
<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 class="flex flex-col sm:flex-row">
|
||||
<div class="sm:w-2/7 mt-auto mb-auto ml-0">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
<div class="w-full p-2">
|
||||
<div
|
||||
class="flex-row flex place-content-between m-auto place-items-center"
|
||||
>
|
||||
<slot name="top" />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,15 +9,15 @@ import ManageRadio from "./ManageRadio.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="justify-center flex flex-row w-full h-full">
|
||||
<div class="bdr-1 flex flex-col">
|
||||
<CreateUser class="bdr-2 bg-bg_primary" />
|
||||
<CreatePost class="bdr-2 bg-bg_primary" />
|
||||
<CreateFavorite class="bdr-2 bg-bg_primary" />
|
||||
<CreateActivity class="bdr-2 bg-bg_primary" />
|
||||
<CreateRowing class="bdr-2 bg-bg_primary" />
|
||||
<ManageUsers class="bdr-2 bg-bg_primary" />
|
||||
<ManageRadio class="bdr-2 bg-bg_primary" />
|
||||
</div>
|
||||
</main>
|
||||
<main class="justify-center flex flex-row w-full h-full">
|
||||
<div class="bdr-1 flex flex-col">
|
||||
<CreateUser class="bdr-2 bg-bg_primary" />
|
||||
<CreatePost class="bdr-2 bg-bg_primary" />
|
||||
<CreateFavorite class="bdr-2 bg-bg_primary" />
|
||||
<CreateActivity class="bdr-2 bg-bg_primary" />
|
||||
<CreateRowing class="bdr-2 bg-bg_primary" />
|
||||
<ManageUsers class="bdr-2 bg-bg_primary" />
|
||||
<ManageRadio class="bdr-2 bg-bg_primary" />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -11,29 +11,35 @@ const name = ref("");
|
||||
const link = ref("");
|
||||
|
||||
async function post() {
|
||||
try {
|
||||
const data = await gql(
|
||||
`mutation CreateActivity($input: CreateActivityInput!) { createActivity(input: $input) { id } }`,
|
||||
{ input: { type: type.value, name: name.value, link: link.value || undefined } },
|
||||
);
|
||||
type.value = "";
|
||||
name.value = "";
|
||||
link.value = "";
|
||||
console.log(data.createActivity);
|
||||
emit("done");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
try {
|
||||
const data = await gql(
|
||||
`mutation CreateActivity($input: CreateActivityInput!) { createActivity(input: $input) { id } }`,
|
||||
{
|
||||
input: {
|
||||
type: type.value,
|
||||
name: name.value,
|
||||
link: link.value || undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
type.value = "";
|
||||
name.value = "";
|
||||
link.value = "";
|
||||
console.log(data.createActivity);
|
||||
emit("done");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<h1>Create Activity</h1>
|
||||
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" />
|
||||
<input type="text" v-model="name" placeholder="Name" @keyup.enter="post" />
|
||||
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
|
||||
<Button @click="post">Upload</Button>
|
||||
<Button @click="emit('cancel')">Cancel</Button>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1>Create Activity</h1>
|
||||
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" />
|
||||
<input type="text" v-model="name" placeholder="Name" @keyup.enter="post" />
|
||||
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
|
||||
<Button @click="post">Upload</Button>
|
||||
<Button @click="emit('cancel')">Cancel</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,28 +10,40 @@ const name = ref("");
|
||||
const link = ref("");
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await gql(
|
||||
`mutation CreateBookmark($input: CreateBookmarkInput!) { createBookmark(input: $input) { id } }`,
|
||||
{ input: { category: category.value, name: name.value, link: link.value } },
|
||||
);
|
||||
category.value = "";
|
||||
name.value = "";
|
||||
link.value = "";
|
||||
emit("done");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
try {
|
||||
await gql(
|
||||
`mutation CreateBookmark($input: CreateBookmarkInput!) { createBookmark(input: $input) { id } }`,
|
||||
{
|
||||
input: { category: category.value, name: name.value, link: link.value },
|
||||
},
|
||||
);
|
||||
category.value = "";
|
||||
name.value = "";
|
||||
link.value = "";
|
||||
emit("done");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<h1>Create Bookmark</h1>
|
||||
<input type="text" v-model="category" placeholder="Category" />
|
||||
<input type="text" v-model="name" placeholder="Name" @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>
|
||||
<div class="flex flex-col">
|
||||
<h1>Create Bookmark</h1>
|
||||
<input type="text" v-model="category" placeholder="Category" />
|
||||
<input
|
||||
type="text"
|
||||
v-model="name"
|
||||
placeholder="Name"
|
||||
@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>
|
||||
|
||||
@@ -11,29 +11,35 @@ const name = ref("");
|
||||
const link = ref("");
|
||||
|
||||
async function post() {
|
||||
try {
|
||||
const data = await gql(
|
||||
`mutation CreateFavorite($input: CreateFavoriteInput!) { createFavorite(input: $input) { id } }`,
|
||||
{ input: { type: type.value, name: name.value, link: link.value || undefined } },
|
||||
);
|
||||
type.value = "";
|
||||
name.value = "";
|
||||
link.value = "";
|
||||
console.log(data.createFavorite);
|
||||
emit("done");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
try {
|
||||
const data = await gql(
|
||||
`mutation CreateFavorite($input: CreateFavoriteInput!) { createFavorite(input: $input) { id } }`,
|
||||
{
|
||||
input: {
|
||||
type: type.value,
|
||||
name: name.value,
|
||||
link: link.value || undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
type.value = "";
|
||||
name.value = "";
|
||||
link.value = "";
|
||||
console.log(data.createFavorite);
|
||||
emit("done");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<h1>Create Favorite</h1>
|
||||
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" />
|
||||
<input type="text" v-model="name" placeholder="Name" @keyup.enter="post" />
|
||||
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
|
||||
<Button @click="post">Upload</Button>
|
||||
<Button @click="emit('cancel')">Cancel</Button>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1>Create Favorite</h1>
|
||||
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" />
|
||||
<input type="text" v-model="name" placeholder="Name" @keyup.enter="post" />
|
||||
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
|
||||
<Button @click="post">Upload</Button>
|
||||
<Button @click="emit('cancel')">Cancel</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,31 +9,32 @@ const title = ref("");
|
||||
const content = ref("");
|
||||
|
||||
async function post() {
|
||||
try {
|
||||
const data = await gql(
|
||||
`mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id } }`,
|
||||
{ input: { title: title.value, content: content.value } },
|
||||
);
|
||||
title.value = "";
|
||||
content.value = "";
|
||||
console.log(data.createPost);
|
||||
emit("done");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
try {
|
||||
const data = await gql(
|
||||
`mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id } }`,
|
||||
{ input: { title: title.value, content: content.value } },
|
||||
);
|
||||
title.value = "";
|
||||
content.value = "";
|
||||
console.log(data.createPost);
|
||||
emit("done");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<h1>Create Post</h1>
|
||||
<input type="text" v-model="title" placeholder="Title" @keyup.enter="post" />
|
||||
<textarea
|
||||
class="h-50"
|
||||
v-model="content"
|
||||
placeholder="Content"
|
||||
></textarea>
|
||||
<Button @click="post">Upload</Button>
|
||||
<Button @click="emit('cancel')">Cancel</Button>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1>Create Post</h1>
|
||||
<input
|
||||
type="text"
|
||||
v-model="title"
|
||||
placeholder="Title"
|
||||
@keyup.enter="post"
|
||||
/>
|
||||
<textarea class="h-50" v-model="content" placeholder="Content"></textarea>
|
||||
<Button @click="post">Upload</Button>
|
||||
<Button @click="emit('cancel')">Cancel</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,47 +9,60 @@ const images = ref([]);
|
||||
const results = ref([]);
|
||||
|
||||
function onFileChange(e) {
|
||||
images.value = Array.from(e.target.files);
|
||||
results.value = [];
|
||||
images.value = Array.from(e.target.files);
|
||||
results.value = [];
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!images.value.length) return;
|
||||
results.value = images.value.map((f) => ({ name: f.name, status: "Uploading..." }));
|
||||
if (!images.value.length) return;
|
||||
results.value = images.value.map((f) => ({
|
||||
name: f.name,
|
||||
status: "Uploading...",
|
||||
}));
|
||||
|
||||
await Promise.all(
|
||||
images.value.map(async (file, i) => {
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
try {
|
||||
const res = await axios.post("/api/rowing", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
const mins = Math.floor(res.data.Time / 1e9 / 60);
|
||||
const secs = String(Math.floor((res.data.Time / 1e9) % 60)).padStart(2, "0");
|
||||
results.value[i].status = `${res.data.Distance}m in ${mins}:${secs}`;
|
||||
results.value[i].ok = true;
|
||||
} catch (err) {
|
||||
results.value[i].status = err.response?.data?.error || "Upload failed";
|
||||
results.value[i].ok = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
await Promise.all(
|
||||
images.value.map(async (file, i) => {
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
try {
|
||||
const res = await axios.post("/api/rowing", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
const mins = Math.floor(res.data.Time / 1e9 / 60);
|
||||
const secs = String(Math.floor((res.data.Time / 1e9) % 60)).padStart(
|
||||
2,
|
||||
"0",
|
||||
);
|
||||
results.value[i].status = `${res.data.Distance}m in ${mins}:${secs}`;
|
||||
results.value[i].ok = true;
|
||||
} catch (err) {
|
||||
results.value[i].status = err.response?.data?.error || "Upload failed";
|
||||
results.value[i].ok = false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
images.value = [];
|
||||
emit("done");
|
||||
images.value = [];
|
||||
emit("done");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1>Create Rowing</h1>
|
||||
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple @change="onFileChange" />
|
||||
<Button @click="submit">Upload</Button>
|
||||
<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 class="flex flex-col gap-2">
|
||||
<h1>Create Rowing</h1>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
multiple
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<Button @click="submit">Upload</Button>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -11,35 +11,45 @@ const message = ref("");
|
||||
const error = ref("");
|
||||
|
||||
async function handleCreate() {
|
||||
message.value = "";
|
||||
error.value = "";
|
||||
try {
|
||||
const data = await gql(
|
||||
`mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id username } }`,
|
||||
{ input: { username: username.value, password: password.value } },
|
||||
);
|
||||
message.value = `User "${data.createUser.username}" created successfully.`;
|
||||
username.value = "";
|
||||
password.value = "";
|
||||
} catch (err) {
|
||||
error.value = err.message || "Failed to create user.";
|
||||
}
|
||||
message.value = "";
|
||||
error.value = "";
|
||||
try {
|
||||
const data = await gql(
|
||||
`mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id username } }`,
|
||||
{ input: { username: username.value, password: password.value } },
|
||||
);
|
||||
message.value = `User "${data.createUser.username}" created successfully.`;
|
||||
username.value = "";
|
||||
password.value = "";
|
||||
} catch (err) {
|
||||
error.value = err.message || "Failed to create user.";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="auth.loggedIn && auth.user.admin" class="flex flex-col">
|
||||
<h1>Create User</h1>
|
||||
<p v-if="message" class="text-green-500">{{ message }}</p>
|
||||
<p v-if="error" class="text-red-500">{{ error }}</p>
|
||||
<input type="text" v-model="username" placeholder="Username" @keyup.enter="handleCreate" />
|
||||
<input type="password" v-model="password" placeholder="Password" @keyup.enter="handleCreate" />
|
||||
<Button @click="handleCreate">Create Account</Button>
|
||||
</div>
|
||||
<div v-else-if="auth.loggedIn" class="flex flex-col">
|
||||
<p>You do not have permission to create users.</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col">
|
||||
<p>You must be logged in as an admin to create users.</p>
|
||||
</div>
|
||||
<div v-if="auth.loggedIn && auth.user.admin" class="flex flex-col">
|
||||
<h1>Create User</h1>
|
||||
<p v-if="message" class="text-green-500">{{ message }}</p>
|
||||
<p v-if="error" class="text-red-500">{{ error }}</p>
|
||||
<input
|
||||
type="text"
|
||||
v-model="username"
|
||||
placeholder="Username"
|
||||
@keyup.enter="handleCreate"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
v-model="password"
|
||||
placeholder="Password"
|
||||
@keyup.enter="handleCreate"
|
||||
/>
|
||||
<Button @click="handleCreate">Create Account</Button>
|
||||
</div>
|
||||
<div v-else-if="auth.loggedIn" class="flex flex-col">
|
||||
<p>You do not have permission to create users.</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col">
|
||||
<p>You must be logged in as an admin to create users.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,29 +12,39 @@ const username = ref("");
|
||||
const password = ref("");
|
||||
|
||||
async function handleLogin() {
|
||||
await auth.logIn(username.value, password.value);
|
||||
if (auth.loggedIn && route.query.redirect) {
|
||||
router.push(route.query.redirect);
|
||||
}
|
||||
await auth.logIn(username.value, password.value);
|
||||
if (auth.loggedIn && route.query.redirect) {
|
||||
router.push(route.query.redirect);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logOut();
|
||||
auth.logOut();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="auth.loggedIn" class="flex flex-col">
|
||||
<h1>Logged in</h1>
|
||||
<p>{{ auth.user.id }}</p>
|
||||
<p>{{ auth.user.username }}</p>
|
||||
<p>{{ auth.user.admin }}</p>
|
||||
<Button @click="handleLogout">Log Out</Button>
|
||||
</div>
|
||||
<div v-else class="flex flex-col">
|
||||
<h1>Login</h1>
|
||||
<input type="text" v-model="username" placeholder="Username" @keyup.enter="handleLogin" />
|
||||
<input type="password" v-model="password" placeholder="Password" @keyup.enter="handleLogin" />
|
||||
<Button @click="handleLogin">Log In</Button>
|
||||
</div>
|
||||
<div v-if="auth.loggedIn" class="flex flex-col">
|
||||
<h1>Logged in</h1>
|
||||
<p>{{ auth.user.id }}</p>
|
||||
<p>{{ auth.user.username }}</p>
|
||||
<p>{{ auth.user.admin }}</p>
|
||||
<Button @click="handleLogout">Log Out</Button>
|
||||
</div>
|
||||
<div v-else class="flex flex-col">
|
||||
<h1>Login</h1>
|
||||
<input
|
||||
type="text"
|
||||
v-model="username"
|
||||
placeholder="Username"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
v-model="password"
|
||||
placeholder="Password"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
<Button @click="handleLogin">Log In</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,94 +9,108 @@ const results = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function fetchSongs() {
|
||||
try {
|
||||
const res = await axios.get("/api/radio/songs");
|
||||
songs.value = res.data.songs;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
try {
|
||||
const res = await axios.get("/api/radio/songs");
|
||||
songs.value = res.data.songs;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(e) {
|
||||
files.value = Array.from(e.target.files);
|
||||
results.value = [];
|
||||
files.value = Array.from(e.target.files);
|
||||
results.value = [];
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
if (!files.value.length) return;
|
||||
loading.value = true;
|
||||
results.value = files.value.map((f) => ({ name: f.name, status: "Uploading..." }));
|
||||
if (!files.value.length) return;
|
||||
loading.value = true;
|
||||
results.value = files.value.map((f) => ({
|
||||
name: f.name,
|
||||
status: "Uploading...",
|
||||
}));
|
||||
|
||||
await Promise.all(
|
||||
files.value.map(async (file, i) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
await axios.post("/api/radio/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
results.value[i].status = "Uploaded";
|
||||
results.value[i].ok = true;
|
||||
} catch (err) {
|
||||
results.value[i].status = err.response?.data?.error || "Upload failed";
|
||||
results.value[i].ok = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
await Promise.all(
|
||||
files.value.map(async (file, i) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
await axios.post("/api/radio/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
results.value[i].status = "Uploaded";
|
||||
results.value[i].ok = true;
|
||||
} catch (err) {
|
||||
results.value[i].status = err.response?.data?.error || "Upload failed";
|
||||
results.value[i].ok = false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
files.value = [];
|
||||
loading.value = false;
|
||||
await fetchSongs();
|
||||
files.value = [];
|
||||
loading.value = false;
|
||||
await fetchSongs();
|
||||
}
|
||||
|
||||
async function deleteSong(name) {
|
||||
try {
|
||||
await axios.delete(`/api/radio/songs/${encodeURIComponent(name)}`);
|
||||
await fetchSongs();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
try {
|
||||
await axios.delete(`/api/radio/songs/${encodeURIComponent(name)}`);
|
||||
await fetchSongs();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSong(song) {
|
||||
const action = song.disabled ? "enable" : "disable";
|
||||
try {
|
||||
await axios.patch(`/api/radio/songs/${encodeURIComponent(song.name)}/${action}`);
|
||||
await fetchSongs();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
const action = song.disabled ? "enable" : "disable";
|
||||
try {
|
||||
await axios.patch(
|
||||
`/api/radio/songs/${encodeURIComponent(song.name)}/${action}`,
|
||||
);
|
||||
await fetchSongs();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
onMounted(fetchSongs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1>Manage Radio</h1>
|
||||
<input type="file" accept=".mp3,.ogg,.flac,.wav,.m4a,.opus" multiple @change="onFileChange" />
|
||||
<Button @click="upload" :disabled="loading">Upload</Button>
|
||||
<div v-for="r in results" :key="r.name">
|
||||
<span class="text-primary">{{ r.name }}: </span>
|
||||
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{ r.status }}</span>
|
||||
</div>
|
||||
<div
|
||||
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 class="flex flex-col gap-2">
|
||||
<h1>Manage Radio</h1>
|
||||
<input
|
||||
type="file"
|
||||
accept=".mp3,.ogg,.flac,.wav,.m4a,.opus"
|
||||
multiple
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<Button @click="upload" :disabled="loading">Upload</Button>
|
||||
<div v-for="r in results" :key="r.name">
|
||||
<span class="text-primary">{{ r.name }}: </span>
|
||||
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{
|
||||
r.status
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
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>
|
||||
|
||||
@@ -8,38 +8,39 @@ const auth = useAuthStore();
|
||||
const users = ref([]);
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const data = await gql(`query { users { id username admin } }`);
|
||||
users.value = data.users;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
try {
|
||||
const data = await gql(`query { users { id username admin } }`);
|
||||
users.value = data.users;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAdmin(user) {
|
||||
try {
|
||||
const data = await auth.setUserAdmin(user.id, !user.admin);
|
||||
user.admin = data.admin;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
try {
|
||||
const data = await auth.setUserAdmin(user.id, !user.admin);
|
||||
user.admin = data.admin;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchUsers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<h1>Manage Users</h1>
|
||||
<div v-for="user in users" :key="user.id" class="flex flex-row items-center gap-2">
|
||||
<span>{{ user.username }}</span>
|
||||
<span v-if="user.admin">(admin)</span>
|
||||
<Button
|
||||
v-if="user.id !== auth.user.id"
|
||||
@click="toggleAdmin(user)"
|
||||
>
|
||||
{{ user.admin ? "Demote" : "Promote" }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1>Manage Users</h1>
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="flex flex-row items-center gap-2"
|
||||
>
|
||||
<span>{{ user.username }}</span>
|
||||
<span v-if="user.admin">(admin)</span>
|
||||
<Button v-if="user.id !== auth.user.id" @click="toggleAdmin(user)">
|
||||
{{ user.admin ? "Demote" : "Promote" }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 charHeight: number = 14;
|
||||
@@ -10,33 +10,37 @@ let n: number;
|
||||
let m: number;
|
||||
|
||||
function setup() {
|
||||
display.value.style.fontSize = `${charHeight}px`;
|
||||
display.value.style.lineHeight = `${charHeight}px`;
|
||||
fillDisplay()
|
||||
display.value.style.fontSize = `${charHeight}px`;
|
||||
display.value.style.lineHeight = `${charHeight}px`;
|
||||
fillDisplay();
|
||||
}
|
||||
|
||||
function fillDisplay() {
|
||||
// M rows N columns
|
||||
m = Math.floor(display.value.offsetHeight / charHeight);
|
||||
n = Math.floor(display.value.offsetWidth / charWidth);
|
||||
const row = ' '.repeat(n);
|
||||
displayText.value = (row + '\n').repeat(m - 1) + row
|
||||
// M rows N columns
|
||||
m = Math.floor(display.value.offsetHeight / charHeight);
|
||||
n = Math.floor(display.value.offsetWidth / charWidth);
|
||||
const row = " ".repeat(n);
|
||||
displayText.value = (row + "\n").repeat(m - 1) + row;
|
||||
}
|
||||
|
||||
function close() {
|
||||
displayText.value = ""
|
||||
displayText.value = "";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setup()
|
||||
})
|
||||
setup();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
close()
|
||||
})
|
||||
close();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<pre class="overflow-scroll w-full h-full bg-black text-white m-0 p-0" id="container" ref="display">{{ displayText
|
||||
}}</pre>
|
||||
<pre
|
||||
class="overflow-scroll w-full h-full bg-black text-white m-0 p-0"
|
||||
id="container"
|
||||
ref="display"
|
||||
>{{ displayText }}</pre
|
||||
>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,9 @@ import Header from "@/components/text/Header.vue";
|
||||
import { useHomeDataStore } from "@/stores/homeData";
|
||||
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 authStore = useAuthStore();
|
||||
@@ -13,48 +15,57 @@ const authStore = useAuthStore();
|
||||
const showCreate = ref(false);
|
||||
|
||||
const groupedBookmarks = computed(() => {
|
||||
const groups = {};
|
||||
for (const b of homeData.bookmarks) {
|
||||
if (!groups[b.category]) groups[b.category] = [];
|
||||
groups[b.category].push(b);
|
||||
}
|
||||
return Object.entries(groups);
|
||||
const groups = {};
|
||||
for (const b of homeData.bookmarks) {
|
||||
if (!groups[b.category]) groups[b.category] = [];
|
||||
groups[b.category].push(b);
|
||||
}
|
||||
return Object.entries(groups);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bookmarks-wrapper">
|
||||
<Header class="text-left">
|
||||
<span class="flex items-center justify-between w-full">
|
||||
{{ showCreate ? "Create Bookmark" : "Bookmarks" }}
|
||||
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
|
||||
{{ showCreate ? "x" : "+" }}
|
||||
</button>
|
||||
</span>
|
||||
</Header>
|
||||
<CreateBookmark v-if="showCreate" class="flex-1 min-h-0 p-1" @done="showCreate = false" @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 class="bookmarks-wrapper">
|
||||
<Header class="text-left">
|
||||
<span class="flex items-center justify-between w-full">
|
||||
{{ showCreate ? "Create Bookmark" : "Bookmarks" }}
|
||||
<button
|
||||
v-if="authStore.user.admin"
|
||||
class="text-sm px-1"
|
||||
@click="showCreate = !showCreate"
|
||||
>
|
||||
{{ showCreate ? "x" : "+" }}
|
||||
</button>
|
||||
</span>
|
||||
</Header>
|
||||
<CreateBookmark
|
||||
v-if="showCreate"
|
||||
class="flex-1 min-h-0 p-1"
|
||||
@done="showCreate = false"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bookmarks-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bookmarks-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
import Slideshow from "@/components/util/Slideshow.vue";
|
||||
|
||||
const images = [
|
||||
{ url: "/img/memes/pidgeon.gif", comment: "鸟" },
|
||||
// { url: "/img/memes/no_slip.png" },
|
||||
// { url: "/img/memes/epic.jpeg" },
|
||||
// { url: "/img/bedroom/img2.png", comment: "办公桌" },
|
||||
// { url: "/img/bedroom/img1.png", comment: "床" },
|
||||
{ url: "/img/memes/pidgeon.gif", comment: "鸟" },
|
||||
// { url: "/img/memes/no_slip.png" },
|
||||
// { url: "/img/memes/epic.jpeg" },
|
||||
// { url: "/img/bedroom/img2.png", comment: "办公桌" },
|
||||
// { url: "/img/bedroom/img1.png", comment: "床" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slideshow :images="images" />
|
||||
<Slideshow :images="images" />
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,9 @@ import { ref, defineAsyncComponent } from "vue";
|
||||
import { useActivityStore } from "@/stores/activity";
|
||||
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 authStore = useAuthStore();
|
||||
@@ -15,18 +17,31 @@ const showCreate = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center">
|
||||
<Header>
|
||||
<span class="flex items-center justify-between w-full">
|
||||
{{ showCreate ? "Create Activity" : "Consumption" }}
|
||||
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
|
||||
{{ showCreate ? "x" : "+" }}
|
||||
</button>
|
||||
</span>
|
||||
</Header>
|
||||
<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>
|
||||
<div class="flex flex-col items-center">
|
||||
<Header>
|
||||
<span class="flex items-center justify-between w-full">
|
||||
{{ showCreate ? "Create Activity" : "Consumption" }}
|
||||
<button
|
||||
v-if="authStore.user.admin"
|
||||
class="text-sm px-1"
|
||||
@click="showCreate = !showCreate"
|
||||
>
|
||||
{{ showCreate ? "x" : "+" }}
|
||||
</button>
|
||||
</span>
|
||||
</Header>
|
||||
<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>
|
||||
|
||||
@@ -7,7 +7,9 @@ import { ref, defineAsyncComponent } from "vue";
|
||||
import { useFavoritesStore } from "@/stores/favorites";
|
||||
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 authStore = useAuthStore();
|
||||
@@ -15,22 +17,31 @@ const showCreate = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center">
|
||||
<Header>
|
||||
<span class="flex items-center justify-between w-full">
|
||||
{{ showCreate ? "Create Favorite" : "favs" }}
|
||||
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
|
||||
{{ showCreate ? "x" : "+" }}
|
||||
</button>
|
||||
</span>
|
||||
</Header>
|
||||
<CreateFavorite v-if="showCreate" class="w-full flex-1 p-1" @done="showCreate = false" @cancel="showCreate = false" />
|
||||
<AutoScroll v-if="!showCreate" class="w-full flex-1">
|
||||
<LinkTable
|
||||
variant="table"
|
||||
class="w-full"
|
||||
:items="favoritesStore.favorites"
|
||||
/>
|
||||
</AutoScroll>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<Header>
|
||||
<span class="flex items-center justify-between w-full">
|
||||
{{ showCreate ? "Create Favorite" : "favs" }}
|
||||
<button
|
||||
v-if="authStore.user.admin"
|
||||
class="text-sm px-1"
|
||||
@click="showCreate = !showCreate"
|
||||
>
|
||||
{{ showCreate ? "x" : "+" }}
|
||||
</button>
|
||||
</span>
|
||||
</Header>
|
||||
<CreateFavorite
|
||||
v-if="showCreate"
|
||||
class="w-full flex-1 p-1"
|
||||
@done="showCreate = false"
|
||||
@cancel="showCreate = false"
|
||||
/>
|
||||
<AutoScroll v-if="!showCreate" class="w-full flex-1">
|
||||
<LinkTable
|
||||
variant="table"
|
||||
class="w-full"
|
||||
:items="favoritesStore.favorites"
|
||||
/>
|
||||
</AutoScroll>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,9 @@ import { ref, computed, defineAsyncComponent } from "vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
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 postsStore = usePostsStore();
|
||||
@@ -20,75 +22,70 @@ const rightCap = computed(() => idx.value === postsStore.postsCount - 1);
|
||||
|
||||
const post = computed(() => postsStore.posts[idx.value]);
|
||||
const userOwnsPost = computed(
|
||||
() => post.value.author.username == authStore.user.username,
|
||||
() => post.value.author.username == authStore.user.username,
|
||||
);
|
||||
|
||||
function nextPost() {
|
||||
if (idx.value < postsStore.postsCount - 1) {
|
||||
idx.value++;
|
||||
}
|
||||
if (idx.value < postsStore.postsCount - 1) {
|
||||
idx.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function prevPost() {
|
||||
if (idx.value > 0) {
|
||||
idx.value--;
|
||||
}
|
||||
if (idx.value > 0) {
|
||||
idx.value--;
|
||||
}
|
||||
}
|
||||
|
||||
function deletePost() {
|
||||
postsStore.deletePost(post.value);
|
||||
postsStore.deletePost(post.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<Header>
|
||||
<span class="flex items-center justify-between w-full">
|
||||
{{ showCreate ? "Create Post" : post.title }}
|
||||
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
|
||||
{{ showCreate ? "x" : "+" }}
|
||||
</button>
|
||||
</span>
|
||||
</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"
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<Header>
|
||||
<span class="flex items-center justify-between w-full">
|
||||
{{ showCreate ? "Create Post" : post.title }}
|
||||
<button
|
||||
v-if="authStore.user.admin"
|
||||
class="text-sm px-1"
|
||||
@click="showCreate = !showCreate"
|
||||
>
|
||||
<small
|
||||
>Created at:
|
||||
{{ new Date(post.createdAt).toLocaleString() }}</small
|
||||
>
|
||||
<small>By: {{ post.author.username }}</small>
|
||||
<Markdown
|
||||
class="flex-1 border-box text-wrap"
|
||||
:source="post.content"
|
||||
/>
|
||||
<div class="flex flex-row w-full">
|
||||
<Button
|
||||
class="flex-1 border-box"
|
||||
v-if="!leftCap"
|
||||
@click="prevPost"
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1 border-box"
|
||||
v-if="!rightCap"
|
||||
@click="nextPost"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
<Button class="w-full" v-if="userOwnsPost" @click="deletePost"
|
||||
>Delete</Button
|
||||
>
|
||||
</div>
|
||||
{{ showCreate ? "x" : "+" }}
|
||||
</button>
|
||||
</span>
|
||||
</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>Created at: {{ new Date(post.createdAt).toLocaleString() }}</small>
|
||||
<small>By: {{ post.author.username }}</small>
|
||||
<Markdown class="flex-1 border-box text-wrap" :source="post.content" />
|
||||
<div class="flex flex-row w-full">
|
||||
<Button class="flex-1 border-box" v-if="!leftCap" @click="prevPost">
|
||||
Prev
|
||||
</Button>
|
||||
<Button class="flex-1 border-box" v-if="!rightCap" @click="nextPost">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
<Button class="w-full" v-if="userOwnsPost" @click="deletePost"
|
||||
>Delete</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
img {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,25 +2,25 @@
|
||||
import Header from "@/components/text/Header.vue";
|
||||
import LinkTable from "@/components/util/LinkTable.vue";
|
||||
const gym = [
|
||||
{ name: "Row", type: "30 min" },
|
||||
{ name: "Run", type: "5k" },
|
||||
{ name: "Pushup & Squat", type: "50" },
|
||||
{ name: "Row", type: "30 min" },
|
||||
{ name: "Run", type: "5k" },
|
||||
{ name: "Pushup & Squat", type: "50" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col place-content-between items-center">
|
||||
<Header>Gym</Header>
|
||||
<p>I'm not a gym geek</p>
|
||||
<p>4/7 days I do:</p>
|
||||
<div class="overflow-scroll w-full border-box">
|
||||
<LinkTable variant="table" class="w-full" :items="gym" />
|
||||
</div>
|
||||
<div class="flex flex-col place-content-between items-center">
|
||||
<Header>Gym</Header>
|
||||
<p>I'm not a gym geek</p>
|
||||
<p>4/7 days I do:</p>
|
||||
<div class="overflow-scroll w-full border-box">
|
||||
<LinkTable variant="table" class="w-full" :items="gym" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
img {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useHomeDataStore } from "@/stores/homeData";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const CreateRowing = defineAsyncComponent(() => import("@/views/admin/CreateRowing.vue"));
|
||||
const CreateRowing = defineAsyncComponent(
|
||||
() => import("@/views/admin/CreateRowing.vue"),
|
||||
);
|
||||
|
||||
const store = useHomeDataStore();
|
||||
const authStore = useAuthStore();
|
||||
@@ -117,13 +119,22 @@ function formatValue(key, val) {
|
||||
<Header>
|
||||
<span class="flex items-center justify-between w-full">
|
||||
{{ 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" : "+" }}
|
||||
</button>
|
||||
</span>
|
||||
</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">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
|
||||
@@ -25,223 +25,219 @@ import Bookmarks from "./Bookmarks.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="justify-center flex flex-row w-full h-full overflow-x-hidden">
|
||||
<div class="outerWrap flex flex-row">
|
||||
<div class="sidebar">
|
||||
<Time class="time-sidebar sidebar-cell" />
|
||||
<Timer class="timer-sidebar sidebar-cell" />
|
||||
<Radio class="radio-sidebar sidebar-cell" />
|
||||
<CommitHistory class="commits-sidebar flex-1 sidebar-cell" />
|
||||
<main class="justify-center flex flex-row w-full h-full overflow-x-hidden">
|
||||
<div class="outerWrap flex flex-row">
|
||||
<div class="sidebar">
|
||||
<Time class="time-sidebar sidebar-cell" />
|
||||
<Timer class="timer-sidebar sidebar-cell" />
|
||||
<Radio class="radio-sidebar sidebar-cell" />
|
||||
<CommitHistory class="commits-sidebar flex-1 sidebar-cell" />
|
||||
|
||||
<!-- <Elle class="flex-1" /> -->
|
||||
<!-- <MusicPlayer /> -->
|
||||
<img
|
||||
src="/img/memes/fire-woman.gif"
|
||||
alt=""
|
||||
width="178"
|
||||
height="178"
|
||||
class="border-tertiary border-2 sidebar-image box-border w-full bg-tertiary"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="a4page-portrait homeGrid relative bdr-1 border-quaternary"
|
||||
>
|
||||
<!-- <Intro class="intro" /> -->
|
||||
<Intro2 class="intro grid-cell" />
|
||||
<!-- <BadApple class="intro" /> -->
|
||||
<Listening class="listening grid-cell" />
|
||||
<Stamps class="stamps grid-cell" />
|
||||
<Feed class="feed grid-cell" />
|
||||
<Links class="links grid-cell" />
|
||||
<Collage class="collage grid-cell" />
|
||||
<Consumption class="consumption grid-cell" />
|
||||
<Favorites class="favorites grid-cell" />
|
||||
<!-- <Gym class="gym" /> -->
|
||||
<Gym2 class="gym grid-cell" />
|
||||
</div>
|
||||
<div class="sidebar">
|
||||
<Steam class="steam-sidebar sidebar-cell" />
|
||||
<Bookmarks class="bookmarks-sidebar sidebar-cell" />
|
||||
<Chat
|
||||
class="chat-sidebar flex-1 min-h-0 chat-home sidebar-cell"
|
||||
/>
|
||||
<Miku
|
||||
class="sidebar-image miku-image box-border border-tertiary border-2 bg-bg_primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- <Elle class="flex-1" /> -->
|
||||
<!-- <MusicPlayer /> -->
|
||||
<img
|
||||
src="/img/memes/fire-woman.gif"
|
||||
alt=""
|
||||
width="178"
|
||||
height="178"
|
||||
class="border-tertiary border-2 sidebar-image box-border w-full bg-tertiary"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="a4page-portrait homeGrid relative bdr-1 border-quaternary">
|
||||
<!-- <Intro class="intro" /> -->
|
||||
<Intro2 class="intro grid-cell" />
|
||||
<!-- <BadApple class="intro" /> -->
|
||||
<Listening class="listening grid-cell" />
|
||||
<Stamps class="stamps grid-cell" />
|
||||
<Feed class="feed grid-cell" />
|
||||
<Links class="links grid-cell" />
|
||||
<Collage class="collage grid-cell" />
|
||||
<Consumption class="consumption grid-cell" />
|
||||
<Favorites class="favorites grid-cell" />
|
||||
<!-- <Gym class="gym" /> -->
|
||||
<Gym2 class="gym grid-cell" />
|
||||
</div>
|
||||
<div class="sidebar">
|
||||
<Steam class="steam-sidebar sidebar-cell" />
|
||||
<Bookmarks class="bookmarks-sidebar sidebar-cell" />
|
||||
<Chat class="chat-sidebar flex-1 min-h-0 chat-home sidebar-cell" />
|
||||
<Miku
|
||||
class="sidebar-image miku-image box-border border-tertiary border-2 bg-bg_primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid-cell {
|
||||
background-color: var(--bg_primary);
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: var(--quaternary);
|
||||
background-color: var(--bg_primary);
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: var(--quaternary);
|
||||
}
|
||||
|
||||
.intro {
|
||||
grid-area: intro;
|
||||
grid-area: intro;
|
||||
}
|
||||
.listening {
|
||||
grid-area: listening;
|
||||
grid-area: listening;
|
||||
}
|
||||
.stamps {
|
||||
grid-area: stamps;
|
||||
grid-area: stamps;
|
||||
}
|
||||
.feed {
|
||||
grid-area: feed;
|
||||
grid-area: feed;
|
||||
}
|
||||
.links {
|
||||
grid-area: links;
|
||||
grid-area: links;
|
||||
}
|
||||
.collage {
|
||||
grid-area: collage;
|
||||
grid-area: collage;
|
||||
}
|
||||
.consumption {
|
||||
grid-area: consumption;
|
||||
grid-area: consumption;
|
||||
}
|
||||
.gym {
|
||||
grid-area: gym;
|
||||
grid-area: gym;
|
||||
}
|
||||
.favorites {
|
||||
grid-area: favorites;
|
||||
grid-area: favorites;
|
||||
}
|
||||
|
||||
.sidebar-cell {
|
||||
background-color: var(--bg_primary);
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: var(--quaternary);
|
||||
background-color: var(--bg_primary);
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: var(--quaternary);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 15rem;
|
||||
min-height: 0;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 15rem;
|
||||
min-height: 0;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.outerWrap {
|
||||
height: 310mm;
|
||||
gap: 10px;
|
||||
height: 310mm;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.homeGrid {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
grid-template-rows: repeat(10, 1fr);
|
||||
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 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"
|
||||
"consumption consumption consumption consumption gym gym gym favorites favorites favorites"
|
||||
"consumption consumption consumption consumption gym gym gym favorites favorites favorites";
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
grid-template-rows: repeat(10, 1fr);
|
||||
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 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"
|
||||
"consumption consumption consumption consumption gym gym gym favorites favorites favorites"
|
||||
"consumption consumption consumption consumption gym gym gym favorites favorites favorites";
|
||||
}
|
||||
|
||||
.chat-home {
|
||||
max-height: 800px;
|
||||
max-height: 800px;
|
||||
}
|
||||
|
||||
.miku-image {
|
||||
height: 15rem;
|
||||
height: 15rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.outerWrap {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
}
|
||||
.outerWrap {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.homeGrid {
|
||||
order: -1;
|
||||
width: 95vw;
|
||||
height: 297mm;
|
||||
margin-inline: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.homeGrid {
|
||||
order: -1;
|
||||
width: 95vw;
|
||||
height: 297mm;
|
||||
margin-inline: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 95vw;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: unset;
|
||||
justify-content: space-around;
|
||||
}
|
||||
.sidebar {
|
||||
width: 95vw;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: unset;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.commits-sidebar,
|
||||
.steam-sidebar,
|
||||
.bookmarks-sidebar {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
}
|
||||
.commits-sidebar,
|
||||
.steam-sidebar,
|
||||
.bookmarks-sidebar {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
max-height: 800px;
|
||||
height: 25vh;
|
||||
}
|
||||
.chat-sidebar {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
max-height: 800px;
|
||||
height: 25vh;
|
||||
}
|
||||
|
||||
.time-sidebar,
|
||||
.sidebar-image,
|
||||
.radio-sidebar,
|
||||
.timer-sidebar {
|
||||
display: none;
|
||||
}
|
||||
.time-sidebar,
|
||||
.sidebar-image,
|
||||
.radio-sidebar,
|
||||
.timer-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* .sidebar-image { */
|
||||
/* max-height: 150px; */
|
||||
/* max-width: 150px; */
|
||||
/* object-fit: contain; */
|
||||
/* } */
|
||||
/* .sidebar-image { */
|
||||
/* max-height: 150px; */
|
||||
/* max-width: 150px; */
|
||||
/* object-fit: contain; */
|
||||
/* } */
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.homeGrid {
|
||||
border-image: none;
|
||||
border-width: 0;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: repeat(11, 1fr);
|
||||
height: 150vh;
|
||||
grid-template-areas:
|
||||
"intro intro intro"
|
||||
"intro intro intro"
|
||||
"listening stamps stamps"
|
||||
"listening feed feed"
|
||||
"links feed feed"
|
||||
"links feed feed"
|
||||
"links feed feed"
|
||||
"favorites feed feed"
|
||||
"favorites consumption consumption"
|
||||
"favorites consumption consumption"
|
||||
"favorites consumption consumption";
|
||||
}
|
||||
.homeGrid {
|
||||
border-image: none;
|
||||
border-width: 0;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: repeat(11, 1fr);
|
||||
height: 150vh;
|
||||
grid-template-areas:
|
||||
"intro intro intro"
|
||||
"intro intro intro"
|
||||
"listening stamps stamps"
|
||||
"listening feed feed"
|
||||
"links feed feed"
|
||||
"links feed feed"
|
||||
"links feed feed"
|
||||
"favorites feed feed"
|
||||
"favorites consumption consumption"
|
||||
"favorites consumption consumption"
|
||||
"favorites consumption consumption";
|
||||
}
|
||||
|
||||
.collage {
|
||||
display: none;
|
||||
}
|
||||
.collage {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gym {
|
||||
display: none;
|
||||
}
|
||||
.gym {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-random {
|
||||
background-color: var(--bg_primary);
|
||||
background-image: url("/img/miku/miku2.gif");
|
||||
background-size: 10px 10px;
|
||||
background-color: var(--bg_primary);
|
||||
background-image: url("/img/miku/miku2.gif");
|
||||
background-size: 10px 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,27 +4,29 @@ import Paragraph from "@/components/text/Paragraph.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 border-box flex flex-col p-1 text-left items-start justify-start">
|
||||
<Header>Yo</Header>
|
||||
<!-- <Header>Intro</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- Hi, I'm Adam, thank you for visiting my website. -->
|
||||
<!-- </Paragraph> -->
|
||||
<!-- <Header>Getting around</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- Pages available can be traversed through links below. I am hoping to -->
|
||||
<!-- add some shrines, code-walkthoughs, live chat and page transitions -->
|
||||
<!-- at a later date. -->
|
||||
<!-- </Paragraph> -->
|
||||
<!-- <Header>Contact</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- Please email me <a href="mailto:adam.a.french@outlook.com">here</a>, -->
|
||||
<!-- or contact me though any of the social medias linked. -->
|
||||
<!-- </Paragraph> -->
|
||||
<!-- <Header>A Quote</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- One crossed wire, one wayward pinch of potassium chlorate, one -->
|
||||
<!-- errant twitch, and KA-BLOOIE! -->
|
||||
<!-- </Paragraph> -->
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 border-box flex flex-col p-1 text-left items-start justify-start"
|
||||
>
|
||||
<Header>Yo</Header>
|
||||
<!-- <Header>Intro</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- Hi, I'm Adam, thank you for visiting my website. -->
|
||||
<!-- </Paragraph> -->
|
||||
<!-- <Header>Getting around</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- Pages available can be traversed through links below. I am hoping to -->
|
||||
<!-- add some shrines, code-walkthoughs, live chat and page transitions -->
|
||||
<!-- at a later date. -->
|
||||
<!-- </Paragraph> -->
|
||||
<!-- <Header>Contact</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- Please email me <a href="mailto:adam.a.french@outlook.com">here</a>, -->
|
||||
<!-- or contact me though any of the social medias linked. -->
|
||||
<!-- </Paragraph> -->
|
||||
<!-- <Header>A Quote</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- One crossed wire, one wayward pinch of potassium chlorate, one -->
|
||||
<!-- errant twitch, and KA-BLOOIE! -->
|
||||
<!-- </Paragraph> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,46 +3,46 @@ import { rand } from "@vueuse/core";
|
||||
import { ref, onMounted, onUnmounted, nextTick } from "vue";
|
||||
|
||||
interface Item {
|
||||
x: number;
|
||||
y: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
content: string;
|
||||
x: number;
|
||||
y: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
const itemEls = ref<HTMLDivElement[]>([]);
|
||||
|
||||
const phrases = [
|
||||
"Welcome to my website",
|
||||
"I'm looking to do a big revamp",
|
||||
"A4 sheets of paper are what I'm used to",
|
||||
"I'd love to know your recommendations",
|
||||
"for very short books",
|
||||
"Message me by any means necessary",
|
||||
"I like anime, all kinds of music and sci fic",
|
||||
"Welcome to my website",
|
||||
"I'm looking to do a big revamp",
|
||||
"A4 sheets of paper are what I'm used to",
|
||||
"I'd love to know your recommendations",
|
||||
"for very short books",
|
||||
"Message me by any means necessary",
|
||||
"I like anime, all kinds of music and sci fic",
|
||||
];
|
||||
|
||||
// Non-reactive animation state to avoid triggering Vue re-renders every frame
|
||||
const animState = phrases.map((text, i) => ({
|
||||
x: 0,
|
||||
y: i * 20,
|
||||
dx: rand(0, 60) / 100,
|
||||
dy: 1.0,
|
||||
content: text,
|
||||
cachedW: 0,
|
||||
cachedH: 0,
|
||||
x: 0,
|
||||
y: i * 20,
|
||||
dx: rand(0, 60) / 100,
|
||||
dy: 1.0,
|
||||
content: text,
|
||||
cachedW: 0,
|
||||
cachedH: 0,
|
||||
}));
|
||||
|
||||
// Reactive items only for initial render
|
||||
const items = ref<Item[]>(
|
||||
animState.map((s) => ({
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
dx: s.dx,
|
||||
dy: s.dy,
|
||||
content: s.content,
|
||||
})),
|
||||
animState.map((s) => ({
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
dx: s.dx,
|
||||
dy: s.dy,
|
||||
content: s.content,
|
||||
})),
|
||||
);
|
||||
|
||||
let rafId = 0;
|
||||
@@ -52,79 +52,76 @@ let lastFrameTime = 0;
|
||||
const FRAME_INTERVAL = 1000 / 30;
|
||||
|
||||
function measureSizes() {
|
||||
const c = container.value;
|
||||
if (c) {
|
||||
cachedCW = c.clientWidth;
|
||||
cachedCH = c.clientHeight;
|
||||
const c = container.value;
|
||||
if (c) {
|
||||
cachedCW = c.clientWidth;
|
||||
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) {
|
||||
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)`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver;
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
measureSizes();
|
||||
rafId = requestAnimationFrame(animate);
|
||||
await nextTick();
|
||||
measureSizes();
|
||||
rafId = requestAnimationFrame(animate);
|
||||
|
||||
resizeObserver = new ResizeObserver(measureSizes);
|
||||
resizeObserver.observe(container.value!);
|
||||
resizeObserver = new ResizeObserver(measureSizes);
|
||||
resizeObserver.observe(container.value!);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(rafId);
|
||||
resizeObserver?.disconnect();
|
||||
cancelAnimationFrame(rafId);
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="w-full h-full relative overflow-hidden">
|
||||
<div
|
||||
ref="container"
|
||||
class="w-full h-full relative overflow-hidden"
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
ref="itemEls"
|
||||
class="absolute w-fit h-fit"
|
||||
>
|
||||
<div
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
ref="itemEls"
|
||||
class="absolute w-fit h-fit"
|
||||
>
|
||||
<h1>
|
||||
{{ item.content }}
|
||||
</h1>
|
||||
</div>
|
||||
<h1>
|
||||
{{ item.content }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,31 +4,31 @@ import LinkTable from "@/components/util/LinkTable.vue";
|
||||
import Header from "@/components/text/Header.vue";
|
||||
|
||||
const site_links = [
|
||||
{ name: "CV", link: "/cv" },
|
||||
{ name: "Bookmarks", link: "/bookmarks" },
|
||||
{ name: "Shrines", link: "/shrines" },
|
||||
{ name: "Admin", link: "/admin" },
|
||||
{ name: "CV", link: "/cv" },
|
||||
{ name: "Bookmarks", link: "/bookmarks" },
|
||||
{ name: "Shrines", link: "/shrines" },
|
||||
{ name: "Admin", link: "/admin" },
|
||||
];
|
||||
|
||||
const social_links = [
|
||||
{ name: "Gitea", link: "/gitea/explore/repos" },
|
||||
{ name: "Steam", link: "https://steamcommunity.com/id/SteveThePug" },
|
||||
{ name: "Github", link: "https://github.com/SteveThePug" },
|
||||
{ name: "Spotify", link: "https://open.spotify.com/user/stevethepug" },
|
||||
{ name: "Notes", link: "/notes/" },
|
||||
{ name: "Gitea", link: "/gitea/explore/repos" },
|
||||
{ name: "Steam", link: "https://steamcommunity.com/id/SteveThePug" },
|
||||
{ name: "Github", link: "https://github.com/SteveThePug" },
|
||||
{ name: "Spotify", link: "https://open.spotify.com/user/stevethepug" },
|
||||
{ name: "Notes", link: "/notes/" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col overflow-auto">
|
||||
<Header>Links</Header>
|
||||
<div class="flex flex-col justify-between flex-1">
|
||||
<div class="flex flex-col gap-1">
|
||||
<RouterTable :linkArr="site_links" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<LinkTable :items="social_links" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col overflow-auto">
|
||||
<Header>Links</Header>
|
||||
<div class="flex flex-col justify-between flex-1">
|
||||
<div class="flex flex-col gap-1">
|
||||
<RouterTable :linkArr="site_links" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<LinkTable :items="social_links" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,75 +11,76 @@ let nextId = null;
|
||||
let refreshId = null;
|
||||
|
||||
function nextSong() {
|
||||
clearTimeout(nextId);
|
||||
nextId = setTimeout(nextSong, 5000);
|
||||
idx.value = (idx.value + 1) % songsStore.songsCount;
|
||||
clearTimeout(nextId);
|
||||
nextId = setTimeout(nextSong, 5000);
|
||||
idx.value = (idx.value + 1) % songsStore.songsCount;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
songsStore.fetchSongs();
|
||||
nextId = setTimeout(nextSong, 5000);
|
||||
refreshId = setInterval(songsStore.fetchSongs, 120000);
|
||||
songsStore.fetchSongs();
|
||||
nextId = setTimeout(nextSong, 5000);
|
||||
refreshId = setInterval(songsStore.fetchSongs, 120000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(nextId);
|
||||
clearInterval(refreshId);
|
||||
clearTimeout(nextId);
|
||||
clearInterval(refreshId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="listening-wrapper">
|
||||
<div class="listening-wrapper">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
@click="nextSong"
|
||||
:key="song.track.name"
|
||||
class="flex flex-col items-center pt-2"
|
||||
>
|
||||
<Header>Listening To</Header>
|
||||
<img :src="song.track.album.images[0].url" :alt="song.track.album.name + ' album art'" />
|
||||
<p class="text-center">
|
||||
<strong>Song:</strong> {{ song.track.name }}
|
||||
</p>
|
||||
<p class="text-center">
|
||||
<strong>Artist:</strong> {{ song.track.artists[0].name }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@click="nextSong"
|
||||
:key="song.track.name"
|
||||
class="flex flex-col items-center pt-2"
|
||||
>
|
||||
<Header>Listening To</Header>
|
||||
<img
|
||||
:src="song.track.album.images[0].url"
|
||||
:alt="song.track.album.name + ' album art'"
|
||||
/>
|
||||
<p class="text-center"><strong>Song:</strong> {{ song.track.name }}</p>
|
||||
<p class="text-center">
|
||||
<strong>Artist:</strong> {{ song.track.artists[0].name }}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.listening-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 70%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
width: 70%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
p {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.fade-leave-active {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import Slideshow from "@/components/util/Slideshow.vue";
|
||||
|
||||
const images = [
|
||||
{ url: "/img/miku/miku1.gif" },
|
||||
{ url: "/img/miku/miku2.gif" },
|
||||
// { url: "/img/miku/miku2.png" },
|
||||
{ url: "/img/miku/miku1.gif" },
|
||||
{ url: "/img/miku/miku2.gif" },
|
||||
// { url: "/img/miku/miku2.png" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slideshow class="p-5" :images="images" :interval="10000" />
|
||||
<Slideshow class="p-5" :images="images" :interval="10000" />
|
||||
</template>
|
||||
|
||||
@@ -6,20 +6,20 @@ import Link from "@/components/text/Link.vue";
|
||||
import { shuffleArray } from "@/js/utils.js";
|
||||
|
||||
let srcs = [
|
||||
"/img/stamps/portal.gif",
|
||||
"/img/stamps/miku.gif",
|
||||
"/img/stamps/utau.gif",
|
||||
"/img/stamps/teto.webp",
|
||||
"/img/stamps/3ds.jpg",
|
||||
"/img/stamps/fry.png",
|
||||
"/img/stamps/ai.png",
|
||||
"/img/stamps/rei.png",
|
||||
"/img/stamps/tetris.gif",
|
||||
"/img/stamps/tf2.gif",
|
||||
"/img/stamps/demo.gif",
|
||||
"/img/stamps/demo.gif",
|
||||
"/img/stamps/demo.gif",
|
||||
"/img/stamps/demo.gif",
|
||||
"/img/stamps/portal.gif",
|
||||
"/img/stamps/miku.gif",
|
||||
"/img/stamps/utau.gif",
|
||||
"/img/stamps/teto.webp",
|
||||
"/img/stamps/3ds.jpg",
|
||||
"/img/stamps/fry.png",
|
||||
"/img/stamps/ai.png",
|
||||
"/img/stamps/rei.png",
|
||||
"/img/stamps/tetris.gif",
|
||||
"/img/stamps/tf2.gif",
|
||||
"/img/stamps/demo.gif",
|
||||
"/img/stamps/demo.gif",
|
||||
"/img/stamps/demo.gif",
|
||||
"/img/stamps/demo.gif",
|
||||
];
|
||||
shuffleArray(srcs);
|
||||
|
||||
@@ -31,71 +31,76 @@ let dx = 0.2;
|
||||
let dy = 0.12;
|
||||
|
||||
function bounce() {
|
||||
const el = touchscreen.value?.$el;
|
||||
if (!el) return;
|
||||
const el = touchscreen.value?.$el;
|
||||
if (!el) return;
|
||||
|
||||
const maxX = el.scrollWidth - el.clientWidth;
|
||||
const maxY = el.scrollHeight - el.clientHeight;
|
||||
const maxX = el.scrollWidth - el.clientWidth;
|
||||
const maxY = el.scrollHeight - el.clientHeight;
|
||||
|
||||
if (maxX > 0) {
|
||||
posX += dx;
|
||||
if (posX <= 0) {
|
||||
posX = 0;
|
||||
dx = -dx;
|
||||
} else if (posX >= maxX) {
|
||||
posX = maxX;
|
||||
dx = -dx;
|
||||
}
|
||||
el.scrollLeft = posX;
|
||||
if (maxX > 0) {
|
||||
posX += dx;
|
||||
if (posX <= 0) {
|
||||
posX = 0;
|
||||
dx = -dx;
|
||||
} else if (posX >= maxX) {
|
||||
posX = maxX;
|
||||
dx = -dx;
|
||||
}
|
||||
if (maxY > 0) {
|
||||
posY += dy;
|
||||
if (posY <= 0) {
|
||||
posY = 0;
|
||||
dy = -dy;
|
||||
} else if (posY >= maxY) {
|
||||
posY = maxY;
|
||||
dy = -dy;
|
||||
}
|
||||
el.scrollTop = posY;
|
||||
el.scrollLeft = posX;
|
||||
}
|
||||
if (maxY > 0) {
|
||||
posY += dy;
|
||||
if (posY <= 0) {
|
||||
posY = 0;
|
||||
dy = -dy;
|
||||
} else if (posY >= maxY) {
|
||||
posY = maxY;
|
||||
dy = -dy;
|
||||
}
|
||||
el.scrollTop = posY;
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(bounce);
|
||||
animId = requestAnimationFrame(bounce);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
animId = requestAnimationFrame(bounce);
|
||||
animId = requestAnimationFrame(bounce);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animId) cancelAnimationFrame(animId);
|
||||
if (animId) cancelAnimationFrame(animId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Touchscreen ref="touchscreen">
|
||||
<div class="flex flex-wrap tst">
|
||||
<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" />
|
||||
</Link>
|
||||
<Link bare href="https://jacobbarron.xyz">
|
||||
<img
|
||||
src="https://jacobbarron.xyz/Banneh.gif"
|
||||
alt="jacobbarron.xyz"
|
||||
/>
|
||||
</Link>
|
||||
<img v-for="src in srcs" :src="src" :alt="src.split('/').pop().split('.')[0]" loading="lazy" />
|
||||
</div>
|
||||
</Touchscreen>
|
||||
<Touchscreen ref="touchscreen">
|
||||
<div class="flex flex-wrap tst">
|
||||
<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"
|
||||
/>
|
||||
</Link>
|
||||
<Link bare href="https://jacobbarron.xyz">
|
||||
<img src="https://jacobbarron.xyz/Banneh.gif" alt="jacobbarron.xyz" />
|
||||
</Link>
|
||||
<img
|
||||
v-for="src in srcs"
|
||||
:src="src"
|
||||
:alt="src.split('/').pop().split('.')[0]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</Touchscreen>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
img {
|
||||
width: 89px;
|
||||
height: 59px;
|
||||
width: 89px;
|
||||
height: 59px;
|
||||
}
|
||||
|
||||
.tst {
|
||||
min-width: calc(89px * 4);
|
||||
min-width: calc(89px * 4);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,116 +17,116 @@ let nextId = null;
|
||||
let refreshId = null;
|
||||
|
||||
function nextGame() {
|
||||
clearTimeout(nextId);
|
||||
nextId = setTimeout(nextGame, 5000);
|
||||
if (steamStatus.value.recentGames.length) {
|
||||
idx.value = (idx.value + 1) % steamStatus.value.recentGames.length;
|
||||
}
|
||||
clearTimeout(nextId);
|
||||
nextId = setTimeout(nextGame, 5000);
|
||||
if (steamStatus.value.recentGames.length) {
|
||||
idx.value = (idx.value + 1) % steamStatus.value.recentGames.length;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextId = setTimeout(nextGame, 5000);
|
||||
refreshId = setInterval(() => steamStore.fetchSteam(), 5 * 60 * 1000);
|
||||
nextId = setTimeout(nextGame, 5000);
|
||||
refreshId = setInterval(() => steamStore.fetchSteam(), 5 * 60 * 1000);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
clearTimeout(nextId);
|
||||
clearInterval(refreshId);
|
||||
clearTimeout(nextId);
|
||||
clearInterval(refreshId);
|
||||
});
|
||||
|
||||
function formatHours(minutes) {
|
||||
const hrs = (minutes / 60).toFixed(1);
|
||||
return `${hrs}h`;
|
||||
const hrs = (minutes / 60).toFixed(1);
|
||||
return `${hrs}h`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="steam-wrapper">
|
||||
<Header class="text-left">
|
||||
<span class="flex items-center gap-2">
|
||||
Steam
|
||||
<span
|
||||
class="inline-block w-2 h-2 rounded-full"
|
||||
:class="steamStatus.online ? 'bg-green-500' : 'bg-gray-400'"
|
||||
:title="steamStatus.online ? 'Online' : 'Offline'"
|
||||
/>
|
||||
</span>
|
||||
</Header>
|
||||
<div class="steam-wrapper">
|
||||
<Header class="text-left">
|
||||
<span class="flex items-center gap-2">
|
||||
Steam
|
||||
<span
|
||||
class="inline-block w-2 h-2 rounded-full"
|
||||
:class="steamStatus.online ? 'bg-green-500' : 'bg-gray-400'"
|
||||
:title="steamStatus.online ? 'Online' : 'Offline'"
|
||||
/>
|
||||
</span>
|
||||
</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">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
@click="nextGame"
|
||||
:key="game.appId"
|
||||
class="flex flex-col items-center pt-2"
|
||||
>
|
||||
<img
|
||||
:src="game.headerImageUrl"
|
||||
:alt="game.name"
|
||||
width="145"
|
||||
height="68"
|
||||
class="game-img"
|
||||
/>
|
||||
<p class="text-center">
|
||||
<strong>{{ game.name }}</strong>
|
||||
</p>
|
||||
<p class="text-center text-tertiary text-xs">
|
||||
{{ formatHours(game.playtime2Weeks) }} last 2 weeks
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
<div v-else-if="game" class="game-container">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
@click="nextGame"
|
||||
:key="game.appId"
|
||||
class="flex flex-col items-center pt-2"
|
||||
>
|
||||
<img
|
||||
:src="game.headerImageUrl"
|
||||
:alt="game.name"
|
||||
width="145"
|
||||
height="68"
|
||||
class="game-img"
|
||||
/>
|
||||
<p class="text-center">
|
||||
<strong>{{ game.name }}</strong>
|
||||
</p>
|
||||
<p class="text-center text-tertiary text-xs">
|
||||
{{ formatHours(game.playtime2Weeks) }} last 2 weeks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-2 text-sm">No recent games.</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-2 text-sm">No recent games.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.steam-wrapper {
|
||||
position: relative;
|
||||
height: 54mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
height: 54mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: scroll;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.game-img {
|
||||
width: 90%;
|
||||
max-width: 300px;
|
||||
height: auto;
|
||||
width: 90%;
|
||||
max-width: 300px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
p {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.fade-leave-active {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.steam-wrapper {
|
||||
height: auto;
|
||||
min-height: 120px;
|
||||
}
|
||||
.steam-wrapper {
|
||||
height: auto;
|
||||
min-height: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,27 +3,25 @@ import VideoTable from "@/components/util/VideoTable.vue";
|
||||
import Link from "@/components/text/Link.vue";
|
||||
|
||||
const videoSources = [
|
||||
{ name: "demoman", link: "/img/demoman/1760582395316219.webm" },
|
||||
{ name: "demoman", link: "/img/demoman/1761052136609718.webm" },
|
||||
{ name: "demoman", link: "/img/demoman/1761088452011210.mp4" },
|
||||
{ name: "demoman", link: "/img/demoman/1761570214170465.webm" },
|
||||
{ name: "demoman", link: "/img/demoman/1761828457509465.webm" },
|
||||
{ name: "demoman", link: "/img/demoman/1760582395316219.webm" },
|
||||
{ name: "demoman", link: "/img/demoman/1761052136609718.webm" },
|
||||
{ name: "demoman", link: "/img/demoman/1761088452011210.mp4" },
|
||||
{ name: "demoman", link: "/img/demoman/1761570214170465.webm" },
|
||||
{ name: "demoman", link: "/img/demoman/1761828457509465.webm" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="items-center flex flex-col">
|
||||
<div
|
||||
class="a4page-portrait bdr-1 flex flex-row relative overflow-scroll items-center"
|
||||
>
|
||||
<p>
|
||||
<Link href="https://wiki.teamfortress.com/wiki/Demoman"
|
||||
>The goat</Link
|
||||
>
|
||||
</p>
|
||||
<div>
|
||||
<VideoTable :sourceArr="videoSources" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<main class="items-center flex flex-col">
|
||||
<div
|
||||
class="a4page-portrait bdr-1 flex flex-row relative overflow-scroll items-center"
|
||||
>
|
||||
<p>
|
||||
<Link href="https://wiki.teamfortress.com/wiki/Demoman">The goat</Link>
|
||||
</p>
|
||||
<div>
|
||||
<VideoTable :sourceArr="videoSources" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -3,11 +3,11 @@ import Wip from "@/components/util/Wip.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="items-center flex flex-col">
|
||||
<div
|
||||
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
|
||||
>
|
||||
<Wip />
|
||||
</div>
|
||||
</main>
|
||||
<main class="items-center flex flex-col">
|
||||
<div
|
||||
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
|
||||
>
|
||||
<Wip />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,9 @@ import Wip from "@/components/util/Wip.vue";
|
||||
|
||||
<template>
|
||||
<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 />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script setup>
|
||||
import RouterTable from "@/components/util/RouterTable.vue";
|
||||
const shrine_links = [
|
||||
{ name: "Demoman", link: "/shrines/demoman" },
|
||||
{ name: "Evangelion", link: "/shrines/evangelion" },
|
||||
{ name: "GTO", link: "/shrines/gto" },
|
||||
{ name: "Skipskipbenben", link: "/shrines/skipskipbenben" },
|
||||
{ name: "Demoman", link: "/shrines/demoman" },
|
||||
{ name: "Evangelion", link: "/shrines/evangelion" },
|
||||
{ name: "GTO", link: "/shrines/gto" },
|
||||
{ name: "Skipskipbenben", link: "/shrines/skipskipbenben" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="items-center flex flex-col">
|
||||
<div class="background" />
|
||||
<div
|
||||
class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll gap-1"
|
||||
>
|
||||
<RouterTable :linkArr="shrine_links" />
|
||||
</div>
|
||||
</main>
|
||||
<main class="items-center flex flex-col">
|
||||
<div class="background" />
|
||||
<div
|
||||
class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll gap-1"
|
||||
>
|
||||
<RouterTable :linkArr="shrine_links" />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -3,11 +3,11 @@ import Wip from "@/components/util/Wip.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="items-center flex flex-col">
|
||||
<div
|
||||
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
|
||||
>
|
||||
<Wip />
|
||||
</div>
|
||||
</main>
|
||||
<main class="items-center flex flex-col">
|
||||
<div
|
||||
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
|
||||
>
|
||||
<Wip />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -5,52 +5,51 @@ import Header from "@/components/text/Header.vue";
|
||||
import Paragraph from "@/components/text/Paragraph.vue";
|
||||
|
||||
const links = [
|
||||
{ name: "GitHub", href: "https://github.com/SteveThePug" },
|
||||
{ name: "Gitea", href: "/gitea/explore/repos" },
|
||||
{ name: "Spotify", href: "https://open.spotify.com/user/stevethepug" },
|
||||
{ name: "GitHub", href: "https://github.com/SteveThePug" },
|
||||
{ name: "Gitea", href: "/gitea/explore/repos" },
|
||||
{ name: "Spotify", href: "https://open.spotify.com/user/stevethepug" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex justify-center px-4 py-16">
|
||||
<div class="max-w-xl w-full flex flex-col gap-12">
|
||||
<section>
|
||||
<Header>Adam French</Header>
|
||||
<Paragraph>
|
||||
Junior software engineer focused on full-stack development,
|
||||
systems programming, and infrastructure. First Class Honours
|
||||
in Computer Science with Mathematics from Leeds and
|
||||
Waterloo.
|
||||
</Paragraph>
|
||||
</section>
|
||||
<main class="flex justify-center px-4 py-16">
|
||||
<div class="max-w-xl w-full flex flex-col gap-12">
|
||||
<section>
|
||||
<Header>Adam French</Header>
|
||||
<Paragraph>
|
||||
Junior software engineer focused on full-stack development, systems
|
||||
programming, and infrastructure. First Class Honours in Computer
|
||||
Science with Mathematics from Leeds and Waterloo.
|
||||
</Paragraph>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Header>About</Header>
|
||||
<Paragraph>
|
||||
This website is self-hosted and has a lot more on it than it
|
||||
needs to. Please have a look at my
|
||||
<InlineLink to="/cv">CV</InlineLink> for a full breakdown of
|
||||
my experience, projects, and skills. Please visit
|
||||
<InlineLink to="/stp">STP</InlineLink> for the prefered but
|
||||
less professional experience.
|
||||
</Paragraph>
|
||||
</section>
|
||||
<section>
|
||||
<Header>About</Header>
|
||||
<Paragraph>
|
||||
This website is self-hosted and has a lot more on it than it needs to.
|
||||
Please have a look at my
|
||||
<InlineLink to="/cv">CV</InlineLink> for a full breakdown of my
|
||||
experience, projects, and skills. Please visit
|
||||
<InlineLink to="/stp">STP</InlineLink> for the prefered but less
|
||||
professional experience.
|
||||
</Paragraph>
|
||||
</section>
|
||||
|
||||
<nav class="navRow flex flex-row flex-wrap gap-4 justify-around">
|
||||
<Link to="/cv"> CV </Link>
|
||||
<Link to="/stp"> STP </Link>
|
||||
<Link href="mailto:adam.a.french@outlook.com"> Email </Link>
|
||||
<Link v-for="link in links" :key="link.name" :href="link.href">
|
||||
{{ link.name }}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</main>
|
||||
<nav class="navRow flex flex-row flex-wrap gap-4 justify-around">
|
||||
<Link to="/cv"> CV </Link>
|
||||
<Link to="/stp"> STP </Link>
|
||||
<Link href="mailto:adam.a.french@outlook.com"> Email </Link>
|
||||
<Link v-for="link in links" :key="link.name" :href="link.href">
|
||||
{{ link.name }}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.navRow > a {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid currentColor;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,72 +1,70 @@
|
||||
<template>
|
||||
<main>
|
||||
<table id="cover-nav" class="cover-nav no-print">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Companies</th>
|
||||
<th>Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a href="#LloydsBank">Lloyds</a></td>
|
||||
<td>YES</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<main>
|
||||
<table id="cover-nav" class="cover-nav no-print">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Companies</th>
|
||||
<th>Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a href="#LloydsBank">Lloyds</a></td>
|
||||
<td>YES</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="no-print m-1 w-full text-center"></div>
|
||||
<div id="LloydsBank" class="a5page">
|
||||
<div class="contact">
|
||||
<h1>Adam French</h1>
|
||||
<!-- <a href="index.html"><img width=25 height=50 src="img/rune.png"></a> -->
|
||||
<div class="contact-details">
|
||||
<p>+447563266931</p>
|
||||
<p>adam.a.french@outlook.com</p>
|
||||
</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 class="no-print m-1 w-full text-center"></div>
|
||||
<div id="LloydsBank" class="a5page">
|
||||
<div class="contact">
|
||||
<h1>Adam French</h1>
|
||||
<!-- <a href="index.html"><img width=25 height=50 src="img/rune.png"></a> -->
|
||||
<div class="contact-details">
|
||||
<p>+447563266931</p>
|
||||
<p>adam.a.french@outlook.com</p>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
@import "@/assets/css/cv_styles.css";
|
||||
@media print {
|
||||
@page {
|
||||
size: A5 landscape;
|
||||
margin: 0;
|
||||
}
|
||||
@page {
|
||||
size: A5 landscape;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,60 +16,60 @@ const path = Array.isArray(pathArray) ? pathArray.join("/") : pathArray;
|
||||
const url = `/api/notes/${path}`;
|
||||
|
||||
function getFilename(headers) {
|
||||
const disposition = headers["content-disposition"];
|
||||
if (!disposition) return null;
|
||||
const disposition = headers["content-disposition"];
|
||||
if (!disposition) return null;
|
||||
|
||||
const match = disposition.match(/filename="?([^"]+)"?/);
|
||||
return match ? match[1] : null;
|
||||
const match = disposition.match(/filename="?([^"]+)"?/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function fetchFile() {
|
||||
const response = await axios.get(url, { responseType: "blob" });
|
||||
filename.value = getFilename(response.headers);
|
||||
const response = await axios.get(url, { responseType: "blob" });
|
||||
filename.value = getFilename(response.headers);
|
||||
|
||||
const lastModified = response.headers["last-modified"];
|
||||
last_edited.value = lastModified ? new Date(lastModified) : null;
|
||||
const lastModified = response.headers["last-modified"];
|
||||
last_edited.value = lastModified ? new Date(lastModified) : null;
|
||||
|
||||
if (filename.value.toLowerCase().endsWith(".md")) {
|
||||
const text = await response.data.text();
|
||||
file.value = fixLinks(text);
|
||||
} else {
|
||||
file.value = response.data;
|
||||
}
|
||||
if (filename.value.toLowerCase().endsWith(".md")) {
|
||||
const text = await response.data.text();
|
||||
file.value = fixLinks(text);
|
||||
} else {
|
||||
file.value = response.data;
|
||||
}
|
||||
}
|
||||
|
||||
function fixLinks(filedata) {
|
||||
return filedata.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
|
||||
if (
|
||||
url.startsWith("http://") ||
|
||||
url.startsWith("https://") ||
|
||||
url.startsWith("#") ||
|
||||
url.startsWith("./") ||
|
||||
url.startsWith("../") ||
|
||||
url.startsWith("//")
|
||||
) {
|
||||
return match;
|
||||
}
|
||||
return filedata.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
|
||||
if (
|
||||
url.startsWith("http://") ||
|
||||
url.startsWith("https://") ||
|
||||
url.startsWith("#") ||
|
||||
url.startsWith("./") ||
|
||||
url.startsWith("../") ||
|
||||
url.startsWith("//")
|
||||
) {
|
||||
return match;
|
||||
}
|
||||
|
||||
return `[${text}](/notes/${url})`;
|
||||
});
|
||||
return `[${text}](/notes/${url})`;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchFile);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="items-center flex flex-col">
|
||||
<div class="background" />
|
||||
<div
|
||||
v-if="file"
|
||||
class="a4page-portrait border-primary-1 flex flex-col relative overflow-scroll gap-1 bg-bg_primary"
|
||||
>
|
||||
<h1>{{ filename }}</h1>
|
||||
<small>{{ last_edited }}</small>
|
||||
<Markdown class="flex-1 border-box text-wrap" :source="file" />
|
||||
</div>
|
||||
<main class="items-center flex flex-col">
|
||||
<div class="background" />
|
||||
<div
|
||||
v-if="file"
|
||||
class="a4page-portrait border-primary-1 flex flex-col relative overflow-scroll gap-1 bg-bg_primary"
|
||||
>
|
||||
<h1>{{ filename }}</h1>
|
||||
<small>{{ last_edited }}</small>
|
||||
<Markdown class="flex-1 border-box text-wrap" :source="file" />
|
||||
</div>
|
||||
|
||||
<div v-else>Loading…</div>
|
||||
</main>
|
||||
<div v-else>Loading…</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -9,25 +9,25 @@ import topLevelAwait from "vite-plugin-top-level-await";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
...(process.env.NODE_ENV !== "production" ? [vueDevTools()] : []),
|
||||
tailwindcss(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
...(process.env.NODE_ENV !== "production" ? [vueDevTools()] : []),
|
||||
tailwindcss(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
"/gitea": "http://localhost:3000",
|
||||
"/radio": "http://localhost:8000",
|
||||
"/searxng": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
"/gitea": "http://localhost:3000",
|
||||
"/radio": "http://localhost:8000",
|
||||
"/searxng": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user