This commit is contained in:
8
frontend/vue-app/.dockerignore
Normal file
8
frontend/vue-app/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
*.md
|
||||
*.log
|
||||
8
frontend/vue-app/.editorconfig
Normal file
8
frontend/vue-app/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
1
frontend/vue-app/.gitattributes
vendored
Normal file
1
frontend/vue-app/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
36
frontend/vue-app/.gitignore
vendored
Normal file
36
frontend/vue-app/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
6
frontend/vue-app/.prettierrc.json
Normal file
6
frontend/vue-app/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
28
frontend/vue-app/192.168.1.102+1-key.pem
Normal file
28
frontend/vue-app/192.168.1.102+1-key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCu4wdLkE3sTDd9
|
||||
Mea+AJFpnsk2nR0CBm0MqopJvr0E7X+iOpVRdfHOqxQ5vS6a5sjMjDDLjpO6Qb6Z
|
||||
o0zrm6N+djhfPlITdwXXoNW4TURrWaBFyQXDzHt+eaVBMXpRhLYFcpHvFam+EfM7
|
||||
5XB5UmNe400eKhAC5IMIOxuAWhMMN1TfMGVkt+pQAhTFYbeWMvEfCxY0IY3wfzMf
|
||||
7+ZMKc1YQ0R8hhwqF2KP5BswwTFAIANhopkqGkszOz5ZRQIVlZCSYehbfGEklpPo
|
||||
LjPeF0qZJWwPzZy0fT9SJH1sLl8SA/+KTo6n3GeJpPdG+MlxMgi7a9hDXM8MlwCV
|
||||
us34apVbAgMBAAECggEAWfQCM5a0zd7iB64cHgySvr3ihwnG+tytSH+Lg6Ts+lTi
|
||||
emIhnXXJ+2A5lf09tIUSMUvGaV0blQjt7X52ORWjwY8zLaITe1mUErXyV8q+b2z5
|
||||
KAvewDg0KPiOzHqTjMxzB1hHwa7l0RLQhjVcZbq/y/WkG+jMtYAt+ZTVb25lL7mI
|
||||
JAkBTmATcPrsnYxqk1kwZsTcgrnXZwXKWBKtKy6i728V1bLxYF/KyizGnaClt0+I
|
||||
H0qi5jnF5ispXmdBlJa2kFOzDNRa4iO7m328nqFSikdYT/xaPfGwNtTplgSxQ2kE
|
||||
WkbKq6Tjx1K4XR4en5sT7NXst89Vl53UwWT85AZxuQKBgQDeyAOu1H81/SDhtvOt
|
||||
mdbb6WvRFrzHSRQEX5vBasD1cgKz3uTQeAUKlTGcKZz796pDnluNoAOBx9bnXeYp
|
||||
6PMfif5tJmykmBPik9CtKlsZEwGexjrfvsGM+tISCRDMfF1FAk16BBlQePUWUyEi
|
||||
kqdw8pk8HGkA82Blhu3N8dJbFQKBgQDI9sp99Za7cHUHGKjF8wFS7feFwvbeYzY5
|
||||
WLXcCTac75Xoqr9O59a74Fz2QdBZGT5eCg06NUbxJ2YTJuWbfRR0jHYEMPUY9iTj
|
||||
cYCiYYS67NMjHe4/wZU430S/Egz4FUXyf/XswhURjM5pHxyWmLh7XejjRKs8x6GE
|
||||
WUva37GKrwKBgBj0VJ1HxjwY7474/FCs08lsWxxfrKOyBuD6iKrgt16G99CIHh9P
|
||||
4liuH5F7g88hjdvnKCA0FVB7PxJJjVeSdXFJ9srpK/A/7LJLlmtfPDcRzvOnBr87
|
||||
Udjl25QTmeMd5yCswlrxjJhcBDAM/cAupzzan9mA4S4vFNQqigawmLyFAoGACreW
|
||||
jucU9cQGia1X+s59yJVmONzv22ZBEwfXEvfu0Km6PeE1OJkGi5hofL1/xfChsdQp
|
||||
ZmxG7z9hoy3U2tjtyVVgSdLujzk5OGPqLz6yHGHa1KmY9g91zMWjXekxhd1kkI0g
|
||||
aVLkWr4+l76QALv+Qp38eHpGA4TF6U/1yqNZTYMCgYB12cwRZkihD60MfXaq/vqY
|
||||
zu2AC49k3h+QKKtYMJ7i5i1nwH9EyrycBLtE9xFm40GYrqUJZ/55BwjaskMI8x5h
|
||||
X7r4lK1XqOGRIX7nwSWS1UOucj2CAqAYrXal8FzOo+aazBcRCcUiC0vt49ICVKss
|
||||
+wapUQ7517R2w2tUnAOidg==
|
||||
-----END PRIVATE KEY-----
|
||||
25
frontend/vue-app/192.168.1.102+1.pem
Normal file
25
frontend/vue-app/192.168.1.102+1.pem
Normal file
@@ -0,0 +1,25 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIETDCCArSgAwIBAgIQDRI/2ndt0jHqQgdHokXM3jANBgkqhkiG9w0BAQsFADCB
|
||||
hTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMS0wKwYDVQQLDCRERVNL
|
||||
VE9QLUFVOTBHNkpcUnlhbkBERVNLVE9QLUFVOTBHNkoxNDAyBgNVBAMMK21rY2Vy
|
||||
dCBERVNLVE9QLUFVOTBHNkpcUnlhbkBERVNLVE9QLUFVOTBHNkowHhcNMjUxMTE4
|
||||
MTU1MjA1WhcNMjgwMjE4MTU1MjA1WjBYMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxv
|
||||
cG1lbnQgY2VydGlmaWNhdGUxLTArBgNVBAsMJERFU0tUT1AtQVU5MEc2SlxSeWFu
|
||||
QERFU0tUT1AtQVU5MEc2SjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||
AK7jB0uQTexMN30x5r4AkWmeyTadHQIGbQyqikm+vQTtf6I6lVF18c6rFDm9Lprm
|
||||
yMyMMMuOk7pBvpmjTOubo352OF8+UhN3Bdeg1bhNRGtZoEXJBcPMe355pUExelGE
|
||||
tgVyke8Vqb4R8zvlcHlSY17jTR4qEALkgwg7G4BaEww3VN8wZWS36lACFMVht5Yy
|
||||
8R8LFjQhjfB/Mx/v5kwpzVhDRHyGHCoXYo/kGzDBMUAgA2GimSoaSzM7PllFAhWV
|
||||
kJJh6Ft8YSSWk+guM94XSpklbA/NnLR9P1IkfWwuXxID/4pOjqfcZ4mk90b4yXEy
|
||||
CLtr2ENczwyXAJW6zfhqlVsCAwEAAaNkMGIwDgYDVR0PAQH/BAQDAgWgMBMGA1Ud
|
||||
JQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFMdcsDJbHnPC2tztDztuvlALzXcJ
|
||||
MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEwKgBZjANBgkqhkiG9w0BAQsFAAOCAYEA
|
||||
nhDUKSW8Ti6h39cth+JeX3isU/cBO1l3y3Ptflb3i02na3x2b39hVstm1xCuxF43
|
||||
KNLF+Mfe1OegifDHCxkEQGM6HvppBjbUchZlTcr0q1xusU6v/moGxYnEP1BewvRv
|
||||
B5eNHSADROdRNSbtuK8DeVPWG3uXzyqmQPTlSmtNYAXuigsmPEodiFLBxIoE1qWd
|
||||
Y4EGWK9b7Vka7IjIH6KnegJe5n9aI102Hu2rZwP8WIlDhqPzpDprrpj0n8t38Q5z
|
||||
FK+Qae3E1i5vSAZoX0buUngnpXAbGDQl/Sq/PIIFeljlOWtI8JDhUb2nZHWnPoyA
|
||||
NuiGgYsOFfU6eH6gFw/sTphjrABIQ9JbQoSjeUA3vcol1De7mqCPJ6oC4lbeMdr1
|
||||
xt1bD6UiIhjx219ikAlnNSfm9eZsqBepPLi029wBDP/De1RdGRcLRwtNBxNWkYcc
|
||||
t/7BWjjDd0/67meIEK85kYatfK1uUrVOjeU097LjwHjvsfsqKZr3Kok+kAzFalvF
|
||||
-----END CERTIFICATE-----
|
||||
19
frontend/vue-app/Dockerfile
Normal file
19
frontend/vue-app/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# Stage 1: Build the app
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
# Copy SSL certificate and key
|
||||
COPY 192.168.1.102+1.pem /etc/nginx/ssl/server.crt
|
||||
COPY 192.168.1.102+1-key.pem /etc/nginx/ssl/server.key
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
54
frontend/vue-app/README.md
Normal file
54
frontend/vue-app/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# vue-app
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||
|
||||
```sh
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
1
frontend/vue-app/env.d.ts
vendored
Normal file
1
frontend/vue-app/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
28
frontend/vue-app/eslint.config.ts
Normal file
28
frontend/vue-app/eslint.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
{
|
||||
...pluginVitest.configs.recommended,
|
||||
files: ['src/**/__tests__/*'],
|
||||
},
|
||||
skipFormatting,
|
||||
)
|
||||
16
frontend/vue-app/index.html
Normal file
16
frontend/vue-app/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>Chore Time</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
frontend/vue-app/nginx.conf
Normal file
43
frontend/vue-app/nginx.conf
Normal file
@@ -0,0 +1,43 @@
|
||||
events {}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
server {
|
||||
client_max_body_size 2M;
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/server.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/server.key;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://chore-app-backend:5000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /events {
|
||||
proxy_pass http://chore-app-backend:5000/events;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
chunked_transfer_encoding off;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 36000s;
|
||||
proxy_send_timeout 36000s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
6516
frontend/vue-app/package-lock.json
generated
Normal file
6516
frontend/vue-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
frontend/vue-app/package.json
Normal file
46
frontend/vue-app/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "chore-app-frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"_build": "run-p type-check \"build-only {@}\" --",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "eslint . --fix --cache",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^22.18.11",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/eslint-plugin": "^1.3.23",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-vue": "~10.5.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^27.0.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.1.11",
|
||||
"vite-plugin-vue-devtools": "^8.0.3",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-tsc": "^3.1.1"
|
||||
}
|
||||
}
|
||||
BIN
frontend/vue-app/public/favicon.ico
Normal file
BIN
frontend/vue-app/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
18
frontend/vue-app/src/App.vue
Normal file
18
frontend/vue-app/src/App.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<BackendEventsListener />
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BackendEventsListener from '@/components/BackendEventsListener.vue'
|
||||
import { checkAuth } from '@/stores/auth'
|
||||
|
||||
checkAuth()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
11
frontend/vue-app/src/__tests__/App.spec.ts
Normal file
11
frontend/vue-app/src/__tests__/App.spec.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import App from '../App.vue'
|
||||
|
||||
describe('App', () => {
|
||||
it('mounts renders properly', () => {
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.text()).toContain('You did it!')
|
||||
})
|
||||
})
|
||||
56
frontend/vue-app/src/assets/actions-shared.css
Normal file
56
frontend/vue-app/src/assets/actions-shared.css
Normal file
@@ -0,0 +1,56 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.actions button {
|
||||
padding: 1rem 2.2rem;
|
||||
border-radius: 12px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
transition: background 0.18s;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Unified error style */
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
background: var(--error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.actions {
|
||||
gap: 1.2rem;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.8rem 1.2rem;
|
||||
font-size: 1.05rem;
|
||||
min-width: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.error-message {
|
||||
color: var(--error, #e53e3e);
|
||||
font-size: 0.98rem;
|
||||
margin-top: 0.4rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Success message */
|
||||
.success-message {
|
||||
color: var(--success, #16a34a);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Input error */
|
||||
.input-error {
|
||||
border-color: var(--error, #e53e3e);
|
||||
}
|
||||
121
frontend/vue-app/src/assets/button-shared.css
Normal file
121
frontend/vue-app/src/assets/button-shared.css
Normal file
@@ -0,0 +1,121 @@
|
||||
/* Base button style */
|
||||
.btn {
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.08);
|
||||
transition:
|
||||
background 0.18s,
|
||||
color 0.18s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Primary button (e.g., Save, Confirm) */
|
||||
.btn-primary {
|
||||
background: var(--btn-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
background: var(--btn-primary-hover);
|
||||
}
|
||||
.btn-primary:disabled,
|
||||
.btn-primary[disabled] {
|
||||
background: var(--btn-secondary, #f3f3f3);
|
||||
color: var(--btn-secondary-text, #666);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Secondary button (e.g., Cancel) */
|
||||
.btn-secondary {
|
||||
background: var(--btn-secondary);
|
||||
color: var(--btn-secondary-text);
|
||||
}
|
||||
.btn-secondary:hover,
|
||||
.btn-secondary:focus {
|
||||
background: var(--btn-secondary-hover);
|
||||
}
|
||||
|
||||
/* Danger button (e.g., Delete) */
|
||||
.btn-danger {
|
||||
background: var(--btn-danger);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover,
|
||||
.btn-danger:focus {
|
||||
background: var(--btn-danger-hover);
|
||||
}
|
||||
|
||||
/* Green button (e.g., Confirm) */
|
||||
.btn-green {
|
||||
background: var(--btn-green);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-green:hover,
|
||||
.btn-green:focus {
|
||||
background: var(--btn-green-hover);
|
||||
}
|
||||
|
||||
.form-btn {
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.12);
|
||||
transition:
|
||||
background 0.15s,
|
||||
transform 0.06s;
|
||||
}
|
||||
.form-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.form-btn:hover:not(:disabled) {
|
||||
background: var(--btn-primary-hover, #5a67d8);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Link-style button */
|
||||
.btn-link {
|
||||
color: var(--btn-primary);
|
||||
text-decoration: underline;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.btn-link:disabled {
|
||||
text-decoration: none;
|
||||
opacity: 0.75;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: var(--btn-primary);
|
||||
}
|
||||
|
||||
.round-btn {
|
||||
background: var(--sign-in-btn-bg);
|
||||
color: var(--sign-in-btn-color);
|
||||
border: 2px solid var(--sign-in-btn-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-right: 0.1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s,
|
||||
color 0.18s;
|
||||
}
|
||||
.round-btn:hover {
|
||||
background: var(--sign-in-btn-hover-bg);
|
||||
color: var(--sign-in-btn-hover-color);
|
||||
}
|
||||
205
frontend/vue-app/src/assets/child-list-shared.css
Normal file
205
frontend/vue-app/src/assets/child-list-shared.css
Normal file
@@ -0,0 +1,205 @@
|
||||
.child-list-container {
|
||||
background: var(--child-list-bg, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
color: var(--child-list-title-color, #fff);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.child-list-container h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--child-list-title-color, #fff);
|
||||
}
|
||||
|
||||
.scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-track {
|
||||
background: var(--child-list-scrollbar-track, rgba(255, 255, 255, 0.05));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
background: var(
|
||||
--child-list-scrollbar-thumb,
|
||||
linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8))
|
||||
);
|
||||
border-radius: 10px;
|
||||
border: var(--child-list-scrollbar-thumb-border, 2px solid rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: var(
|
||||
--child-list-scrollbar-thumb-hover,
|
||||
linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1))
|
||||
);
|
||||
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.item-scroll {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-width: min-content;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Fallback for browsers that don't support flex gap */
|
||||
.item-card + .item-card {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
position: relative;
|
||||
background: var(--item-card-bg, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.18s ease;
|
||||
border: var(--item-card-border, 1px solid rgba(255, 255, 255, 0.08));
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--item-card-hover-shadow, 0 8px 20px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.item-card.ready {
|
||||
box-shadow: var(--item-card-ready-shadow, 0 0 0 3px #667eea88, 0 0 12px #667eea44);
|
||||
border-color: var(--item-card-ready-border, #667eea);
|
||||
animation: ready-glow 0.7s;
|
||||
}
|
||||
|
||||
.item-card.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
filter: grayscale(0.7);
|
||||
}
|
||||
|
||||
.item-card.good {
|
||||
border-color: var(--item-card-good-border, rgba(46, 204, 113, 0.9)); /* green */
|
||||
background: var(--item-card-good-bg, rgba(46, 204, 113, 0.06));
|
||||
}
|
||||
|
||||
.item-card.bad {
|
||||
border-color: var(--item-card-bad-border, rgba(255, 99, 71, 0.95)); /* red */
|
||||
background: var(--item-card-bad-bg, rgba(255, 99, 71, 0.03));
|
||||
}
|
||||
|
||||
@keyframes ready-glow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 #667eea00;
|
||||
border-color: inherit;
|
||||
}
|
||||
100% {
|
||||
box-shadow: var(--item-card-ready-shadow, 0 0 0 3px #667eea88, 0 0 12px #667eea44);
|
||||
border-color: var(--item-card-ready-border, #667eea);
|
||||
}
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--item-name-color, #fff);
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Image styling */
|
||||
.item-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
margin: 0 auto 0.4rem auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.item-points {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--item-points-color, #ffd166);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
text-shadow: var(
|
||||
--item-points-shadow,
|
||||
-1px -1px 0 #1a3d1f,
|
||||
1px -1px 0 #1a3d1f,
|
||||
-1px 1px 0 #1a3d1f,
|
||||
1px 1px 0 #1a3d1f
|
||||
);
|
||||
}
|
||||
|
||||
.item-points.ready {
|
||||
color: var(--item-points-ready-color, #38c172); /* a nice green */
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.item-card {
|
||||
min-width: 110px;
|
||||
max-width: 150px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
.item-name {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.item-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto 0.3rem auto;
|
||||
}
|
||||
.item-points {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.pending-block {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80%;
|
||||
background: var(--pending-block-bg, #222b);
|
||||
color: var(--pending-block-color, #62ff7a);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0;
|
||||
letter-spacing: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
29
frontend/vue-app/src/assets/common.css
Normal file
29
frontend/vue-app/src/assets/common.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.loading,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
color: var(--child-list-loading-color, #fff);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
background: var(--error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error, #e53e3e);
|
||||
font-size: 0.98rem;
|
||||
margin-top: 0.4rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: var(--success, #16a34a);
|
||||
font-size: 1rem;
|
||||
}
|
||||
80
frontend/vue-app/src/assets/edit-forms.css
Normal file
80
frontend/vue-app/src/assets/edit-forms.css
Normal file
@@ -0,0 +1,80 @@
|
||||
.profile-view,
|
||||
.edit-view,
|
||||
.child-edit-view,
|
||||
.reward-edit-view,
|
||||
.task-edit-view {
|
||||
max-width: 400px;
|
||||
margin: 2rem auto;
|
||||
background: var(--form-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px var(--form-shadow);
|
||||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||
}
|
||||
|
||||
.profile-view h2,
|
||||
.edit-view h2,
|
||||
.child-edit-view h2,
|
||||
.reward-edit-view h2,
|
||||
.task-edit-view h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--form-heading);
|
||||
}
|
||||
|
||||
.profile-form,
|
||||
.task-form,
|
||||
.reward-form,
|
||||
.child-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.profile-form div.group,
|
||||
.task-form div.group,
|
||||
.reward-form div.group,
|
||||
.child-edit-form div.group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-form div.group label,
|
||||
.task-form div.group label,
|
||||
.reward-form div.group label,
|
||||
.child-edit-form div.group label {
|
||||
font-weight: 600;
|
||||
color: var(--form-label-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
div.group input[type='text'],
|
||||
div.group input[type='number'],
|
||||
div.group input[type='email'] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--form-input-border);
|
||||
font-size: 1rem;
|
||||
background: var(--form-input-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
div.group input:focus {
|
||||
outline: none;
|
||||
border: 1.5px solid var(--form-input-focus);
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
text-align: center;
|
||||
color: var(--form-loading);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.form-group.image-picker-group {
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
185
frontend/vue-app/src/assets/global.css
Normal file
185
frontend/vue-app/src/assets/global.css
Normal file
@@ -0,0 +1,185 @@
|
||||
:root {
|
||||
--primary: #667eea;
|
||||
--secondary: #7257b3;
|
||||
--accent: #cbd5e1;
|
||||
--header-bg: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
||||
--button-bg: #fff;
|
||||
--button-active-bg: var(--secondary);
|
||||
--button-hover-bg: #e6eaff;
|
||||
--button-text: var(--primary);
|
||||
--button-active-text: #fff;
|
||||
--app-version: var(--accent);
|
||||
|
||||
/* Add these: */
|
||||
--btn-primary: #667eea;
|
||||
--btn-primary-hover: #5a67d8;
|
||||
--btn-secondary: #f3f3f3;
|
||||
--btn-secondary-hover: #e2e8f0;
|
||||
--btn-secondary-text: #666;
|
||||
--btn-danger: #ef4444;
|
||||
--btn-danger-hover: #dc2626;
|
||||
--btn-green: #22c55e;
|
||||
--btn-green-hover: #16a34a;
|
||||
|
||||
--error: #e53e3e;
|
||||
--error-bg: rgba(255, 107, 107, 0.1);
|
||||
|
||||
--form-bg: #fff;
|
||||
--form-shadow: #667eea22;
|
||||
--form-heading: #667eea;
|
||||
--form-label: #444;
|
||||
--form-input-bg: #f8fafc;
|
||||
--form-input-border: #cbd5e1;
|
||||
--form-loading: #666;
|
||||
|
||||
--list-bg: #fff5;
|
||||
--list-item-bg: #f8fafc;
|
||||
--list-item-border-reward: #38c172;
|
||||
--list-item-border-bad: #e53e3e;
|
||||
--list-item-border-good: #00e1ff;
|
||||
--list-item-bg-reward: #94ffb1;
|
||||
--list-item-bg-bad: #ffc5c5;
|
||||
--list-item-bg-good: #8dabfd;
|
||||
--list-image-bg: #eee;
|
||||
--delete-btn-hover-bg: #ffeaea;
|
||||
--delete-btn-hover-shadow: #ef444422;
|
||||
--checkbox-accent: #667eea;
|
||||
--list-loading-color: #c0c0c0;
|
||||
|
||||
--modal-bg: #fff;
|
||||
--modal-text: #222;
|
||||
--modal-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
--dialog-message: #444;
|
||||
--dialog-child-name: #667eea;
|
||||
--info-image-bg: #eee;
|
||||
--info-points: #667eea;
|
||||
--loading-color: #fff;
|
||||
|
||||
/* ChildrenListView styles */
|
||||
--card-bg: #fff;
|
||||
--card-title: #333;
|
||||
--card-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
--kebab-icon-color: #666;
|
||||
--kebab-menu-bg: #f7fafc;
|
||||
--kebab-menu-border: #bcc1c9;
|
||||
--kebab-menu-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), 0 1.5px 6px rgba(102, 126, 234, 0.08);
|
||||
--kebab-menu-blur: 11px;
|
||||
--menu-item-color: #333;
|
||||
--menu-item-hover-bg: rgba(0, 0, 0, 0.04);
|
||||
--menu-item-danger: #ff4d4f;
|
||||
--modal-bg: #fff;
|
||||
--modal-text: #222;
|
||||
--modal-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
--fab-bg: #667eea;
|
||||
--fab-hover-bg: #5a67d8;
|
||||
--fab-active-bg: #4c51bf;
|
||||
--message-block-color: #fdfdfd;
|
||||
--sub-message-color: #c1d0f1;
|
||||
--sign-in-btn-bg: #fff;
|
||||
--sign-in-btn-color: #2563eb;
|
||||
--sign-in-btn-border: #2563eb;
|
||||
--sign-in-btn-hover-bg: #2563eb;
|
||||
--sign-in-btn-hover-color: #fff;
|
||||
--child-image-bg: #fff;
|
||||
--age-color: #666;
|
||||
--points-color: #444;
|
||||
|
||||
/* Image Picker styles */
|
||||
--icon-btn-bg: #f3f3f3;
|
||||
--icon-btn-color: #667eea;
|
||||
--icon-btn-shadow: 0 2px 8px rgba(102, 126, 234, 0.07);
|
||||
--selectable-image-border: #e6e6e6;
|
||||
--selectable-image-bg: #fafbff;
|
||||
--selectable-image-selected: #667eea;
|
||||
--loading-text: #888;
|
||||
--modal-bg: #fff;
|
||||
--modal-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* ChildDetailCard color variables */
|
||||
--detail-card-bg: #fff;
|
||||
--detail-card-shadow: 0 8px 32px rgba(0, 0, 0, 0.13);
|
||||
|
||||
--child-name-color: #333;
|
||||
--child-age-color: #666;
|
||||
|
||||
--points-label-color: #888;
|
||||
--points-value-color: #667eea;
|
||||
|
||||
/* ChildEditView styles */
|
||||
--form-label-color: #333;
|
||||
--form-input-bg: #fafbff;
|
||||
--form-input-border: #e6e6e6;
|
||||
--form-input-color: #222;
|
||||
--form-input-focus: #667eea;
|
||||
|
||||
/*Reward/Task Assignment styles*/
|
||||
--assign-heading-color: #ffffff;
|
||||
--assign-no-items-color: #fdfdfd;
|
||||
--assign-sub-message-color: #b5ccff;
|
||||
--assign-create-btn-bg: #fff;
|
||||
--assign-create-btn-color: #2563eb;
|
||||
--assign-create-btn-border: #2563eb;
|
||||
--assign-create-btn-hover-bg: #2563eb;
|
||||
--assign-create-btn-hover-color: #fff;
|
||||
|
||||
--notification-reward-name: #ef4444;
|
||||
|
||||
/* ChildLists styles */
|
||||
--child-list-bg: rgba(255, 255, 255, 0.1);
|
||||
--child-list-title-color: #fff;
|
||||
--child-list-loading-color: #fff;
|
||||
--child-list-scrollbar-track: rgba(255, 255, 255, 0.05);
|
||||
--child-list-scrollbar-thumb: linear-gradient(
|
||||
180deg,
|
||||
rgba(102, 126, 234, 0.8),
|
||||
rgba(118, 75, 162, 0.8)
|
||||
);
|
||||
--child-list-scrollbar-thumb-hover: linear-gradient(
|
||||
180deg,
|
||||
rgba(102, 126, 234, 1),
|
||||
rgba(118, 75, 162, 1)
|
||||
);
|
||||
--child-list-scrollbar-thumb-border: 2px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
--item-card-bg: rgba(255, 255, 255, 0.12);
|
||||
--item-card-border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
--item-card-good-border: rgba(46, 204, 113, 0.9);
|
||||
--item-card-good-bg: rgba(46, 204, 113, 0.06);
|
||||
--item-card-bad-border: rgba(255, 99, 71, 0.95);
|
||||
--item-card-bad-bg: rgba(255, 99, 71, 0.03);
|
||||
--item-card-ready-shadow: 0 0 0 3px #667eea88, 0 0 12px #667eea44;
|
||||
--item-card-ready-border: #667eea;
|
||||
--item-card-hover-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
|
||||
--item-name-color: #fff;
|
||||
|
||||
--item-points-color: #ffd166;
|
||||
--item-points-shadow:
|
||||
-1px -1px 0 #1a3d1f, 1px -1px 0 #1a3d1f, -1px 1px 0 #1a3d1f, 1px 1px 0 #1a3d1f;
|
||||
--item-points-ready-color: #38c172;
|
||||
|
||||
--pending-block-bg: #222b;
|
||||
--pending-block-color: #62ff7a;
|
||||
|
||||
/* TaskEditView styles */
|
||||
--toggle-btn-bg: #f3f3f3;
|
||||
--toggle-btn-color: #444;
|
||||
--toggle-btn-border: #cbd5e1;
|
||||
|
||||
--toggle-btn-good-bg: #38c172;
|
||||
--toggle-btn-good-color: #fff;
|
||||
--toggle-btn-good-shadow: #38c17233;
|
||||
--toggle-btn-good-border: #38c172;
|
||||
|
||||
--toggle-btn-bad-bg: #e53e3e;
|
||||
--toggle-btn-bad-color: #fff;
|
||||
--toggle-btn-bad-shadow: #e53e3e33;
|
||||
--toggle-btn-bad-border: #e53e3e;
|
||||
|
||||
/*TaskView and RewardView styles*/
|
||||
--create-btn-bg: #fff;
|
||||
--create-btn-color: #2563eb;
|
||||
--create-btn-border: #2563eb;
|
||||
--create-btn-hover-bg: #2563eb;
|
||||
--create-btn-hover-color: #fff;
|
||||
}
|
||||
108
frontend/vue-app/src/assets/layout-shared.css
Normal file
108
frontend/vue-app/src/assets/layout-shared.css
Normal file
@@ -0,0 +1,108 @@
|
||||
/* Root layout */
|
||||
.layout-root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
background: var(--header-bg, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
|
||||
}
|
||||
|
||||
/* Top bar */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 5px 5px;
|
||||
height: 48px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Back button container and login button container */
|
||||
.back-btn-container,
|
||||
.login-btn-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.back-btn-container {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.login-btn-container {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Spacer for layouts without a center nav */
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Back button */
|
||||
.back-btn {
|
||||
background: var(--button-bg, #fff);
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px 8px 0 0;
|
||||
cursor: pointer;
|
||||
color: var(--button-text, #667eea);
|
||||
font-weight: 600;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Login button inside login-btn container */
|
||||
.login-btn {
|
||||
background: var(--button-bg, #fff);
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px 8px 0 0;
|
||||
cursor: pointer;
|
||||
color: var(--button-text, #667eea);
|
||||
font-weight: 600;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
.main-content {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
/* App version display */
|
||||
.app-version {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 12px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--app-version, #cbd5e1);
|
||||
opacity: 0.85;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.back-btn,
|
||||
.login-btn button {
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.6rem;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
25
frontend/vue-app/src/assets/modal.css
Normal file
25
frontend/vue-app/src/assets/modal.css
Normal file
@@ -0,0 +1,25 @@
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--modal-bg);
|
||||
color: var(--modal-text);
|
||||
padding: 1.25rem;
|
||||
border-radius: 10px;
|
||||
width: 360px;
|
||||
max-width: calc(100% - 32px);
|
||||
box-shadow: var(--modal-shadow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
25
frontend/vue-app/src/assets/scroll.css
Normal file
25
frontend/vue-app/src/assets/scroll.css
Normal file
@@ -0,0 +1,25 @@
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-track {
|
||||
background: var(--child-list-scrollbar-track, rgba(255, 255, 255, 0.05));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
background: var(
|
||||
--child-list-scrollbar-thumb,
|
||||
linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8))
|
||||
);
|
||||
border-radius: 10px;
|
||||
border: var(--child-list-scrollbar-thumb-border, 2px solid rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: var(
|
||||
--child-list-scrollbar-thumb-hover,
|
||||
linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1))
|
||||
);
|
||||
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
112
frontend/vue-app/src/assets/view-shared.css
Normal file
112
frontend/vue-app/src/assets/view-shared.css
Normal file
@@ -0,0 +1,112 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--loading-color);
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Modal Backdrop and Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
.modal {
|
||||
background: var(--modal-bg);
|
||||
color: var(--modal-text);
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 12px;
|
||||
min-width: 240px;
|
||||
box-shadow: var(--modal-shadow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Dialog Message */
|
||||
.dialog-message {
|
||||
font-size: 1.08rem;
|
||||
color: var(--dialog-message);
|
||||
font-weight: 500;
|
||||
margin-bottom: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.dialog-message .child-name {
|
||||
color: var(--dialog-child-name);
|
||||
font-weight: 700;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Info Sections (Reward/Task) */
|
||||
.info,
|
||||
.reward-info,
|
||||
.task-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.image,
|
||||
.reward-image,
|
||||
.task-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--info-image-bg);
|
||||
}
|
||||
.details,
|
||||
.reward-details,
|
||||
.task-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.name,
|
||||
.reward-name,
|
||||
.task-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.points,
|
||||
.reward-points,
|
||||
.task-points {
|
||||
color: var(--info-points);
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.main {
|
||||
gap: 1rem;
|
||||
}
|
||||
.modal {
|
||||
padding: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
17
frontend/vue-app/src/common/api.ts
Normal file
17
frontend/vue-app/src/common/api.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> {
|
||||
try {
|
||||
const data = await res.json()
|
||||
return { msg: data.error || data.message || 'Error', code: data.code }
|
||||
} catch {
|
||||
const text = await res.text()
|
||||
return { msg: text || 'Error' }
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmailValid(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
export function isPasswordStrong(password: string): boolean {
|
||||
return /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{8,}$/.test(password)
|
||||
}
|
||||
30
frontend/vue-app/src/common/backendEvents.ts
Normal file
30
frontend/vue-app/src/common/backendEvents.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { eventBus } from './eventBus'
|
||||
|
||||
export function useBackendEvents(userId: Ref<string>) {
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
const connect = () => {
|
||||
if (eventSource) eventSource.close()
|
||||
if (userId.value) {
|
||||
console.log('Connecting to backend events for user:', userId.value)
|
||||
eventSource = new EventSource(`/events?user_id=${userId.value}`)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
// Emit globally for any component that cares
|
||||
eventBus.emit(data.type, data)
|
||||
eventBus.emit('sse', data) // optional: catch-all channel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(connect)
|
||||
watch(userId, connect)
|
||||
onBeforeUnmount(() => {
|
||||
console.log('Disconnecting from backend events for user:', userId.value)
|
||||
eventSource?.close()
|
||||
})
|
||||
}
|
||||
12
frontend/vue-app/src/common/errorCodes.ts
Normal file
12
frontend/vue-app/src/common/errorCodes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const MISSING_FIELDS = 'MISSING_FIELDS'
|
||||
export const EMAIL_EXISTS = 'EMAIL_EXISTS'
|
||||
export const MISSING_TOKEN = 'MISSING_TOKEN'
|
||||
export const INVALID_TOKEN = 'INVALID_TOKEN'
|
||||
export const TOKEN_TIMESTAMP_MISSING = 'TOKEN_TIMESTAMP_MISSING'
|
||||
export const TOKEN_EXPIRED = 'TOKEN_EXPIRED'
|
||||
export const MISSING_EMAIL = 'MISSING_EMAIL'
|
||||
export const USER_NOT_FOUND = 'USER_NOT_FOUND'
|
||||
export const ALREADY_VERIFIED = 'ALREADY_VERIFIED'
|
||||
export const MISSING_EMAIL_OR_PASSWORD = 'MISSING_EMAIL_OR_PASSWORD'
|
||||
export const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'
|
||||
export const NOT_VERIFIED = 'NOT_VERIFIED'
|
||||
29
frontend/vue-app/src/common/eventBus.ts
Normal file
29
frontend/vue-app/src/common/eventBus.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
type Callback = (payload: any) => void
|
||||
|
||||
class EventBus {
|
||||
private listeners: Map<string, Callback[]> = new Map()
|
||||
|
||||
on(event: string, callback: Callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, [])
|
||||
}
|
||||
this.listeners.get(event)!.push(callback)
|
||||
}
|
||||
|
||||
off(event: string, callback: Callback) {
|
||||
const list = this.listeners.get(event)
|
||||
if (!list) return
|
||||
|
||||
const index = list.indexOf(callback)
|
||||
if (index !== -1) list.splice(index, 1)
|
||||
}
|
||||
|
||||
emit(event: string, payload?: any) {
|
||||
const list = this.listeners.get(event)
|
||||
if (!list) return
|
||||
|
||||
list.forEach((callback) => callback(payload))
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus()
|
||||
64
frontend/vue-app/src/common/imageCache.ts
Normal file
64
frontend/vue-app/src/common/imageCache.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export const DEFAULT_IMAGE_CACHE = 'images-v1'
|
||||
|
||||
const objectUrlMap = new Map<string, string>()
|
||||
|
||||
export async function getCachedImageUrl(
|
||||
imageId: string,
|
||||
cacheName = DEFAULT_IMAGE_CACHE,
|
||||
): Promise<string> {
|
||||
if (!imageId) throw new Error('imageId required')
|
||||
|
||||
// reuse existing object URL if created in this session
|
||||
const existing = objectUrlMap.get(imageId)
|
||||
if (existing) return existing
|
||||
|
||||
const requestUrl = `/api/image/request/${imageId}`
|
||||
|
||||
// Try Cache Storage first
|
||||
let response: Response | undefined
|
||||
if ('caches' in window) {
|
||||
const cache = await caches.open(cacheName)
|
||||
response = await cache.match(requestUrl)
|
||||
if (!response) {
|
||||
const fetched = await fetch(requestUrl)
|
||||
if (!fetched.ok) throw new Error(`HTTP ${fetched.status}`)
|
||||
// store a clone in Cache Storage (non-blocking)
|
||||
cache.put(requestUrl, fetched.clone()).catch((e) => {
|
||||
console.warn('Cache put failed:', e)
|
||||
})
|
||||
response = fetched
|
||||
}
|
||||
} else {
|
||||
const fetched = await fetch(requestUrl)
|
||||
if (!fetched.ok) throw new Error(`HTTP ${fetched.status}`)
|
||||
response = fetched
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
objectUrlMap.set(imageId, objectUrl)
|
||||
return objectUrl
|
||||
}
|
||||
|
||||
export function revokeImageUrl(imageId: string) {
|
||||
const url = objectUrlMap.get(imageId)
|
||||
if (url) {
|
||||
try {
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
objectUrlMap.delete(imageId)
|
||||
}
|
||||
}
|
||||
|
||||
export function revokeAllImageUrls() {
|
||||
for (const url of objectUrlMap.values()) {
|
||||
try {
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
objectUrlMap.clear()
|
||||
}
|
||||
124
frontend/vue-app/src/common/models.ts
Normal file
124
frontend/vue-app/src/common/models.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
export interface Task {
|
||||
id: string
|
||||
name: string
|
||||
points: number
|
||||
is_good: boolean
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const TASK_FIELDS = ['id', 'name', 'points', 'is_good', 'image_id'] as const
|
||||
|
||||
export interface Child {
|
||||
id: string
|
||||
name: string
|
||||
age: number
|
||||
tasks: string[]
|
||||
rewards: string[]
|
||||
points: number
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const CHILD_FIELDS = ['id', 'name', 'age', 'tasks', 'rewards', 'points', 'image_id'] as const
|
||||
|
||||
export interface Reward {
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
points_needed: number
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const REWARD_FIELDS = ['id', 'name', 'cost', 'points_needed', 'image_id'] as const
|
||||
|
||||
export interface RewardStatus {
|
||||
id: string
|
||||
name: string
|
||||
points_needed: number
|
||||
cost: number
|
||||
redeeming: boolean
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const REWARD_STATUS_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'points_needed',
|
||||
'cost',
|
||||
'redeeming',
|
||||
'image_id',
|
||||
] as const
|
||||
|
||||
export interface PendingReward {
|
||||
id: string
|
||||
child_id: string
|
||||
child_name: string
|
||||
child_image_id: string | null
|
||||
child_image_url?: string | null // optional, for resolved URLs
|
||||
reward_id: string
|
||||
reward_name: string
|
||||
reward_image_id: string | null
|
||||
reward_image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const PENDING_REWARD_FIELDS = [
|
||||
'id',
|
||||
'child_id',
|
||||
'child_name',
|
||||
'child_image_id',
|
||||
'reward_id',
|
||||
'reward_name',
|
||||
'reward_image_id',
|
||||
] as const
|
||||
|
||||
export interface Event {
|
||||
type: string
|
||||
payload:
|
||||
| ChildModifiedEventPayload
|
||||
| ChildTaskTriggeredEventPayload
|
||||
| ChildRewardTriggeredEventPayload
|
||||
| ChildRewardRequestEventPayload
|
||||
| ChildTasksSetEventPayload
|
||||
| ChildRewardsSetEventPayload
|
||||
| TaskModifiedEventPayload
|
||||
| RewardModifiedEventPayload
|
||||
}
|
||||
|
||||
export interface ChildModifiedEventPayload {
|
||||
child_id: string
|
||||
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||
}
|
||||
|
||||
export interface ChildTaskTriggeredEventPayload {
|
||||
task_id: string
|
||||
child_id: string
|
||||
points: number
|
||||
}
|
||||
|
||||
export interface ChildRewardTriggeredEventPayload {
|
||||
task_id: string
|
||||
child_id: string
|
||||
points: number
|
||||
}
|
||||
|
||||
export interface ChildRewardRequestEventPayload {
|
||||
child_id: string
|
||||
reward_id: string
|
||||
operation: 'GRANTED' | 'CREATED' | 'CANCELLED'
|
||||
}
|
||||
|
||||
export interface ChildTasksSetEventPayload {
|
||||
child_id: string
|
||||
task_ids: string[]
|
||||
}
|
||||
|
||||
export interface ChildRewardsSetEventPayload {
|
||||
child_id: string
|
||||
reward_ids: string[]
|
||||
}
|
||||
export interface TaskModifiedEventPayload {
|
||||
task_id: string
|
||||
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||
}
|
||||
export interface RewardModifiedEventPayload {
|
||||
reward_id: string
|
||||
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||
}
|
||||
18
frontend/vue-app/src/components/BackendEventsListener.vue
Normal file
18
frontend/vue-app/src/components/BackendEventsListener.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useBackendEvents } from '@/common/backendEvents'
|
||||
import { currentUserId } from '@/stores/auth'
|
||||
|
||||
const userId = ref(currentUserId.value)
|
||||
|
||||
watch(currentUserId, (id) => {
|
||||
userId.value = id
|
||||
})
|
||||
|
||||
// Always call useBackendEvents in setup, passing the reactive userId
|
||||
useBackendEvents(userId)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
51
frontend/vue-app/src/components/auth/AuthLanding.vue
Normal file
51
frontend/vue-app/src/components/auth/AuthLanding.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="auth-landing">
|
||||
<div class="auth-card">
|
||||
<h1>Welcome</h1>
|
||||
<p>Please sign in or create an account to continue.</p>
|
||||
<div class="auth-actions">
|
||||
<button class="btn btn-primary" @click="goToLogin">Log In</button>
|
||||
<button class="btn btn-secondary" @click="goToSignup">Sign Up</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
function goToLogin() {
|
||||
router.push({ name: 'Login' })
|
||||
}
|
||||
function goToSignup() {
|
||||
router.push({ name: 'Signup' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-landing {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--header-bg, linear-gradient(135deg, var(--primary), var(--secondary)));
|
||||
}
|
||||
.auth-card {
|
||||
background: var(--card-bg, #fff);
|
||||
padding: 2.5rem 2rem;
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--card-shadow, 0 8px 32px rgba(0, 0, 0, 0.13));
|
||||
text-align: center;
|
||||
max-width: 340px;
|
||||
width: 100%;
|
||||
}
|
||||
.auth-card h1 {
|
||||
color: var(--card-title, #333);
|
||||
}
|
||||
.auth-actions {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
187
frontend/vue-app/src/components/auth/ForgotPassword.vue
Normal file
187
frontend/vue-app/src/components/auth/ForgotPassword.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<div class="edit-view">
|
||||
<form class="forgot-form" @submit.prevent="submitForm" novalidate>
|
||||
<h2>Reset your password</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
v-model="email"
|
||||
:class="{ 'input-error': submitAttempted && !isEmailValid }"
|
||||
required
|
||||
/>
|
||||
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite">
|
||||
Email is required.
|
||||
</small>
|
||||
<small
|
||||
v-else-if="submitAttempted && !isEmailValid"
|
||||
class="error-message"
|
||||
aria-live="polite"
|
||||
>
|
||||
Please enter a valid email address.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
<div
|
||||
v-if="successMsg"
|
||||
class="success-message"
|
||||
style="margin-bottom: 1rem"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ successMsg }}
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 0.4rem">
|
||||
<button type="submit" class="form-btn" :disabled="loading || !isEmailValid">
|
||||
{{ loading ? 'Sending…' : 'Send reset link' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style="
|
||||
text-align: center;
|
||||
margin-top: 0.8rem;
|
||||
color: var(--sub-message-color, #6b7280);
|
||||
font-size: 0.95rem;
|
||||
"
|
||||
>
|
||||
Remembered your password?
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
@click="goToLogin"
|
||||
style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-primary);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 6px;
|
||||
"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isEmailValid } from '@/common/api'
|
||||
import '@/assets/view-shared.css'
|
||||
import '@/assets/global.css'
|
||||
import '@/assets/edit-forms.css'
|
||||
import '@/assets/actions-shared.css'
|
||||
import '@/assets/button-shared.css'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const email = ref('')
|
||||
const submitAttempted = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const successMsg = ref('')
|
||||
|
||||
const isEmailValidRef = computed(() => isEmailValid(email.value))
|
||||
|
||||
async function submitForm() {
|
||||
submitAttempted.value = true
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
|
||||
if (!isEmailValidRef.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/request-password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.value.trim() }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
let msg = 'Could not send reset email.'
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data && (data.error || data.message)) {
|
||||
msg = data.error || data.message
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const text = await res.text()
|
||||
if (text) msg = text
|
||||
} catch {}
|
||||
}
|
||||
errorMsg.value = msg
|
||||
return
|
||||
}
|
||||
successMsg.value =
|
||||
'If this email is registered, you will receive a password reset link shortly.'
|
||||
email.value = ''
|
||||
submitAttempted.value = false
|
||||
} catch {
|
||||
errorMsg.value = 'Network error. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function goToLogin() {
|
||||
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.edit-view) {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.forgot-form {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--form-label, #444);
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group input[type='email'] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.6rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
font-size: 1rem;
|
||||
background: var(--form-input-bg, #fff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-link:disabled {
|
||||
text-decoration: none;
|
||||
cursor: default;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.forgot-form {
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
326
frontend/vue-app/src/components/auth/Login.vue
Normal file
326
frontend/vue-app/src/components/auth/Login.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<div class="edit-view">
|
||||
<form class="login-form" @submit.prevent="submitForm" novalidate>
|
||||
<h2>Sign in</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
v-model="email"
|
||||
:class="{ 'input-error': submitAttempted && !isEmailValid }"
|
||||
required
|
||||
/>
|
||||
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite"
|
||||
>Email is required.</small
|
||||
>
|
||||
<small
|
||||
v-else-if="submitAttempted && !isEmailValid"
|
||||
class="error-message"
|
||||
aria-live="polite"
|
||||
>Please enter a valid email address.</small
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
v-model="password"
|
||||
:class="{ 'input-error': submitAttempted && !password }"
|
||||
required
|
||||
/>
|
||||
<small v-if="submitAttempted && !password" class="error-message" aria-live="polite"
|
||||
>Password is required.</small
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- show server error message -->
|
||||
<div v-if="loginError" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
|
||||
{{ loginError }}
|
||||
</div>
|
||||
|
||||
<!-- show resend UI when server indicated unverified account (independent of loginError) -->
|
||||
<div v-if="showResend && !resendSent" style="margin-top: 0.5rem">
|
||||
<button
|
||||
v-if="!resendLoading"
|
||||
type="button"
|
||||
class="btn-link"
|
||||
@click="resendVerification"
|
||||
:disabled="!email"
|
||||
>
|
||||
Resend verification email
|
||||
</button>
|
||||
|
||||
<span v-else class="btn-link btn-disabled" aria-busy="true">Sending…</span>
|
||||
</div>
|
||||
|
||||
<!-- success / error messages for the resend action (shown even if loginError was cleared) -->
|
||||
<div
|
||||
v-if="resendSent"
|
||||
style="margin-top: 0.5rem; color: var(--success, #16a34a); font-size: 0.92rem"
|
||||
>
|
||||
Verification email sent. Check your inbox.
|
||||
</div>
|
||||
|
||||
<div v-if="resendError" class="error-message" style="margin-top: 0.5rem" aria-live="polite">
|
||||
{{ resendError }}
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 0.4rem">
|
||||
<button type="submit" class="form-btn" :disabled="loading || !formValid">
|
||||
{{ loading ? 'Signing in…' : 'Sign in' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style="
|
||||
text-align: center;
|
||||
margin-top: 0.8rem;
|
||||
color: var(--sub-message-color, #6b7280);
|
||||
font-size: 0.95rem;
|
||||
"
|
||||
>
|
||||
Don't have an account?
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
@click="goToSignup"
|
||||
style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-primary);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 6px;
|
||||
"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
text-align: center;
|
||||
margin-top: 0.4rem;
|
||||
color: var(--sub-message-color, #6b7280);
|
||||
font-size: 0.95rem;
|
||||
"
|
||||
>
|
||||
Forgot your password?
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
@click="goToForgotPassword"
|
||||
style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-primary);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 6px;
|
||||
"
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import '@/assets/view-shared.css'
|
||||
import '@/assets/global.css'
|
||||
import '@/assets/edit-forms.css'
|
||||
import '@/assets/actions-shared.css'
|
||||
import '@/assets/button-shared.css'
|
||||
import {
|
||||
MISSING_EMAIL_OR_PASSWORD,
|
||||
INVALID_CREDENTIALS,
|
||||
NOT_VERIFIED,
|
||||
MISSING_EMAIL,
|
||||
USER_NOT_FOUND,
|
||||
ALREADY_VERIFIED,
|
||||
} from '@/common/errorCodes'
|
||||
import { parseErrorResponse, isEmailValid } from '@/common/api'
|
||||
import { loginUser } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const submitAttempted = ref(false)
|
||||
const loading = ref(false)
|
||||
const loginError = ref('')
|
||||
|
||||
/* new state for resend flow */
|
||||
const showResend = ref(false)
|
||||
const resendLoading = ref(false)
|
||||
const resendSent = ref(false)
|
||||
const resendError = ref('')
|
||||
|
||||
const isEmailValidRef = computed(() => isEmailValid(email.value))
|
||||
const formValid = computed(() => email.value && isEmailValidRef.value && password.value)
|
||||
|
||||
async function submitForm() {
|
||||
submitAttempted.value = true
|
||||
loginError.value = ''
|
||||
showResend.value = false
|
||||
resendError.value = ''
|
||||
resendSent.value = false
|
||||
|
||||
if (!formValid.value) return
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.value.trim(), password: password.value }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const { msg, code } = await parseErrorResponse(res)
|
||||
showResend.value = false
|
||||
let displayMsg = msg
|
||||
switch (code) {
|
||||
case MISSING_EMAIL_OR_PASSWORD:
|
||||
displayMsg = 'Email and password are required.'
|
||||
break
|
||||
case INVALID_CREDENTIALS:
|
||||
displayMsg = 'The email and password combination is incorrect. Please try again.'
|
||||
break
|
||||
case NOT_VERIFIED:
|
||||
displayMsg =
|
||||
'Your account is not verified. Please check your email for the verification link.'
|
||||
showResend.value = true
|
||||
break
|
||||
default:
|
||||
displayMsg = msg || `Login failed with status ${res.status}.`
|
||||
}
|
||||
loginError.value = displayMsg
|
||||
return
|
||||
}
|
||||
|
||||
loginUser() // <-- set user as logged in
|
||||
|
||||
await router.push({ path: '/' }).catch(() => (window.location.href = '/'))
|
||||
} catch (err) {
|
||||
loginError.value = 'Network error. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resendVerification() {
|
||||
loginError.value = ''
|
||||
resendError.value = ''
|
||||
resendSent.value = false
|
||||
if (!email.value) {
|
||||
resendError.value = 'Please enter your email above to resend verification.'
|
||||
return
|
||||
}
|
||||
resendLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/resend-verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.value }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const { msg, code } = await parseErrorResponse(res)
|
||||
resendError.value = msg
|
||||
let displayMsg = msg
|
||||
switch (code) {
|
||||
case MISSING_EMAIL:
|
||||
displayMsg = 'Email is required.'
|
||||
break
|
||||
case USER_NOT_FOUND:
|
||||
displayMsg = 'This email is not registered.'
|
||||
break
|
||||
case ALREADY_VERIFIED:
|
||||
displayMsg = 'Your account is already verified. Please log in.'
|
||||
showResend.value = false
|
||||
break
|
||||
default:
|
||||
displayMsg = msg || `Login failed with status ${res.status}.`
|
||||
}
|
||||
resendError.value = displayMsg
|
||||
return
|
||||
}
|
||||
resendSent.value = true
|
||||
} catch {
|
||||
resendError.value = 'Network error. Please try again.'
|
||||
} finally {
|
||||
resendLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function goToSignup() {
|
||||
await router.push({ name: 'Signup' }).catch(() => (window.location.href = '/auth/signup'))
|
||||
}
|
||||
|
||||
async function goToForgotPassword() {
|
||||
await router
|
||||
.push({ name: 'ForgotPassword' })
|
||||
.catch(() => (window.location.href = '/auth/forgot-password'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.edit-view) {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* reuse edit-forms form-group styles */
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--form-label, #444);
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group input[type='email'],
|
||||
.form-group input[type='password'] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.6rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
font-size: 1rem;
|
||||
background: var(--form-input-bg, #fff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* also ensure disabled button doesn't show underline in browsers that style disabled anchors/buttons */
|
||||
.btn-link:disabled {
|
||||
text-decoration: none;
|
||||
cursor: default;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.login-form {
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
286
frontend/vue-app/src/components/auth/ResetPassword.vue
Normal file
286
frontend/vue-app/src/components/auth/ResetPassword.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<div class="edit-view">
|
||||
<form
|
||||
v-if="tokenChecked && tokenValid"
|
||||
class="reset-form"
|
||||
@submit.prevent="submitForm"
|
||||
novalidate
|
||||
>
|
||||
<h2>Set a new password</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">New password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
v-model="password"
|
||||
:class="{ 'input-error': submitAttempted && !isPasswordStrong }"
|
||||
required
|
||||
/>
|
||||
<small v-if="submitAttempted && !password" class="error-message" aria-live="polite">
|
||||
Password is required.
|
||||
</small>
|
||||
<small
|
||||
v-else-if="submitAttempted && !isPasswordStrong"
|
||||
class="error-message"
|
||||
aria-live="polite"
|
||||
>
|
||||
Password must be at least 8 characters and contain a letter and a number.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm">Confirm password</label>
|
||||
<input
|
||||
id="confirm"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
v-model="confirmPassword"
|
||||
:class="{ 'input-error': submitAttempted && !passwordsMatch }"
|
||||
required
|
||||
/>
|
||||
<small
|
||||
v-if="submitAttempted && !confirmPassword"
|
||||
class="error-message"
|
||||
aria-live="polite"
|
||||
>
|
||||
Please confirm your password.
|
||||
</small>
|
||||
<small
|
||||
v-else-if="submitAttempted && !passwordsMatch"
|
||||
class="error-message"
|
||||
aria-live="polite"
|
||||
>
|
||||
Passwords do not match.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
<div
|
||||
v-if="successMsg"
|
||||
class="success-message"
|
||||
style="margin-bottom: 1rem"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ successMsg }}
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 0.4rem">
|
||||
<button type="submit" class="form-btn" :disabled="loading || !formValid">
|
||||
{{ loading ? 'Resetting…' : 'Reset password' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style="
|
||||
text-align: center;
|
||||
margin-top: 0.8rem;
|
||||
color: var(--sub-message-color, #6b7280);
|
||||
font-size: 0.95rem;
|
||||
"
|
||||
>
|
||||
Remembered your password?
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
@click="goToLogin"
|
||||
style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-primary);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 6px;
|
||||
"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
<div
|
||||
v-else-if="tokenChecked && !tokenValid"
|
||||
class="error-message"
|
||||
aria-live="polite"
|
||||
style="margin-top: 2rem"
|
||||
>
|
||||
{{ errorMsg }}
|
||||
<div style="margin-top: 1.2rem">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
@click="goToLogin"
|
||||
style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-primary);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 6px;
|
||||
"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="margin-top: 2rem; text-align: center">Checking reset link...</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { isPasswordStrong } from '@/common/api'
|
||||
import '@/assets/view-shared.css'
|
||||
import '@/assets/global.css'
|
||||
import '@/assets/edit-forms.css'
|
||||
import '@/assets/actions-shared.css'
|
||||
import '@/assets/button-shared.css'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const submitAttempted = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const successMsg = ref('')
|
||||
const token = ref('')
|
||||
const tokenValid = ref(false)
|
||||
const tokenChecked = ref(false)
|
||||
|
||||
const isPasswordStrongRef = computed(() => isPasswordStrong(password.value))
|
||||
const passwordsMatch = computed(() => password.value === confirmPassword.value)
|
||||
const formValid = computed(
|
||||
() =>
|
||||
password.value && confirmPassword.value && isPasswordStrongRef.value && passwordsMatch.value,
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// Get token from query string
|
||||
const raw = route.query.token ?? ''
|
||||
token.value = Array.isArray(raw) ? raw[0] : String(raw || '')
|
||||
|
||||
// Validate token with backend
|
||||
if (token.value) {
|
||||
try {
|
||||
const res = await fetch(`/api/validate-reset-token?token=${encodeURIComponent(token.value)}`)
|
||||
tokenChecked.value = true
|
||||
if (res.ok) {
|
||||
tokenValid.value = true
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
errorMsg.value = data.error || 'This password reset link is invalid or has expired.'
|
||||
tokenValid.value = false
|
||||
}
|
||||
} catch {
|
||||
errorMsg.value = 'Network error. Please try again.'
|
||||
tokenValid.value = false
|
||||
tokenChecked.value = true
|
||||
}
|
||||
} else {
|
||||
errorMsg.value = 'No reset token provided.'
|
||||
tokenValid.value = false
|
||||
tokenChecked.value = true
|
||||
}
|
||||
})
|
||||
|
||||
async function submitForm() {
|
||||
submitAttempted.value = true
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
|
||||
if (!formValid.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: token.value,
|
||||
password: password.value,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
let msg = 'Could not reset password.'
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data && (data.error || data.message)) {
|
||||
msg = data.error || data.message
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const text = await res.text()
|
||||
if (text) msg = text
|
||||
} catch {}
|
||||
}
|
||||
errorMsg.value = msg
|
||||
return
|
||||
}
|
||||
successMsg.value = 'Your password has been reset. You may now sign in.'
|
||||
password.value = ''
|
||||
confirmPassword.value = ''
|
||||
submitAttempted.value = false // <-- add this line
|
||||
} catch {
|
||||
errorMsg.value = 'Network error. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function goToLogin() {
|
||||
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.edit-view) {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.reset-form {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--form-label, #444);
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group input[type='password'] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.6rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
font-size: 1rem;
|
||||
background: var(--form-input-bg, #fff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-link:disabled {
|
||||
text-decoration: none;
|
||||
cursor: default;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.reset-form {
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
369
frontend/vue-app/src/components/auth/Signup.vue
Normal file
369
frontend/vue-app/src/components/auth/Signup.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<div class="edit-view">
|
||||
<form
|
||||
v-if="!signupSuccess"
|
||||
@submit.prevent="submitForm"
|
||||
class="signup-form child-edit-view"
|
||||
novalidate
|
||||
>
|
||||
<h2>Sign up</h2>
|
||||
<div class="form-group">
|
||||
<label for="firstName">First name</label>
|
||||
<input
|
||||
v-model="firstName"
|
||||
id="firstName"
|
||||
type="text"
|
||||
autofocus
|
||||
autocomplete="given-name"
|
||||
required
|
||||
:class="{ 'input-error': submitAttempted && !firstName }"
|
||||
/>
|
||||
<small v-if="submitAttempted && !firstName" class="error-message" aria-live="polite"
|
||||
>First name is required.</small
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="lastName">Last name</label>
|
||||
<input
|
||||
v-model="lastName"
|
||||
id="lastName"
|
||||
autocomplete="family-name"
|
||||
type="text"
|
||||
required
|
||||
:class="{ 'input-error': submitAttempted && !lastName }"
|
||||
/>
|
||||
<small v-if="submitAttempted && !lastName" class="error-message" aria-live="polite"
|
||||
>Last name is required.</small
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email address</label>
|
||||
<input
|
||||
v-model="email"
|
||||
id="email"
|
||||
autocomplete="email"
|
||||
type="email"
|
||||
required
|
||||
:class="{ 'input-error': submitAttempted && (!email || !isEmailValid) }"
|
||||
/>
|
||||
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite"
|
||||
>Email is required.</small
|
||||
>
|
||||
<small v-else-if="submitAttempted && !isEmailValid" class="error-message"
|
||||
>Please enter a valid email address.</small
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
v-model="password"
|
||||
id="password"
|
||||
autocomplete="new-password"
|
||||
type="password"
|
||||
required
|
||||
@input="checkPasswordStrength"
|
||||
:class="{ 'input-error': (submitAttempted || passwordTouched) && !isPasswordStrong }"
|
||||
/>
|
||||
<small
|
||||
v-if="(submitAttempted || passwordTouched) && !isPasswordStrong"
|
||||
class="error-message"
|
||||
aria-live="polite"
|
||||
>Password must be at least 8 characters, include a number and a letter.</small
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm password</label>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
id="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
type="password"
|
||||
required
|
||||
:class="{ 'input-error': (submitAttempted || confirmTouched) && !passwordsMatch }"
|
||||
@blur="confirmTouched = true"
|
||||
/>
|
||||
<small
|
||||
v-if="(submitAttempted || confirmTouched) && !passwordsMatch"
|
||||
class="error-message"
|
||||
aria-live="polite"
|
||||
>Passwords do not match.</small
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 0.4rem">
|
||||
<button type="submit" class="form-btn" :disabled="!formValid || loading">Sign up</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Error and success messages -->
|
||||
<ErrorMessage v-if="signupError" :message="signupError" aria-live="polite" />
|
||||
|
||||
<!-- Modal for "Account already exists" -->
|
||||
<ModalDialog v-if="showEmailExistsModal">
|
||||
<h3>Account already exists</h3>
|
||||
<p>
|
||||
An account with <strong>{{ email }}</strong> already exists.
|
||||
</p>
|
||||
<div style="display: flex; gap: 2rem; justify-content: center">
|
||||
<button @click="goToLogin" class="form-btn">Sign In</button>
|
||||
<button @click="showEmailExistsModal = false" class="form-btn">Cancel</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
<!-- Verification card shown after successful signup -->
|
||||
<div v-else-if="signupSuccess">
|
||||
<div class="icon-wrap" aria-hidden="true">
|
||||
<!-- simple check icon -->
|
||||
<svg
|
||||
class="success-icon"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="card-title">Check your email</h2>
|
||||
<p class="card-message">
|
||||
A verification link has been sent to <strong>{{ email }}</strong
|
||||
>. Please open the email and follow the instructions to verify your account.
|
||||
</p>
|
||||
<div class="card-actions">
|
||||
<button class="form-btn" @click="goToLogin">Go to Sign In</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ErrorMessage from '@/components/shared/ErrorMessage.vue'
|
||||
import ModalDialog from '@/components/shared/ModalDialog.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isEmailValid, isPasswordStrong } from '@/common/api'
|
||||
import { EMAIL_EXISTS, MISSING_FIELDS } from '@/common/errorCodes'
|
||||
import '@/assets/view-shared.css'
|
||||
import '@/assets/actions-shared.css'
|
||||
import '@/assets/button-shared.css'
|
||||
import '@/assets/global.css'
|
||||
import '@/assets/edit-forms.css'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const passwordTouched = ref(false)
|
||||
const confirmTouched = ref(false)
|
||||
const submitAttempted = ref(false)
|
||||
const signupError = ref('')
|
||||
const signupSuccess = ref(false)
|
||||
const showEmailExistsModal = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
function checkPasswordStrength() {
|
||||
passwordTouched.value = true
|
||||
}
|
||||
|
||||
const isEmailValidRef = computed(() => isEmailValid(email.value))
|
||||
const isPasswordStrongRef = computed(() => isPasswordStrong(password.value))
|
||||
|
||||
const passwordsMatch = computed(() => password.value === confirmPassword.value)
|
||||
|
||||
const formValid = computed(
|
||||
() =>
|
||||
firstName.value &&
|
||||
lastName.value &&
|
||||
email.value &&
|
||||
isEmailValidRef.value &&
|
||||
isPasswordStrongRef.value &&
|
||||
passwordsMatch.value,
|
||||
)
|
||||
|
||||
async function submitForm() {
|
||||
submitAttempted.value = true
|
||||
passwordTouched.value = true
|
||||
confirmTouched.value = true
|
||||
signupError.value = ''
|
||||
signupSuccess.value = false
|
||||
showEmailExistsModal.value = false
|
||||
if (!formValid.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await fetch('/api/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
first_name: firstName.value.trim(),
|
||||
last_name: lastName.value.trim(),
|
||||
email: email.value.trim(),
|
||||
password: password.value,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const { msg, code } = await parseErrorResponse(response)
|
||||
let displayMsg = msg
|
||||
switch (code) {
|
||||
case MISSING_FIELDS:
|
||||
displayMsg = 'Please fill in all required fields.'
|
||||
clearFields()
|
||||
break
|
||||
case EMAIL_EXISTS:
|
||||
displayMsg = 'An account with this email already exists.'
|
||||
showEmailExistsModal.value = true
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
signupError.value = displayMsg
|
||||
return
|
||||
}
|
||||
// Signup successful
|
||||
signupSuccess.value = true
|
||||
clearFields()
|
||||
} catch (err) {
|
||||
signupError.value = 'Network error. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||
}
|
||||
|
||||
// Clear password fields and close modal
|
||||
function handleCancelEmailExists() {
|
||||
password.value = ''
|
||||
confirmPassword.value = ''
|
||||
showEmailExistsModal.value = false
|
||||
}
|
||||
|
||||
async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> {
|
||||
try {
|
||||
const data = await res.json()
|
||||
return { msg: data.error || data.message || 'Signup failed.', code: data.code }
|
||||
} catch {
|
||||
const text = await res.text()
|
||||
return { msg: text || 'Signup failed.' }
|
||||
}
|
||||
}
|
||||
|
||||
function clearFields() {
|
||||
firstName.value = ''
|
||||
lastName.value = ''
|
||||
email.value = ''
|
||||
password.value = ''
|
||||
confirmPassword.value = ''
|
||||
passwordTouched.value = false
|
||||
confirmTouched.value = false
|
||||
submitAttempted.value = false
|
||||
signupError.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.edit-view) {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.signup-form {
|
||||
/* keep the edit-view / child-edit-view look from edit-forms.css,
|
||||
only adjust inputs for email/password types */
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--btn-green, #22c55e), var(--btn-green-hover, #16a34a));
|
||||
box-shadow: 0 8px 20px rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.success-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
stroke: #fff;
|
||||
}
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--card-title, #333);
|
||||
}
|
||||
.card-message {
|
||||
margin-top: 0.6rem;
|
||||
color: var(--sub-message-color, #6b7280);
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.card-actions {
|
||||
margin-top: 1.2rem;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Reuse existing input / label styles */
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--form-label, #444);
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group input[type='email'],
|
||||
.form-group input[type='password'] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.6rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
font-size: 1rem;
|
||||
background: var(--form-input-bg, #fff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-dialog {
|
||||
background: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
316
frontend/vue-app/src/components/auth/VerifySignup.vue
Normal file
316
frontend/vue-app/src/components/auth/VerifySignup.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<div class="edit-view">
|
||||
<div class="verify-container">
|
||||
<h2 v-if="verifyingLoading">Verifying…</h2>
|
||||
|
||||
<div v-if="verified" class="success-message" aria-live="polite">
|
||||
Your account has been verified.
|
||||
<div class="meta">
|
||||
Redirecting to sign in in <strong>{{ countdown }}</strong> second<span
|
||||
v-if="countdown !== 1"
|
||||
>s</span
|
||||
>.
|
||||
</div>
|
||||
<div style="margin-top: 0.6rem">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
@click="goToLogin"
|
||||
style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-primary);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 6px;
|
||||
"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Error or success message at the top -->
|
||||
<div
|
||||
v-if="verifyError"
|
||||
class="error-message"
|
||||
aria-live="polite"
|
||||
style="margin-bottom: 1rem"
|
||||
>
|
||||
{{ verifyError }}
|
||||
</div>
|
||||
<div
|
||||
v-if="resendSuccess"
|
||||
class="success-message"
|
||||
aria-live="polite"
|
||||
style="margin-bottom: 1rem"
|
||||
>
|
||||
Verification email sent. Check your inbox.
|
||||
</div>
|
||||
|
||||
<!-- Email form and resend button -->
|
||||
<form @submit.prevent="handleResend" v-if="!sendingDialog">
|
||||
<label
|
||||
for="resend-email"
|
||||
style="display: block; font-weight: 600; margin-bottom: 0.25rem"
|
||||
>Email address</label
|
||||
>
|
||||
<input
|
||||
id="resend-email"
|
||||
v-model.trim="resendEmail"
|
||||
autofocus
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
:class="{ 'input-error': resendAttempted && !isResendEmailValid }"
|
||||
/>
|
||||
<small v-if="resendAttempted && !resendEmail" class="error-message" aria-live="polite"
|
||||
>Email is required.</small
|
||||
>
|
||||
<small
|
||||
v-else-if="resendAttempted && !isResendEmailValid"
|
||||
class="error-message"
|
||||
aria-live="polite"
|
||||
>Please enter a valid email address.</small
|
||||
>
|
||||
<div style="margin-top: 0.6rem">
|
||||
<button
|
||||
type="submit"
|
||||
class="form-btn"
|
||||
:disabled="!isResendEmailValid || resendLoading"
|
||||
>
|
||||
Resend verification email
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Sending dialog -->
|
||||
<div v-if="sendingDialog" class="sending-dialog">
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
style="
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="modal-dialog"
|
||||
style="
|
||||
background: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<h3 style="margin-bottom: 1rem">Sending Verification Email…</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 0.8rem">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
@click="goToLogin"
|
||||
style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-primary);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 6px;
|
||||
"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
MISSING_TOKEN,
|
||||
TOKEN_TIMESTAMP_MISSING,
|
||||
TOKEN_EXPIRED,
|
||||
INVALID_TOKEN,
|
||||
MISSING_EMAIL,
|
||||
USER_NOT_FOUND,
|
||||
ALREADY_VERIFIED,
|
||||
} from '@/common/errorCodes'
|
||||
import '@/assets/actions-shared.css'
|
||||
import '@/assets/button-shared.css'
|
||||
import { parseErrorResponse } from '@/common/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const verifyingLoading = ref(true)
|
||||
const verified = ref(false)
|
||||
const verifyError = ref('')
|
||||
const resendSuccess = ref(false)
|
||||
|
||||
const countdown = ref(10)
|
||||
let countdownTimer: number | null = null
|
||||
|
||||
// Resend state
|
||||
const resendEmail = ref<string>((route.query.email as string) ?? '')
|
||||
const resendAttempted = ref(false)
|
||||
const resendLoading = ref(false)
|
||||
const sendingDialog = ref(false)
|
||||
|
||||
const isResendEmailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(resendEmail.value))
|
||||
|
||||
async function verifyToken() {
|
||||
const raw = route.query.token ?? ''
|
||||
const token = Array.isArray(raw) ? raw[0] : String(raw || '')
|
||||
|
||||
if (!token) {
|
||||
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||
return
|
||||
}
|
||||
|
||||
verifyingLoading.value = true
|
||||
try {
|
||||
const url = `/api/verify?token=${encodeURIComponent(token)}`
|
||||
const res = await fetch(url, { method: 'GET' })
|
||||
|
||||
if (!res.ok) {
|
||||
const { msg, code } = await parseErrorResponse(res)
|
||||
switch (code) {
|
||||
case INVALID_TOKEN:
|
||||
case MISSING_TOKEN:
|
||||
case TOKEN_TIMESTAMP_MISSING:
|
||||
verifyError.value =
|
||||
"Your account isn't verified. Please request a new verification email."
|
||||
break
|
||||
case TOKEN_EXPIRED:
|
||||
verifyError.value =
|
||||
'Your verification link has expired. Please request a new verification email.'
|
||||
break
|
||||
default:
|
||||
verifyError.value = msg || `Verification failed with status ${res.status}.`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// success
|
||||
verified.value = true
|
||||
startRedirectCountdown()
|
||||
} catch {
|
||||
verifyError.value = 'Network error. Please try again.'
|
||||
} finally {
|
||||
verifyingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startRedirectCountdown() {
|
||||
countdown.value = 10
|
||||
countdownTimer = window.setInterval(() => {
|
||||
countdown.value -= 1
|
||||
if (countdown.value <= 0) {
|
||||
clearCountdown()
|
||||
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function clearCountdown() {
|
||||
if (countdownTimer !== null) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearCountdown()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
verifyToken()
|
||||
})
|
||||
|
||||
async function handleResend() {
|
||||
resendAttempted.value = true
|
||||
resendSuccess.value = false
|
||||
verifyError.value = ''
|
||||
if (!isResendEmailValid.value) return
|
||||
|
||||
sendingDialog.value = true
|
||||
resendLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/resend-verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: resendEmail.value.trim() }),
|
||||
})
|
||||
resendEmail.value = ''
|
||||
sendingDialog.value = false
|
||||
resendLoading.value = false
|
||||
|
||||
if (!res.ok) {
|
||||
const { msg, code } = await parseErrorResponse(res)
|
||||
switch (code) {
|
||||
case MISSING_EMAIL:
|
||||
verifyError.value = 'An email address is required.'
|
||||
break
|
||||
case USER_NOT_FOUND:
|
||||
verifyError.value = 'This email is not registered.'
|
||||
break
|
||||
case ALREADY_VERIFIED:
|
||||
verifyError.value = 'Your account is already verified. Please log in.'
|
||||
break
|
||||
default:
|
||||
verifyError.value = msg || `Resend failed with status ${res.status}.`
|
||||
}
|
||||
return
|
||||
}
|
||||
resendSuccess.value = true
|
||||
verifyError.value = ''
|
||||
} catch {
|
||||
verifyError.value = 'Network error. Please try again.'
|
||||
} finally {
|
||||
sendingDialog.value = false
|
||||
resendLoading.value = false
|
||||
resendAttempted.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.edit-view) {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.verify-container {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 0.5rem;
|
||||
color: var(--sub-message-color, #6b7280);
|
||||
}
|
||||
</style>
|
||||
142
frontend/vue-app/src/components/child/ChildDetailCard.vue
Normal file
142
frontend/vue-app/src/components/child/ChildDetailCard.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
image_id: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
child: Child | null
|
||||
}>()
|
||||
|
||||
const { child } = toRefs(props)
|
||||
const imageUrl = ref<string | null>(null)
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const fetchImage = async (imageId: string) => {
|
||||
try {
|
||||
const url = await getCachedImageUrl(imageId, imageCacheName)
|
||||
imageUrl.value = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching child image:', err)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => child.value?.image_id,
|
||||
(newImageId) => {
|
||||
if (newImageId) {
|
||||
fetchImage(newImageId)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Revoke created object URLs when component unmounts to avoid memory leaks
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="child" class="detail-card-horizontal">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Child Image" class="child-image" />
|
||||
<div class="main-info">
|
||||
<div class="child-name">{{ child.name }}</div>
|
||||
<div class="child-age">Age: {{ child.age }}</div>
|
||||
</div>
|
||||
<div class="points">
|
||||
<span class="label">Points</span>
|
||||
<span class="value">{{ child.points ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-card-horizontal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--detail-card-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--detail-card-shadow);
|
||||
padding: 0.7rem 1rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
box-sizing: border-box;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.child-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.child-name {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 600;
|
||||
color: var(--child-name-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.child-age {
|
||||
font-size: 0.97rem;
|
||||
color: var(--child-age-color);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
min-width: 54px;
|
||||
margin-left: 0.7rem;
|
||||
}
|
||||
|
||||
.points .label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--points-label-color);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.points .value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 900;
|
||||
color: var(--points-value-color);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.detail-card-horizontal {
|
||||
padding: 0.5rem 0.4rem;
|
||||
max-width: 98vw;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.child-image {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
.points {
|
||||
min-width: 38px;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
157
frontend/vue-app/src/components/child/ChildEditView.vue
Normal file
157
frontend/vue-app/src/components/child/ChildEditView.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="child-edit-view">
|
||||
<h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2>
|
||||
<div v-if="loading" class="loading-message">Loading child...</div>
|
||||
<form v-else @submit.prevent="submit" class="child-edit-form">
|
||||
<div class="group">
|
||||
<label for="child-name">Name</label>
|
||||
<input type="text" id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="child-age">Age</label>
|
||||
<input id="child-age" v-model.number="age" type="number" min="0" max="120" required />
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="child-image">Image</label>
|
||||
<ImagePicker
|
||||
id="child-image"
|
||||
v-model="selectedImageId"
|
||||
:image-type="1"
|
||||
@add-image="onAddImage"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import '@/assets/edit-forms.css'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// Accept id as a prop for edit mode
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const isEdit = computed(() => !!props.id)
|
||||
const name = ref('')
|
||||
const age = ref<number | null>(null)
|
||||
const selectedImageId = ref<string | null>(null)
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.id}`)
|
||||
if (!resp.ok) throw new Error('Failed to load child')
|
||||
const data = await resp.json()
|
||||
name.value = data.name ?? ''
|
||||
age.value = Number(data.age) ?? null
|
||||
selectedImageId.value = data.image_id ?? null
|
||||
} catch (e) {
|
||||
error.value = 'Could not load child.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
} else {
|
||||
await nextTick()
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
let imageId = selectedImageId.value
|
||||
error.value = null
|
||||
if (!name.value.trim()) {
|
||||
error.value = 'Child name is required.'
|
||||
return
|
||||
}
|
||||
if (age.value === null || age.value < 0) {
|
||||
error.value = 'Age must be a non-negative number.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
|
||||
// If the selected image is a local upload, upload it first
|
||||
if (imageId === 'local-upload' && localImageFile.value) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', localImageFile.value)
|
||||
formData.append('type', '1')
|
||||
formData.append('permanent', 'false')
|
||||
try {
|
||||
const resp = await fetch('/api/image/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId = data.id
|
||||
} catch (err) {
|
||||
alert('Failed to upload image.')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Now update or create the child
|
||||
try {
|
||||
let resp
|
||||
if (isEdit.value && props.id) {
|
||||
resp = await fetch(`/api/child/${props.id}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
age: age.value,
|
||||
image_id: imageId,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
resp = await fetch('/api/child/add', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
age: age.value,
|
||||
image_id: imageId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (!resp.ok) throw new Error('Failed to save child')
|
||||
await router.push({ name: 'ParentChildrenListView' })
|
||||
} catch (err) {
|
||||
alert('Failed to save child.')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
533
frontend/vue-app/src/components/child/ChildView.vue
Normal file
533
frontend/vue-app/src/components/child/ChildView.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import '@/assets/view-shared.css'
|
||||
import type {
|
||||
Child,
|
||||
Event,
|
||||
Task,
|
||||
Reward,
|
||||
RewardStatus,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
ChildRewardRequestEventPayload,
|
||||
ChildTasksSetEventPayload,
|
||||
ChildRewardsSetEventPayload,
|
||||
TaskModifiedEventPayload,
|
||||
RewardModifiedEventPayload,
|
||||
ChildModifiedEventPayload,
|
||||
} from '@/common/models'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const child = ref<Child | null>(null)
|
||||
const tasks = ref<string[]>([])
|
||||
const rewards = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const showRewardDialog = ref(false)
|
||||
const showCancelDialog = ref(false)
|
||||
const dialogReward = ref<Reward | null>(null)
|
||||
const childRewardListRef = ref()
|
||||
|
||||
function handleTaskTriggered(event: Event) {
|
||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardTriggered(event: Event) {
|
||||
const payload = event.payload as ChildRewardTriggeredEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildTaskSet(event: Event) {
|
||||
const payload = event.payload as ChildTasksSetEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
tasks.value = payload.task_ids
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildRewardSet(event: Event) {
|
||||
const payload = event.payload as ChildRewardsSetEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
rewards.value = payload.reward_ids
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardRequest(event: Event) {
|
||||
const payload = event.payload as ChildRewardRequestEventPayload
|
||||
const childId = payload.child_id
|
||||
const rewardId = payload.reward_id
|
||||
if (child.value && childId == child.value.id) {
|
||||
if (rewards.value.find((r) => r === rewardId)) {
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildModified(event: Event) {
|
||||
const payload = event.payload as ChildModifiedEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
switch (payload.operation) {
|
||||
case 'DELETE':
|
||||
// Navigate away back to children list
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
break
|
||||
|
||||
case 'ADD':
|
||||
// A new child was added, this shouldn't affect the current child view
|
||||
console.log('ADD operation received for child_modified, no action taken.')
|
||||
break
|
||||
|
||||
case 'EDIT':
|
||||
//our child was edited, refetch its data
|
||||
try {
|
||||
const dataPromise = fetchChildData(payload.child_id)
|
||||
dataPromise.then((data) => {
|
||||
if (data) {
|
||||
child.value = data
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
}
|
||||
break
|
||||
default:
|
||||
console.warn(`Unknown operation: ${payload.operation}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskModified(event: Event) {
|
||||
const payload = event.payload as TaskModifiedEventPayload
|
||||
if (child.value) {
|
||||
const task_id = payload.task_id
|
||||
if (tasks.value.includes(task_id)) {
|
||||
try {
|
||||
switch (payload.operation) {
|
||||
case 'DELETE':
|
||||
// Remove the task from the list
|
||||
tasks.value = tasks.value.filter((t) => t !== task_id)
|
||||
return // No need to refetch
|
||||
|
||||
case 'ADD':
|
||||
// A new task was added, this shouldn't affect the current task list
|
||||
console.log('ADD operation received for task_modified, no action taken.')
|
||||
return // No need to refetch
|
||||
|
||||
case 'EDIT':
|
||||
try {
|
||||
const dataPromise = fetchChildData(child.value.id)
|
||||
dataPromise.then((data) => {
|
||||
if (data) {
|
||||
tasks.value = data.tasks || []
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn(`Unknown operation: ${payload.operation}`)
|
||||
return // No need to refetch
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after task modification:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardModified(event: Event) {
|
||||
const payload = event.payload as RewardModifiedEventPayload
|
||||
if (child.value) {
|
||||
const reward_id = payload.reward_id
|
||||
if (rewards.value.includes(reward_id)) {
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggerTask = (task: Task) => {
|
||||
if ('speechSynthesis' in window && task.name) {
|
||||
const utter = new window.SpeechSynthesisUtterance(task.name)
|
||||
window.speechSynthesis.speak(utter)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerReward = (reward: RewardStatus) => {
|
||||
if ('speechSynthesis' in window && reward.name) {
|
||||
const utterString =
|
||||
reward.name +
|
||||
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
|
||||
const utter = new window.SpeechSynthesisUtterance(utterString)
|
||||
window.speechSynthesis.speak(utter)
|
||||
console.log('Reward data is:', reward)
|
||||
if (reward.redeeming) {
|
||||
dialogReward.value = reward
|
||||
showCancelDialog.value = true
|
||||
return // Do not allow redeeming if already pending
|
||||
}
|
||||
if (reward.points_needed <= 0) {
|
||||
dialogReward.value = reward
|
||||
showRewardDialog.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPendingReward() {
|
||||
if (!child.value?.id || !dialogReward.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to cancel pending reward')
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel pending reward:', err)
|
||||
} finally {
|
||||
showCancelDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRedeemReward() {
|
||||
showRewardDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
|
||||
function closeCancelDialog() {
|
||||
showCancelDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
|
||||
async function confirmRedeemReward() {
|
||||
if (!child.value?.id || !dialogReward.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/request-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
} catch (err) {
|
||||
console.error('Failed to redeem reward:', err)
|
||||
} finally {
|
||||
showRewardDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChildData(id: string | number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${id}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
error.value = null
|
||||
return data
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
return null
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
let inactivityTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function resetInactivityTimer() {
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||
inactivityTimer = setTimeout(() => {
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
}, 60000) // 60 seconds
|
||||
}
|
||||
|
||||
function setupInactivityListeners() {
|
||||
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart']
|
||||
events.forEach((evt) => window.addEventListener(evt, resetInactivityTimer))
|
||||
}
|
||||
|
||||
function removeInactivityListeners() {
|
||||
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart']
|
||||
events.forEach((evt) => window.removeEventListener(evt, resetInactivityTimer))
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||
}
|
||||
|
||||
const hasPendingRewards = computed(() =>
|
||||
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
eventBus.on('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.on('child_reward_triggered', handleRewardTriggered)
|
||||
eventBus.on('child_tasks_set', handleChildTaskSet)
|
||||
eventBus.on('child_rewards_set', handleChildRewardSet)
|
||||
eventBus.on('task_modified', handleTaskModified)
|
||||
eventBus.on('reward_modified', handleRewardModified)
|
||||
eventBus.on('child_modified', handleChildModified)
|
||||
eventBus.on('child_reward_request', handleRewardRequest)
|
||||
if (route.params.id) {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (idParam !== undefined) {
|
||||
const promise = fetchChildData(idParam)
|
||||
promise.then((data) => {
|
||||
if (data) {
|
||||
child.value = data
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
setupInactivityListeners()
|
||||
resetInactivityTimer()
|
||||
} catch (err) {
|
||||
console.error('Error in onMounted:', err)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
||||
eventBus.off('child_tasks_set', handleChildTaskSet)
|
||||
eventBus.off('child_rewards_set', handleChildRewardSet)
|
||||
eventBus.off('task_modified', handleTaskModified)
|
||||
eventBus.off('reward_modified', handleRewardModified)
|
||||
eventBus.off('child_modified', handleChildModified)
|
||||
eventBus.off('child_reward_request', handleRewardRequest)
|
||||
removeInactivityListeners()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
|
||||
<div v-else class="layout">
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ScrollingList
|
||||
title="Chores"
|
||||
ref="childChoreListRef"
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Penalties"
|
||||
ref="childPenaltyListRef"
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return !item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Rewards"
|
||||
ref="childRewardListRef"
|
||||
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
||||
itemKey="reward_status"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerReward"
|
||||
:getItemClass="
|
||||
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
|
||||
"
|
||||
>
|
||||
<template #item="{ item }: { item: RewardStatus }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
alt="Reward Image"
|
||||
class="item-image"
|
||||
/>
|
||||
<div class="item-points">
|
||||
<span v-if="item.redeeming" class="pending">PENDING</span>
|
||||
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
|
||||
<span v-else>{{ item.points_needed }} more points</span>
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showRewardDialog && dialogReward" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="reward-info">
|
||||
<img
|
||||
v-if="dialogReward.image_url"
|
||||
:src="dialogReward.image_url"
|
||||
alt="Reward Image"
|
||||
class="reward-image"
|
||||
/>
|
||||
<div class="reward-details">
|
||||
<div class="reward-name">{{ dialogReward.name }}</div>
|
||||
<div class="reward-points">{{ dialogReward.cost }} pts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
Would you like to redeem this reward?
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="confirmRedeemReward" class="btn btn-primary">Yes</button>
|
||||
<button @click="cancelRedeemReward" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showCancelDialog && dialogReward" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="reward-info">
|
||||
<img
|
||||
v-if="dialogReward.image_url"
|
||||
:src="dialogReward.image_url"
|
||||
alt="Reward Image"
|
||||
class="reward-image"
|
||||
/>
|
||||
<div class="reward-details">
|
||||
<div class="reward-name">{{ dialogReward.name }}</div>
|
||||
<div class="reward-points">{{ dialogReward.cost }} pts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
This reward is pending.<br />
|
||||
Would you like to cancel the pending reward request?
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
|
||||
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assign-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: var(--back-btn-bg);
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--back-btn-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-points {
|
||||
color: var(--item-points-color, #ffd166);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
text-shadow: var(--item-points-shadow);
|
||||
}
|
||||
.ready {
|
||||
color: var(--item-points-ready-color, #38c172);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.pending {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80%;
|
||||
background: var(--pending-block-bg, #222b);
|
||||
color: var(--pending-block-color, #62ff7a);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0;
|
||||
letter-spacing: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.item-points {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.good) {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
}
|
||||
:deep(.bad) {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
:deep(.reward) {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
:deep(.disabled) {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
filter: grayscale(0.7);
|
||||
}
|
||||
</style>
|
||||
600
frontend/vue-app/src/components/child/ParentView.vue
Normal file
600
frontend/vue-app/src/components/child/ParentView.vue
Normal file
@@ -0,0 +1,600 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import '@/assets/view-shared.css'
|
||||
import type {
|
||||
Task,
|
||||
Child,
|
||||
Event,
|
||||
Reward,
|
||||
RewardStatus,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
ChildRewardRequestEventPayload,
|
||||
ChildTasksSetEventPayload,
|
||||
ChildRewardsSetEventPayload,
|
||||
ChildModifiedEventPayload,
|
||||
TaskModifiedEventPayload,
|
||||
RewardModifiedEventPayload,
|
||||
} from '@/common/models'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const child = ref<Child | null>(null)
|
||||
const tasks = ref<string[]>([])
|
||||
const rewards = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const showConfirm = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
const showRewardConfirm = ref(false)
|
||||
const selectedReward = ref<Reward | null>(null)
|
||||
const childRewardListRef = ref()
|
||||
const showPendingRewardDialog = ref(false)
|
||||
|
||||
function handleTaskTriggered(event: Event) {
|
||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardTriggered(event: Event) {
|
||||
const payload = event.payload as ChildRewardTriggeredEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildTaskSet(event: Event) {
|
||||
const payload = event.payload as ChildTasksSetEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
tasks.value = payload.task_ids
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildRewardSet(event: Event) {
|
||||
const payload = event.payload as ChildRewardsSetEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
rewards.value = payload.reward_ids
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardRequest(event: Event) {
|
||||
const payload = event.payload as ChildRewardRequestEventPayload
|
||||
const childId = payload.child_id
|
||||
const rewardId = payload.reward_id
|
||||
if (child.value && childId == child.value.id) {
|
||||
if (rewards.value.find((r) => r === rewardId)) {
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskModified(event: Event) {
|
||||
const payload = event.payload as TaskModifiedEventPayload
|
||||
if (child.value) {
|
||||
const task_id = payload.task_id
|
||||
if (tasks.value.includes(task_id)) {
|
||||
try {
|
||||
switch (payload.operation) {
|
||||
case 'DELETE':
|
||||
// Remove the task from the list
|
||||
tasks.value = tasks.value.filter((t) => t !== task_id)
|
||||
return // No need to refetch
|
||||
|
||||
case 'ADD':
|
||||
// A new task was added, this shouldn't affect the current task list
|
||||
console.log('ADD operation received for task_modified, no action taken.')
|
||||
return // No need to refetch
|
||||
|
||||
case 'EDIT':
|
||||
try {
|
||||
const dataPromise = fetchChildData(child.value.id)
|
||||
dataPromise.then((data) => {
|
||||
if (data) {
|
||||
tasks.value = data.tasks || []
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn(`Unknown operation: ${payload.operation}`)
|
||||
return // No need to refetch
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after task modification:', err)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardModified(event: Event) {
|
||||
const payload = event.payload as RewardModifiedEventPayload
|
||||
if (child.value) {
|
||||
const reward_id = payload.reward_id
|
||||
if (rewards.value.includes(reward_id)) {
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildModified(event: Event) {
|
||||
const payload = event.payload as ChildModifiedEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
switch (payload.operation) {
|
||||
case 'DELETE':
|
||||
// Navigate away back to children list
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
break
|
||||
|
||||
case 'ADD':
|
||||
// A new child was added, this shouldn't affect the current child view
|
||||
console.log('ADD operation received for child_modified, no action taken.')
|
||||
break
|
||||
|
||||
case 'EDIT':
|
||||
//our child was edited, refetch its data
|
||||
try {
|
||||
const dataPromise = fetchChildData(payload.child_id)
|
||||
dataPromise.then((data) => {
|
||||
if (data) {
|
||||
child.value = data
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
loading.value = false
|
||||
}
|
||||
break
|
||||
default:
|
||||
console.warn(`Unknown operation: ${payload.operation}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChildData(id: string | number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${id}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
error.value = null
|
||||
return data
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
return null
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
eventBus.on('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.on('child_reward_triggered', handleRewardTriggered)
|
||||
eventBus.on('child_tasks_set', handleChildTaskSet)
|
||||
eventBus.on('child_rewards_set', handleChildRewardSet)
|
||||
eventBus.on('task_modified', handleTaskModified)
|
||||
eventBus.on('reward_modified', handleRewardModified)
|
||||
eventBus.on('child_modified', handleChildModified)
|
||||
eventBus.on('child_reward_request', handleRewardRequest)
|
||||
|
||||
if (route.params.id) {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (idParam !== undefined) {
|
||||
const promise = fetchChildData(idParam)
|
||||
promise.then((data) => {
|
||||
if (data) {
|
||||
child.value = data
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in onMounted:', err)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
||||
eventBus.off('child_tasks_set', handleChildTaskSet)
|
||||
eventBus.off('child_rewards_set', handleChildRewardSet)
|
||||
eventBus.off('child_modified', handleChildModified)
|
||||
eventBus.off('child_reward_request', handleRewardRequest)
|
||||
eventBus.off('task_modified', handleTaskModified)
|
||||
eventBus.off('reward_modified', handleRewardModified)
|
||||
})
|
||||
|
||||
function getPendingRewardIds(): string[] {
|
||||
const items = childRewardListRef.value?.items || []
|
||||
return items.filter((item: RewardStatus) => item.redeeming).map((item: RewardStatus) => item.id)
|
||||
}
|
||||
|
||||
const triggerTask = (task: Task) => {
|
||||
selectedTask.value = task
|
||||
const pendingRewardIds = getPendingRewardIds()
|
||||
console.log('Pending reward IDs:', pendingRewardIds)
|
||||
if (pendingRewardIds.length > 0) {
|
||||
showPendingRewardDialog.value = true
|
||||
return
|
||||
}
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
async function cancelRewardById(rewardId: string) {
|
||||
if (!child.value?.id) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: rewardId }),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to cancel reward ID ${rewardId}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPendingReward() {
|
||||
if (!child.value?.id) {
|
||||
showPendingRewardDialog.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const pendingRewardIds = getPendingRewardIds()
|
||||
await Promise.all(pendingRewardIds.map((id: string) => cancelRewardById(id)))
|
||||
childRewardListRef.value?.refresh()
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel pending reward:', err)
|
||||
} finally {
|
||||
showPendingRewardDialog.value = false
|
||||
// After cancelling, proceed to trigger the task if one was selected
|
||||
if (selectedTask.value) {
|
||||
showConfirm.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const confirmTriggerTask = async () => {
|
||||
if (!child.value?.id || !selectedTask.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: selectedTask.value.id }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
if (child.value && child.value.id === data.id) child.value.points = data.points
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger task:', err)
|
||||
} finally {
|
||||
showConfirm.value = false
|
||||
selectedTask.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const triggerReward = (reward: RewardStatus) => {
|
||||
if (reward.points_needed > 0) return
|
||||
selectedReward.value = reward
|
||||
showRewardConfirm.value = true
|
||||
}
|
||||
|
||||
const confirmTriggerReward = async () => {
|
||||
if (!child.value?.id || !selectedReward.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/trigger-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: selectedReward.value.id }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
if (child.value && child.value.id === data.id) child.value.points = data.points
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger reward:', err)
|
||||
} finally {
|
||||
showRewardConfirm.value = false
|
||||
selectedReward.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function goToAssignTasks() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'good' } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToAssignBadHabits() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'bad' } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToAssignRewards() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'RewardAssignView', params: { id: child.value.id } })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
|
||||
<div v-else class="layout">
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ScrollingList
|
||||
title="Chores"
|
||||
ref="childChoreListRef"
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Penalties"
|
||||
ref="childPenaltyListRef"
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return !item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Rewards"
|
||||
ref="childRewardListRef"
|
||||
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
||||
itemKey="reward_status"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerReward"
|
||||
:getItemClass="(item) => ({ reward: true })"
|
||||
>
|
||||
<template #item="{ item }: { item: RewardStatus }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
alt="Reward Image"
|
||||
class="item-image"
|
||||
/>
|
||||
<div class="item-points">
|
||||
<span v-if="item.redeeming" class="pending">PENDING</span>
|
||||
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
|
||||
<span v-else>{{ item.points_needed }} more points</span>
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assign-buttons">
|
||||
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Tasks</button>
|
||||
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
||||
Assign Penalties
|
||||
</button>
|
||||
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
|
||||
</div>
|
||||
|
||||
<!-- Pending Reward Dialog -->
|
||||
<div v-if="showPendingRewardDialog" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
There is a pending reward request. The reward must be cancelled before triggering a new
|
||||
task.<br />
|
||||
Would you like to cancel the pending reward?
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
|
||||
<button @click="showPendingRewardDialog = false" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showConfirm && selectedTask" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="task-info">
|
||||
<img
|
||||
v-if="selectedTask.image_url"
|
||||
:src="selectedTask.image_url"
|
||||
alt="Task Image"
|
||||
class="task-image"
|
||||
/>
|
||||
<div class="task-details">
|
||||
<div class="task-name">{{ selectedTask.name }}</div>
|
||||
<div class="task-points" :class="selectedTask.is_good ? 'good' : 'bad'">
|
||||
{{ selectedTask.points }} points
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
{{ selectedTask.is_good ? 'Add' : 'Subtract' }} these points
|
||||
{{ selectedTask.is_good ? 'to' : 'from' }}
|
||||
<span class="child-name">{{ child?.name }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="confirmTriggerTask" class="btn btn-primary">Yes</button>
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
showConfirm = false
|
||||
selectedTask = null
|
||||
}
|
||||
"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showRewardConfirm && selectedReward" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="reward-info">
|
||||
<img
|
||||
v-if="selectedReward.image_url"
|
||||
:src="selectedReward.image_url"
|
||||
alt="Reward Image"
|
||||
class="reward-image"
|
||||
/>
|
||||
<div class="reward-details">
|
||||
<div class="reward-name">{{ selectedReward.name }}</div>
|
||||
<div class="reward-points">
|
||||
{{
|
||||
selectedReward.points_needed === 0
|
||||
? 'Reward Ready!'
|
||||
: selectedReward.points_needed + ' more points'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
Redeem this reward for <span class="child-name">{{ child?.name }}</span
|
||||
>?
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="confirmTriggerReward" class="btn btn-primary">Yes</button>
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
showRewardConfirm = false
|
||||
selectedReward = null
|
||||
}
|
||||
"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assign-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: var(--back-btn-bg);
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--back-btn-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-points {
|
||||
color: var(--item-points-color, #ffd166);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
text-shadow: var(--item-points-shadow);
|
||||
}
|
||||
|
||||
.ready {
|
||||
color: var(--item-points-ready-color, #38c172);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.pending {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80%;
|
||||
background: var(--pending-block-bg, #222b);
|
||||
color: var(--pending-block-color, #62ff7a);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0;
|
||||
letter-spacing: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.item-points {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.good) {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
}
|
||||
:deep(.bad) {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
:deep(.reward) {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
</style>
|
||||
118
frontend/vue-app/src/components/child/RewardAssignView.vue
Normal file
118
frontend/vue-app/src/components/child/RewardAssignView.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="reward-assign-view">
|
||||
<h2>Assign Rewards</h2>
|
||||
<div class="reward-view">
|
||||
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
|
||||
<span> <button class="round-btn" @click="goToCreateReward">Create</button> a reward </span>
|
||||
</MessageBlock>
|
||||
<ItemList
|
||||
v-else
|
||||
ref="rewardListRef"
|
||||
:fetchUrl="`/api/child/${childId}/list-all-rewards`"
|
||||
itemKey="rewards"
|
||||
:itemFields="REWARD_FIELDS"
|
||||
imageField="image_id"
|
||||
selectable
|
||||
@loading-complete="(count) => (rewardCountRef = count)"
|
||||
:getItemClass="(item) => `reward`"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<img v-if="item.image_url" :src="item.image_url" />
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="value">{{ item.cost }} pts</span>
|
||||
</template>
|
||||
</ItemList>
|
||||
</div>
|
||||
<div class="actions" v-if="rewardCountRef != 0">
|
||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import '@/assets/actions-shared.css'
|
||||
import { REWARD_FIELDS } from '@/common/models'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const childId = route.params.id
|
||||
|
||||
const rewardListRef = ref()
|
||||
const rewardCountRef = ref(-1)
|
||||
|
||||
function goToCreateReward() {
|
||||
router.push({ name: 'CreateReward' })
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const selectedIds = rewardListRef.value?.selectedItems ?? []
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${childId}/set-rewards`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_ids: selectedIds }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to update rewards')
|
||||
router.back()
|
||||
} catch (err) {
|
||||
alert('Failed to update rewards.')
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reward-assign-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.reward-assign-view h2 {
|
||||
font-size: 1.15rem;
|
||||
color: var(--assign-heading-color);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
|
||||
.reward-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.reward) {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
</style>
|
||||
129
frontend/vue-app/src/components/child/TaskAssignView.vue
Normal file
129
frontend/vue-app/src/components/child/TaskAssignView.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="task-assign-view">
|
||||
<h2>Assign Tasks</h2>
|
||||
<div class="task-view">
|
||||
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
|
||||
<span> <button class="round-btn" @click="goToCreateTask">Create</button> a task </span>
|
||||
</MessageBlock>
|
||||
<ItemList
|
||||
v-else
|
||||
ref="taskListRef"
|
||||
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=${typeFilter}`"
|
||||
itemKey="tasks"
|
||||
:itemFields="TASK_FIELDS"
|
||||
imageField="image_id"
|
||||
selectable
|
||||
@loading-complete="(count) => (taskCountRef = count)"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<img v-if="item.image_url" :src="item.image_url" />
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="value">{{ item.points }} pts</span>
|
||||
</template>
|
||||
</ItemList>
|
||||
</div>
|
||||
<div class="actions" v-if="taskCountRef > 0">
|
||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import '@/assets/actions-shared.css'
|
||||
import { TASK_FIELDS } from '@/common/models'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const childId = route.params.id
|
||||
|
||||
const taskListRef = ref()
|
||||
const taskCountRef = ref(-1)
|
||||
|
||||
const typeFilter = computed(() => {
|
||||
if (route.params.type === 'good') return 'good'
|
||||
if (route.params.type === 'bad') return 'bad'
|
||||
return 'all'
|
||||
})
|
||||
|
||||
function goToCreateTask() {
|
||||
router.push({ name: 'CreateTask' })
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const selectedIds = taskListRef.value?.selectedItems ?? []
|
||||
try {
|
||||
console.log('selectedIds:', selectedIds)
|
||||
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: typeFilter.value, task_ids: selectedIds }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to update tasks')
|
||||
router.back()
|
||||
} catch (err) {
|
||||
alert('Failed to update tasks.')
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-assign-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.task-assign-view h2 {
|
||||
font-size: 1.15rem;
|
||||
color: var(--assign-heading-color);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
|
||||
.task-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.good) {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
}
|
||||
:deep(.bad) {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="notification-view">
|
||||
<ItemList
|
||||
:fetchUrl="`/api/pending-rewards`"
|
||||
itemKey="rewards"
|
||||
:itemFields="PENDING_REWARD_FIELDS"
|
||||
:imageFields="['child_image_id', 'reward_image_id']"
|
||||
@clicked="handleNotificationClick"
|
||||
@loading-complete="(count) => (notificationListCountRef = count)"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="notification-centered">
|
||||
<div class="child-info">
|
||||
<img v-if="item.child_image_url" :src="item.child_image_url" alt="Child" />
|
||||
<span>{{ item.child_name }}</span>
|
||||
</div>
|
||||
<span class="requested-text">requested</span>
|
||||
<div class="reward-info">
|
||||
<span>{{ item.reward_name }}</span>
|
||||
<img v-if="item.reward_image_url" :src="item.reward_image_url" alt="Reward" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ItemList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import type { PendingReward } from '@/common/models'
|
||||
import { PENDING_REWARD_FIELDS } from '@/common/models'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const notificationListCountRef = ref(-1)
|
||||
|
||||
function handleNotificationClick(item: PendingReward) {
|
||||
router.push({ name: 'ParentView', params: { id: item.child_id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.notification-centered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-inline: auto;
|
||||
align-items: center;
|
||||
}
|
||||
.child-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--dialog-child-name);
|
||||
}
|
||||
|
||||
.reward-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0rem;
|
||||
font-weight: 600;
|
||||
color: var(--notification-reward-name);
|
||||
}
|
||||
|
||||
.requested-text {
|
||||
margin: 0 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--dialog-message);
|
||||
font-size: 1.05rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
169
frontend/vue-app/src/components/profile/UserProfile.vue
Normal file
169
frontend/vue-app/src/components/profile/UserProfile.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="profile-view">
|
||||
<h2>User Profile</h2>
|
||||
<form class="profile-form" @submit.prevent>
|
||||
<div class="group">
|
||||
<label for="child-image">Image</label>
|
||||
<ImagePicker
|
||||
id="child-image"
|
||||
v-model="selectedImageId"
|
||||
:image-type="1"
|
||||
@add-image="onAddImage"
|
||||
/>
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="first-name">First Name</label>
|
||||
<input id="first-name" v-model="firstName" type="text" disabled />
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="last-name">Last Name</label>
|
||||
<input id="last-name" v-model="lastName" type="text" disabled />
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="email">Email Address</label>
|
||||
<input id="email" v-model="email" type="email" disabled />
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn-link" @click="resetPassword" :disabled="resetting">
|
||||
{{ resetting ? 'Sending...' : 'Reset Password' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="successMsg" class="success-message" aria-live="polite">{{ successMsg }}</div>
|
||||
<div v-if="errorMsg" class="error-message" aria-live="polite">{{ errorMsg }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
import '@/assets/edit-forms.css'
|
||||
import '@/assets/actions-shared.css'
|
||||
import '@/assets/button-shared.css'
|
||||
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const email = ref('')
|
||||
const avatarId = ref<string | null>(null)
|
||||
const avatarUrl = ref('/static/avatar-default.png')
|
||||
const selectedImageId = ref<string | null>(null)
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const errorMsg = ref('')
|
||||
const successMsg = ref('')
|
||||
const resetting = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/user/profile')
|
||||
if (!res.ok) throw new Error('Failed to load profile')
|
||||
const data = await res.json()
|
||||
firstName.value = data.first_name || ''
|
||||
lastName.value = data.last_name || ''
|
||||
email.value = data.email || ''
|
||||
avatarId.value = data.image_id || null
|
||||
selectedImageId.value = data.image_id || null
|
||||
|
||||
// Use imageCache to get avatar URL
|
||||
if (avatarId.value) {
|
||||
avatarUrl.value = await getCachedImageUrl(avatarId.value)
|
||||
} else {
|
||||
avatarUrl.value = '/static/avatar-default.png'
|
||||
}
|
||||
} catch {
|
||||
errorMsg.value = 'Could not load user profile.'
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for avatarId changes (e.g., after updating avatar)
|
||||
watch(avatarId, async (id) => {
|
||||
if (id) {
|
||||
avatarUrl.value = await getCachedImageUrl(id)
|
||||
} else {
|
||||
avatarUrl.value = '/static/avatar-default.png'
|
||||
}
|
||||
})
|
||||
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
} else {
|
||||
localImageFile.value = null
|
||||
selectedImageId.value = id
|
||||
updateAvatar(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAvatar(imageId: string) {
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/user/avatar', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_id: imageId }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update avatar')
|
||||
// Update avatarId, which will trigger the watcher to update avatarUrl
|
||||
avatarId.value = imageId
|
||||
successMsg.value = 'Avatar updated!'
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to update avatar.'
|
||||
}
|
||||
}
|
||||
|
||||
// If uploading a new image file
|
||||
watch(localImageFile, async (file) => {
|
||||
if (!file) return
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', '2')
|
||||
formData.append('permanent', 'true')
|
||||
try {
|
||||
const resp = await fetch('/api/image/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
selectedImageId.value = data.id
|
||||
await updateAvatar(data.id)
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to upload avatar image.'
|
||||
}
|
||||
})
|
||||
|
||||
async function resetPassword() {
|
||||
resetting.value = true
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/request-password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.value }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to send reset email')
|
||||
successMsg.value =
|
||||
'If this email is registered, you will receive a password reset link shortly.'
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to send password reset email.'
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.success-message {
|
||||
color: var(--success, #16a34a);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.error-message {
|
||||
color: var(--error, #e53e3e);
|
||||
font-size: 0.98rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
180
frontend/vue-app/src/components/reward/ChildRewardList.vue
Normal file
180
frontend/vue-app/src/components/reward/ChildRewardList.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, watch, nextTick, computed } from 'vue'
|
||||
import { defineProps, defineEmits, defineExpose } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
import type { RewardStatus } from '@/common/models'
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const props = defineProps<{
|
||||
childId: string | null
|
||||
childPoints: number
|
||||
isParentAuthenticated: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['trigger-reward'])
|
||||
|
||||
const rewards = ref<RewardStatus[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const scrollWrapper = ref<HTMLDivElement | null>(null)
|
||||
const rewardRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
const lastCenteredRewardId = ref<string | null>(null)
|
||||
const readyRewardId = ref<string | null>(null)
|
||||
|
||||
const fetchRewards = async (id: string | number | null) => {
|
||||
if (!id) {
|
||||
rewards.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${id}/reward-status`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
rewards.value = data.reward_status
|
||||
|
||||
// Fetch images for each reward using shared utility
|
||||
await Promise.all(rewards.value.map(fetchImage))
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
|
||||
console.error('Error fetching rewards:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchImage = async (reward: RewardStatus) => {
|
||||
if (!reward.image_id) {
|
||||
console.log(`No image ID for reward: ${reward.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await getCachedImageUrl(reward.image_id, imageCacheName)
|
||||
reward.image_id = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching image for reward', reward.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
const centerReward = async (rewardId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = rewardRefs.value[rewardId]
|
||||
if (wrapper && card) {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const wrapperScrollLeft = wrapper.scrollLeft
|
||||
const cardCenter = cardRect.left + cardRect.width / 2
|
||||
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
|
||||
const scrollOffset = cardCenter - wrapperCenter
|
||||
wrapper.scrollTo({
|
||||
left: wrapperScrollLeft + scrollOffset,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRewardClick = async (rewardId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = rewardRefs.value[rewardId]
|
||||
if (!wrapper || !card) return
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const cardCenter = cardRect.left + cardRect.width / 2
|
||||
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
|
||||
|
||||
if (!cardFullyVisible || lastCenteredRewardId.value !== rewardId) {
|
||||
// Center the reward, but don't trigger
|
||||
await centerReward(rewardId)
|
||||
lastCenteredRewardId.value = rewardId
|
||||
readyRewardId.value = rewardId
|
||||
return
|
||||
}
|
||||
|
||||
// If already centered and visible, trigger the reward
|
||||
await triggerReward(rewardId)
|
||||
readyRewardId.value = null
|
||||
}
|
||||
|
||||
const triggerReward = (rewardId: string) => {
|
||||
const reward = rewards.value.find((rew) => rew.id === rewardId)
|
||||
if (!reward) return // Don't trigger if not allowed
|
||||
emit('trigger-reward', reward, reward.points_needed <= 0, reward.redeeming)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.childId,
|
||||
(newId) => {
|
||||
fetchRewards(newId)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.childPoints,
|
||||
() => {
|
||||
rewards.value.forEach((reward) => {
|
||||
reward.points_needed = Math.max(0, reward.cost - props.childPoints)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
function getPendingRewards(): string[] {
|
||||
return rewards.value.filter((r) => r.redeeming).map((r) => r.id)
|
||||
}
|
||||
|
||||
// revoke created object URLs when component unmounts to avoid memory leaks
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
|
||||
// expose refresh method for parent component
|
||||
defineExpose({ refresh: () => fetchRewards(props.childId), getPendingRewards })
|
||||
|
||||
const isAnyPending = computed(() => rewards.value.some((r) => r.redeeming))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="child-list-container">
|
||||
<h3>Rewards</h3>
|
||||
|
||||
<div v-if="loading" class="loading">Loading rewards...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
<div v-else-if="rewards.length === 0" class="empty">No rewards available</div>
|
||||
|
||||
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
||||
<div class="item-scroll">
|
||||
<div
|
||||
v-for="r in rewards"
|
||||
:key="r.id"
|
||||
class="item-card"
|
||||
:class="{
|
||||
ready: readyRewardId === r.id,
|
||||
disabled: isAnyPending && !r.redeeming,
|
||||
}"
|
||||
:ref="(el) => (rewardRefs[r.id] = el)"
|
||||
@click="() => handleRewardClick(r.id)"
|
||||
>
|
||||
<div class="item-name">{{ r.name }}</div>
|
||||
<img v-if="r.image_id" :src="r.image_id" alt="Reward Image" class="item-image" />
|
||||
<div class="item-points" :class="{ ready: r.points_needed === 0 }">
|
||||
<template v-if="r.points_needed === 0"> REWARD READY </template>
|
||||
<template v-else> {{ r.points_needed }} more points </template>
|
||||
</div>
|
||||
<!-- PENDING block if redeeming is true -->
|
||||
<div v-if="r.redeeming" class="pending-block">PENDING</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
169
frontend/vue-app/src/components/reward/RewardEditView.vue
Normal file
169
frontend/vue-app/src/components/reward/RewardEditView.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="reward-edit-view">
|
||||
<h2>{{ isEdit ? 'Edit Reward' : 'Create Reward' }}</h2>
|
||||
<div v-if="loading" class="loading-message">Loading reward...</div>
|
||||
<form v-else @submit.prevent="submit" class="reward-form">
|
||||
<div class="group">
|
||||
<label>
|
||||
Reward Name
|
||||
<input ref="nameInput" v-model="name" type="text" required maxlength="64" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="group">
|
||||
<label>
|
||||
Description
|
||||
<input v-model="description" type="text" maxlength="128" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="group">
|
||||
<label>
|
||||
Cost
|
||||
<input v-model.number="cost" type="number" min="1" max="1000" required />
|
||||
</label>
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="reward-image">Image</label>
|
||||
<ImagePicker
|
||||
id="reward-image"
|
||||
v-model="selectedImageId"
|
||||
:image-type="2"
|
||||
@add-image="onAddImage"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div class="actions">
|
||||
<button type="button" @click="handleCancel" :disabled="loading" class="btn btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="loading" class="btn btn-primary">
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import '@/assets/edit-forms.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isEdit = computed(() => !!props.id)
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const cost = ref(1)
|
||||
const selectedImageId = ref<string | null>(null)
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/reward/${props.id}`)
|
||||
if (!resp.ok) throw new Error('Failed to load reward')
|
||||
const data = await resp.json()
|
||||
name.value = data.name
|
||||
description.value = data.description ?? ''
|
||||
cost.value = Number(data.cost) || 1
|
||||
selectedImageId.value = data.image_id ?? null
|
||||
} catch (e) {
|
||||
error.value = 'Could not load reward.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
} else {
|
||||
await nextTick()
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
let imageId = selectedImageId.value
|
||||
error.value = null
|
||||
if (!name.value.trim()) {
|
||||
error.value = 'Reward name is required.'
|
||||
return
|
||||
}
|
||||
if (cost.value < 1) {
|
||||
error.value = 'Cost must be at least 1.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
|
||||
// If the selected image is a local upload, upload it first
|
||||
if (imageId === 'local-upload' && localImageFile.value) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', localImageFile.value)
|
||||
formData.append('type', '2')
|
||||
formData.append('permanent', 'false')
|
||||
try {
|
||||
const resp = await fetch('/api/image/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId = data.id
|
||||
} catch (err) {
|
||||
alert('Failed to upload image.')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Now update or create the reward
|
||||
try {
|
||||
let resp
|
||||
if (isEdit.value && props.id) {
|
||||
resp = await fetch(`/api/reward/${props.id}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
cost: cost.value,
|
||||
image_id: imageId,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
resp = await fetch('/api/reward/add', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
cost: cost.value,
|
||||
image_id: imageId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (!resp.ok) throw new Error('Failed to save reward')
|
||||
await router.push({ name: 'RewardView' })
|
||||
} catch (err) {
|
||||
alert('Failed to save reward.')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
112
frontend/vue-app/src/components/reward/RewardView.vue
Normal file
112
frontend/vue-app/src/components/reward/RewardView.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="reward-view">
|
||||
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
|
||||
<span> <button class="round-btn" @click="createReward">Create</button> a reward </span>
|
||||
</MessageBlock>
|
||||
|
||||
<ItemList
|
||||
v-else
|
||||
fetchUrl="/api/reward/list"
|
||||
itemKey="rewards"
|
||||
:itemFields="REWARD_FIELDS"
|
||||
imageField="image_id"
|
||||
deletable
|
||||
@clicked="(reward: Reward) => $router.push({ name: 'EditReward', params: { id: reward.id } })"
|
||||
@delete="confirmDeleteReward"
|
||||
@loading-complete="(count) => (rewardCountRef = count)"
|
||||
:getItemClass="(item) => `reward`"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<img v-if="item.image_url" :src="item.image_url" />
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="value">{{ item.cost }} pts</span>
|
||||
</template>
|
||||
</ItemList>
|
||||
|
||||
<FloatingActionButton aria-label="Create Reward" @click="createReward" />
|
||||
|
||||
<DeleteModal
|
||||
:show="showConfirm"
|
||||
message="Are you sure you want to delete this reward?"
|
||||
@confirm="deleteReward"
|
||||
@cancel="showConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
import '@/assets/button-shared.css'
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import DeleteModal from '../shared/DeleteModal.vue'
|
||||
import type { Reward } from '@/common/models'
|
||||
import { REWARD_FIELDS } from '@/common/models'
|
||||
|
||||
import '@/assets/view-shared.css'
|
||||
|
||||
const $router = useRouter()
|
||||
|
||||
const showConfirm = ref(false)
|
||||
const rewardToDelete = ref<string | null>(null)
|
||||
const rewardListRef = ref()
|
||||
const rewardCountRef = ref<number>(-1)
|
||||
|
||||
function confirmDeleteReward(rewardId: string) {
|
||||
rewardToDelete.value = rewardId
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
const deleteReward = async () => {
|
||||
if (!rewardToDelete.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/reward/${rewardToDelete.value}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
// Refresh the reward list after successful delete
|
||||
rewardListRef.value?.refresh()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete reward:', err)
|
||||
} finally {
|
||||
showConfirm.value = false
|
||||
rewardToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const createReward = () => {
|
||||
$router.push({ name: 'CreateReward' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reward-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.reward) {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
</style>
|
||||
530
frontend/vue-app/src/components/shared/ChildrenListView.vue
Normal file
530
frontend/vue-app/src/components/shared/ChildrenListView.vue
Normal file
@@ -0,0 +1,530 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
import { isParentAuthenticated } from '../../stores/auth'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import type {
|
||||
Child,
|
||||
ChildModifiedEventPayload,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
Event,
|
||||
} from '@/common/models'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
import '@/assets/button-shared.css'
|
||||
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import DeleteModal from '../shared/DeleteModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const children = ref<Child[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const images = ref<Map<string, string>>(new Map()) // Store image URLs
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
// UI state for kebab menus & delete confirmation
|
||||
const activeMenuFor = ref<string | number | null>(null) // which child card shows menu
|
||||
const confirmDeleteVisible = ref(false)
|
||||
const deletingChildId = ref<string | number | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const openChildEditor = (child: Child, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
router.push({ name: 'ChildEditView', params: { id: child.id } })
|
||||
}
|
||||
|
||||
async function handleChildModified(event: Event) {
|
||||
const payload = event.payload as ChildModifiedEventPayload
|
||||
const childId = payload.child_id
|
||||
|
||||
switch (payload.operation) {
|
||||
case 'DELETE':
|
||||
children.value = children.value.filter((c) => c.id !== childId)
|
||||
break
|
||||
|
||||
case 'ADD':
|
||||
try {
|
||||
const list = await fetchChildren()
|
||||
children.value = list
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch children after ADD operation:', err)
|
||||
}
|
||||
break
|
||||
|
||||
case 'EDIT':
|
||||
try {
|
||||
const list = await fetchChildren()
|
||||
const updatedChild = list.find((c) => c.id === childId)
|
||||
if (updatedChild) {
|
||||
const idx = children.value.findIndex((c) => c.id === childId)
|
||||
if (idx !== -1) {
|
||||
children.value[idx] = updatedChild
|
||||
} else {
|
||||
console.warn(`EDIT operation: child with id ${childId} not found in current list.`)
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`EDIT operation: updated child with id ${childId} not found in fetched list.`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch children after EDIT operation:', err)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn(`Unknown operation: ${payload.operation}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildTaskTriggered(event: Event) {
|
||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||
const childId = payload.child_id
|
||||
const child = children.value.find((c) => c.id === childId)
|
||||
if (child) {
|
||||
child.points = payload.points
|
||||
} else {
|
||||
console.warn(`Child with id ${childId} not found when updating points.`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildRewardTriggered(event: Event) {
|
||||
const payload = event.payload as ChildRewardTriggeredEventPayload
|
||||
const childId = payload.child_id
|
||||
const child = children.value.find((c) => c.id === childId)
|
||||
if (child) {
|
||||
child.points = payload.points
|
||||
} else {
|
||||
console.warn(`Child with id ${childId} not found when updating points.`)
|
||||
}
|
||||
}
|
||||
|
||||
// points update state
|
||||
const updatingPointsFor = ref<string | number | null>(null)
|
||||
|
||||
const fetchImage = async (imageId: string) => {
|
||||
try {
|
||||
const url = await getCachedImageUrl(imageId, imageCacheName)
|
||||
images.value.set(imageId, url)
|
||||
} catch (err) {
|
||||
console.warn('Failed to load child image', imageId, err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchChildren = async (): Promise<Child[]> => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
images.value.clear()
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/child/list')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const childList = data.children || []
|
||||
|
||||
// Fetch images for each child (shared cache util)
|
||||
await Promise.all(
|
||||
childList.map((child) => {
|
||||
if (child.image_id) {
|
||||
return fetchImage(child.image_id)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}),
|
||||
)
|
||||
return childList
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
||||
console.error('Error fetching children:', err)
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createChild = () => {
|
||||
router.push({ name: 'CreateChild' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
eventBus.on('child_modified', handleChildModified)
|
||||
eventBus.on('child_task_triggered', handleChildTaskTriggered)
|
||||
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
|
||||
|
||||
const listPromise = fetchChildren()
|
||||
listPromise.then((list) => {
|
||||
children.value = list
|
||||
})
|
||||
// listen for outside clicks to auto-close any open kebab menu
|
||||
document.addEventListener('click', onDocClick, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_modified', handleChildModified)
|
||||
eventBus.off('child_task_triggered', handleChildTaskTriggered)
|
||||
eventBus.off('child_reward_triggered', handleChildRewardTriggered)
|
||||
})
|
||||
|
||||
const shouldIgnoreNextCardClick = ref(false)
|
||||
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (activeMenuFor.value !== null) {
|
||||
const path = (e.composedPath && e.composedPath()) || (e as any).path || []
|
||||
const clickedInsideKebab = path.some((node: unknown) => {
|
||||
if (!(node instanceof HTMLElement)) return false
|
||||
return (
|
||||
node.classList.contains('kebab-wrap') ||
|
||||
node.classList.contains('kebab-btn') ||
|
||||
node.classList.contains('kebab-menu')
|
||||
)
|
||||
})
|
||||
if (!clickedInsideKebab) {
|
||||
activeMenuFor.value = null
|
||||
// If the click was on a card, set the flag to ignore the next card click
|
||||
if (
|
||||
path.some((node: unknown) => node instanceof HTMLElement && node.classList.contains('card'))
|
||||
) {
|
||||
shouldIgnoreNextCardClick.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectChild = (childId: string | number) => {
|
||||
if (shouldIgnoreNextCardClick.value) {
|
||||
shouldIgnoreNextCardClick.value = false
|
||||
return
|
||||
}
|
||||
if (activeMenuFor.value !== null) {
|
||||
// If kebab menu is open, ignore card clicks
|
||||
return
|
||||
}
|
||||
if (isParentAuthenticated.value) {
|
||||
router.push(`/parent/${childId}`)
|
||||
} else {
|
||||
router.push(`/child/${childId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// kebab menu helpers
|
||||
const openMenu = (childId: string | number, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
activeMenuFor.value = childId
|
||||
}
|
||||
const closeMenu = () => {
|
||||
activeMenuFor.value = null
|
||||
}
|
||||
|
||||
// delete flow
|
||||
const askDelete = (childId: string | number, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
deletingChildId.value = childId
|
||||
confirmDeleteVisible.value = true
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const performDelete = async () => {
|
||||
if (!deletingChildId.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${deletingChildId.value}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Delete failed: ${resp.status}`)
|
||||
}
|
||||
// refresh list
|
||||
await fetchChildren()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete child', deletingChildId.value, err)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
confirmDeleteVisible.value = false
|
||||
deletingChildId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Points flow: set points to 0 via API and refresh points display
|
||||
const deletePoints = async (childId: string | number, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
closeMenu()
|
||||
updatingPointsFor.value = childId
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${childId}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ points: 0 }),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to update points: ${resp.status}`)
|
||||
}
|
||||
// no need to refresh since we update optimistically via eventBus
|
||||
} catch (err) {
|
||||
console.error('Failed to delete points for child', childId, err)
|
||||
} finally {
|
||||
updatingPointsFor.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocClick, true)
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MessageBlock v-if="children.length === 0" message="No children">
|
||||
<span v-if="!isParentAuthenticated">
|
||||
<button class="round-btn" @click="eventBus.emit('open-login')">Sign in</button> to create a
|
||||
child
|
||||
</span>
|
||||
<span v-else> <button class="round-btn" @click="createChild">Create</button> a child </span>
|
||||
</MessageBlock>
|
||||
|
||||
<div v-else-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
|
||||
<div v-else class="grid">
|
||||
<div v-for="child in children" :key="child.id" class="card" @click="selectChild(child.id)">
|
||||
<!-- kebab menu shown only for authenticated parent -->
|
||||
<div v-if="isParentAuthenticated" class="kebab-wrap" @click.stop>
|
||||
<!-- kebab button -->
|
||||
<button
|
||||
class="kebab-btn"
|
||||
@mousedown.stop.prevent
|
||||
@click="openMenu(child.id, $event)"
|
||||
:aria-expanded="activeMenuFor === child.id ? 'true' : 'false'"
|
||||
aria-label="Options"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
|
||||
<!-- menu items -->
|
||||
<div
|
||||
v-if="activeMenuFor === child.id"
|
||||
class="kebab-menu"
|
||||
@mousedown.stop.prevent
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="openChildEditor(child, $event)"
|
||||
>
|
||||
Edit Child
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="deletePoints(child.id, $event)"
|
||||
:disabled="updatingPointsFor === child.id"
|
||||
>
|
||||
{{ updatingPointsFor === child.id ? 'Updating…' : 'Delete Points' }}
|
||||
</button>
|
||||
<button class="menu-item danger" @mousedown.stop.prevent @click="askDelete(child.id)">
|
||||
Delete Child
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>{{ child.name }}</h2>
|
||||
<img
|
||||
v-if="images.get(child.image_id)"
|
||||
:src="images.get(child.image_id)"
|
||||
alt="Child Image"
|
||||
class="child-image"
|
||||
/>
|
||||
<p class="age">Age: {{ child.age }}</p>
|
||||
<p class="points">Points: {{ child.points ?? 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
:show="confirmDeleteVisible"
|
||||
message="Are you sure you want to delete this child?"
|
||||
@confirm="performDelete"
|
||||
@cancel="confirmDeleteVisible = false"
|
||||
/>
|
||||
|
||||
<FloatingActionButton
|
||||
v-if="isParentAuthenticated"
|
||||
aria-label="Add Child"
|
||||
@click="createChild"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1.2rem 1.2rem;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 12px;
|
||||
overflow: visible; /* allow menu to overflow */
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
position: relative; /* for kebab positioning */
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
/* kebab button / menu (fixed-size button, absolutely positioned menu) */
|
||||
.kebab-wrap {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 20;
|
||||
/* keep the wrapper only as a positioning context */
|
||||
}
|
||||
|
||||
.kebab-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--kebab-icon-color);
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* consistent focus ring without changing layout */
|
||||
.kebab-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.18);
|
||||
}
|
||||
|
||||
/* Menu overlays the card and does NOT alter flow */
|
||||
.kebab-menu {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
min-width: 150px;
|
||||
background: var(--kebab-menu-bg);
|
||||
border: 1.5px solid var(--kebab-menu-border);
|
||||
box-shadow: var(--kebab-menu-shadow);
|
||||
backdrop-filter: blur(var(--kebab-menu-blur));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 1.1rem 0.9rem; /* Increase vertical padding for bigger touch area */
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--menu-item-color);
|
||||
font-size: 1.1rem; /* Slightly larger text for readability */
|
||||
}
|
||||
|
||||
.menu-item + .menu-item {
|
||||
margin-top: 0.5rem; /* Add space between menu items */
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.menu-item {
|
||||
padding: 0.85rem 0.7rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.menu-item + .menu-item {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: var(--menu-item-hover-bg);
|
||||
}
|
||||
|
||||
.menu-item.danger {
|
||||
color: var(--menu-item-danger);
|
||||
}
|
||||
|
||||
/* card content */
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--card-title);
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-word;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.age {
|
||||
font-size: 1.1rem;
|
||||
color: var(--age-color);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.child-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1rem auto;
|
||||
background: var(--child-image-bg);
|
||||
}
|
||||
|
||||
.points {
|
||||
font-size: 1.05rem;
|
||||
color: var(--points-color);
|
||||
margin-top: 0.4rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Loading, error, empty states */
|
||||
.loading,
|
||||
.empty {
|
||||
margin: 1.2rem 0;
|
||||
color: var(--list-loading-color);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
background: var(--error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.value {
|
||||
font-weight: 600;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
</style>
|
||||
48
frontend/vue-app/src/components/shared/DeleteModal.vue
Normal file
48
frontend/vue-app/src/components/shared/DeleteModal.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="modal-backdrop" v-if="show">
|
||||
<div class="modal">
|
||||
<p>{{ message }}</p>
|
||||
<div class="actions">
|
||||
<button @click="handleDelete" class="btn btn-danger" :disabled="deleting">
|
||||
{{ deleting ? 'Deleting' : 'Delete' }}
|
||||
</button>
|
||||
<button @click="handleCancel" class="btn btn-secondary" :disabled="deleting">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
message?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel'])
|
||||
|
||||
const deleting = ref(false)
|
||||
|
||||
function handleDelete() {
|
||||
deleting.value = true
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (!deleting.value) emit('cancel')
|
||||
}
|
||||
|
||||
// Reset deleting state when modal is closed
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (!val) deleting.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import '@/assets/modal.css';
|
||||
@import '@/assets/actions-shared.css';
|
||||
</style>
|
||||
6
frontend/vue-app/src/components/shared/ErrorMessage.vue
Normal file
6
frontend/vue-app/src/components/shared/ErrorMessage.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div v-if="message" class="error-message" aria-live="polite">{{ message }}</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{ message: string }>()
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<button class="fab" @click="$emit('click')" :aria-label="ariaLabel">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<circle cx="14" cy="14" r="14" fill="#667eea" />
|
||||
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '@/assets/global.css'
|
||||
defineProps<{ ariaLabel?: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: var(--fab-bg);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--fab-hover-bg);
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
background: var(--fab-active-bg);
|
||||
}
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
267
frontend/vue-app/src/components/shared/ItemList.vue
Normal file
267
frontend/vue-app/src/components/shared/ItemList.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
fetchUrl: string
|
||||
itemKey: string
|
||||
itemFields: readonly string[]
|
||||
imageFields?: readonly string[]
|
||||
selectable?: boolean
|
||||
deletable?: boolean
|
||||
onClicked?: (item: any) => void
|
||||
onDelete?: (id: string) => void
|
||||
filterFn?: (item: any) => boolean
|
||||
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['clicked', 'delete', 'loading-complete'])
|
||||
|
||||
const items = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedItems = ref<string[]>([])
|
||||
|
||||
defineExpose({
|
||||
items,
|
||||
selectedItems,
|
||||
})
|
||||
|
||||
const fetchItems = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
console.log(`Fetching items from: ${props.fetchUrl}`)
|
||||
try {
|
||||
const resp = await fetch(props.fetchUrl)
|
||||
console.log(`Fetch response status: ${resp.status}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
//console log all data
|
||||
console.log('Fetched data:', data)
|
||||
let itemList = data[props.itemKey || 'items'] || []
|
||||
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||
const initiallySelected: string[] = []
|
||||
await Promise.all(
|
||||
itemList.map(async (item: any) => {
|
||||
if (props.imageFields) {
|
||||
for (const field of props.imageFields) {
|
||||
if (item[field]) {
|
||||
try {
|
||||
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
|
||||
} catch {
|
||||
console.error('Error fetching image for item', item.id)
|
||||
item[`${field.replace('_id', '_url')}`] = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (item.image_id) {
|
||||
try {
|
||||
item.image_url = await getCachedImageUrl(item.image_id)
|
||||
} catch {
|
||||
item.image_url = null
|
||||
}
|
||||
}
|
||||
//for each item see it there is an 'assigned' field that is true. if so check the item's selectable checkbox
|
||||
if (props.selectable && item.assigned === true) {
|
||||
initiallySelected.push(item.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
items.value = itemList
|
||||
if (props.selectable) {
|
||||
selectedItems.value = initiallySelected
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
|
||||
items.value = []
|
||||
if (props.selectable) selectedItems.value = []
|
||||
} finally {
|
||||
emit('loading-complete', items.value.length)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchItems)
|
||||
watch(() => props.fetchUrl, fetchItems)
|
||||
|
||||
const handleClicked = (item: any) => {
|
||||
emit('clicked', item)
|
||||
props.onClicked?.(item)
|
||||
}
|
||||
const handleDelete = (item: any) => {
|
||||
emit('delete', item)
|
||||
props.onDelete?.(item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="listbox">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="items.length === 0" class="empty">No items found.</div>
|
||||
<div v-else>
|
||||
<div v-for="(item, idx) in items" :key="item.id" class="list-row">
|
||||
<div :class="['list-item', props.getItemClass?.(item)]" @click.stop="handleClicked(item)">
|
||||
<slot name="item" :item="item">
|
||||
<!-- Default rendering if no slot is provided -->
|
||||
<img v-if="item.image_url" :src="item.image_url" />
|
||||
<span class="list-name">List Item</span>
|
||||
<span class="list-value">1</span>
|
||||
</slot>
|
||||
<div v-if="props.selectable || props.deletable" class="interact">
|
||||
<input
|
||||
v-if="props.selectable"
|
||||
type="checkbox"
|
||||
class="list-checkbox"
|
||||
v-model="selectedItems"
|
||||
:value="item.id"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
v-if="props.deletable"
|
||||
class="delete-btn"
|
||||
@click.stop="handleDelete(item)"
|
||||
aria-label="Delete item"
|
||||
type="button"
|
||||
>
|
||||
<!-- SVG icon here -->
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
|
||||
<path
|
||||
d="M7 7l6 6M13 7l-6 6"
|
||||
stroke="#ef4444"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="idx < items.length - 1" class="list-separator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.listbox {
|
||||
flex: 0 1 auto;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 4.5rem);
|
||||
overflow-y: auto;
|
||||
margin: 0.2rem 0 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
background: var(--list-bg);
|
||||
padding: 0.2rem 0.2rem 0.2rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px outset var(--list-item-border-reward);
|
||||
border-radius: 8px;
|
||||
padding: 0.2rem 1rem;
|
||||
background: var(--list-item-bg);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
transition: border 0.18s;
|
||||
margin-bottom: 0.2rem;
|
||||
margin-left: 0.2rem;
|
||||
margin-right: 0.2rem;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
||||
box-sizing: border-box;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-item .interact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Image styles */
|
||||
:deep(.list-item img) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
margin-right: 0.7rem;
|
||||
background: var(--list-image-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Name/label styles */
|
||||
.list-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Points/cost/requested text */
|
||||
.list-value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
margin-left: 0.7rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition:
|
||||
background 0.15s,
|
||||
box-shadow 0.15s;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
background: var(--delete-btn-hover-bg);
|
||||
box-shadow: 0 0 0 2px var(--delete-btn-hover-shadow);
|
||||
opacity: 1;
|
||||
}
|
||||
.delete-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.list-checkbox {
|
||||
margin-left: 1rem;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
accent-color: var(--checkbox-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Loading, error, empty states */
|
||||
.loading,
|
||||
.empty {
|
||||
margin: 1.2rem 0;
|
||||
color: var(--list-loading-color);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
background: var(--error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
/* Separator (if needed) */
|
||||
.list-separator {
|
||||
height: 0px;
|
||||
background: #0000;
|
||||
margin: 0rem 0.2rem;
|
||||
border-radius: 0px;
|
||||
}
|
||||
</style>
|
||||
190
frontend/vue-app/src/components/shared/LoginButton.vue
Normal file
190
frontend/vue-app/src/components/shared/LoginButton.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import { authenticateParent, isParentAuthenticated, logoutParent } from '../../stores/auth'
|
||||
import '@/assets/modal.css'
|
||||
import '@/assets/actions-shared.css'
|
||||
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
const pin = ref('')
|
||||
const error = ref('')
|
||||
const pinInput = ref<HTMLInputElement | null>(null)
|
||||
const dropdownOpen = ref(false)
|
||||
|
||||
const open = async () => {
|
||||
pin.value = ''
|
||||
error.value = ''
|
||||
show.value = true
|
||||
await nextTick()
|
||||
pinInput.value?.focus()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
show.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const isDigits = /^\d{4,6}$/.test(pin.value)
|
||||
if (!isDigits) {
|
||||
error.value = 'Enter 4–6 digits'
|
||||
return
|
||||
}
|
||||
|
||||
if (pin.value !== '1179') {
|
||||
error.value = 'Incorrect PIN'
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate parent and navigate
|
||||
authenticateParent()
|
||||
close()
|
||||
router.push('/parent')
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutParent()
|
||||
router.push('/child')
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await fetch('/api/logout', { method: 'POST' })
|
||||
logoutParent()
|
||||
router.push('/auth')
|
||||
} catch {
|
||||
// Optionally show error
|
||||
}
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
|
||||
function goToProfile() {
|
||||
router.push('/parent/profile')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('open-login', open)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
eventBus.off('open-login', open)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="position: relative">
|
||||
<button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login" class="login-btn">
|
||||
Parent
|
||||
</button>
|
||||
<div v-else style="display: inline-block; position: relative">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
aria-label="Parent menu"
|
||||
class="login-btn"
|
||||
style="min-width: 80px"
|
||||
>
|
||||
Parent ▼
|
||||
</button>
|
||||
<div
|
||||
v-if="dropdownOpen"
|
||||
class="dropdown-menu"
|
||||
style="
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
min-width: 120px;
|
||||
z-index: 10;
|
||||
"
|
||||
>
|
||||
<button class="menu-item" @click="goToProfile" style="width: 100%; text-align: left">
|
||||
Profile
|
||||
</button>
|
||||
<button class="menu-item" @click="handleLogout" style="width: 100%; text-align: left">
|
||||
Log out
|
||||
</button>
|
||||
<button class="menu-item danger" @click="signOut" style="width: 100%; text-align: left">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="show" class="modal-backdrop" @click.self="close">
|
||||
<div class="modal">
|
||||
<h3>Enter parent PIN</h3>
|
||||
<form @submit.prevent="submit">
|
||||
<input
|
||||
ref="pinInput"
|
||||
v-model="pin"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
maxlength="6"
|
||||
placeholder="4–6 digits"
|
||||
class="pin-input"
|
||||
/>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* modal */
|
||||
|
||||
.pin-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
margin-bottom: 0.6rem;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.menu-item {
|
||||
padding: 1rem 0.9rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--menu-item-color, #333);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.menu-item + .menu-item {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: var(--menu-item-hover-bg, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
.menu-item.danger {
|
||||
color: var(--menu-item-danger, #ff4d4f);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.menu-item {
|
||||
padding: 0.85rem 0.7rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.menu-item + .menu-item {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
28
frontend/vue-app/src/components/shared/MessageBlock.vue
Normal file
28
frontend/vue-app/src/components/shared/MessageBlock.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="message-block">
|
||||
<div>{{ message }}</div>
|
||||
<div class="sub-message">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import '@/assets/global.css'
|
||||
defineProps<{ message: string }>()
|
||||
</script>
|
||||
<style scoped>
|
||||
.message-block {
|
||||
margin: 2rem 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--message-block-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sub-message {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--sub-message-color);
|
||||
}
|
||||
</style>
|
||||
32
frontend/vue-app/src/components/shared/ModalDialog.vue
Normal file
32
frontend/vue-app/src/components/shared/ModalDialog.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal-dialog">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
// No script content needed unless you want to add props or logic
|
||||
</script>
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-dialog {
|
||||
background: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
312
frontend/vue-app/src/components/shared/ScrollingList.vue
Normal file
312
frontend/vue-app/src/components/shared/ScrollingList.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '@/common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
fetchBaseUrl: string
|
||||
ids?: readonly string[]
|
||||
itemKey: string
|
||||
imageFields?: readonly string[]
|
||||
isParentAuthenticated?: boolean
|
||||
filterFn?: (item: any) => boolean
|
||||
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
|
||||
}>()
|
||||
|
||||
// Compute the fetch URL with ids if present
|
||||
const fetchUrl = computed(() => {
|
||||
if (props.ids && props.ids.length > 0) {
|
||||
const separator = props.fetchBaseUrl.includes('?') ? '&' : '?'
|
||||
return `${props.fetchBaseUrl}${separator}ids=${props.ids.join(',')}`
|
||||
}
|
||||
return props.fetchBaseUrl
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'trigger-item', item: any): void
|
||||
}>()
|
||||
|
||||
const items = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const scrollWrapper = ref<HTMLDivElement | null>(null)
|
||||
const itemRefs = ref<Record<string, HTMLElement | Element | null>>({})
|
||||
const lastCenteredItemId = ref<string | null>(null)
|
||||
const readyItemId = ref<string | null>(null)
|
||||
|
||||
const fetchItems = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const resp = await fetch(fetchUrl.value)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
// Try to use 'tasks', 'reward_status', or 'items' as fallback
|
||||
let itemList = data[props.itemKey || 'items'] || []
|
||||
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||
items.value = itemList
|
||||
// Fetch images for each item
|
||||
await Promise.all(
|
||||
itemList.map(async (item: any) => {
|
||||
if (props.imageFields) {
|
||||
for (const field of props.imageFields) {
|
||||
if (item[field]) {
|
||||
try {
|
||||
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
|
||||
} catch {
|
||||
item[`${field.replace('_id', '_url')}`] = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (item.image_id) {
|
||||
try {
|
||||
item.image_url = await getCachedImageUrl(item.image_id)
|
||||
} catch {
|
||||
console.error('Error fetching image for item', item.id)
|
||||
item.image_url = null
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
|
||||
items.value = []
|
||||
console.error('Error fetching items:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await fetchItems()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh,
|
||||
items,
|
||||
})
|
||||
|
||||
const centerItem = async (itemId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = itemRefs.value[itemId]
|
||||
if (wrapper && card) {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const wrapperScrollLeft = wrapper.scrollLeft
|
||||
const cardCenter = cardRect.left + cardRect.width / 2
|
||||
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
|
||||
const scrollOffset = cardCenter - wrapperCenter
|
||||
wrapper.scrollTo({
|
||||
left: wrapperScrollLeft + scrollOffset,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleClicked = async (item: any) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = itemRefs.value[item.id]
|
||||
if (!wrapper || !card) return
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const cardCenter = cardRect.left + cardRect.width / 2
|
||||
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
|
||||
|
||||
if (!cardFullyVisible || lastCenteredItemId.value !== item.id) {
|
||||
// Center the item, but don't trigger
|
||||
await centerItem(item.id)
|
||||
lastCenteredItemId.value = item.id
|
||||
readyItemId.value = item.id
|
||||
return
|
||||
}
|
||||
emit(
|
||||
'trigger-item',
|
||||
items.value.find((i) => i.id === item.id),
|
||||
)
|
||||
readyItemId.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.ids],
|
||||
() => {
|
||||
fetchItems()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="child-list-container">
|
||||
<h3>{{ title }}</h3>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
<div v-else-if="items.length === 0" class="empty">No {{ title }}</div>
|
||||
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
||||
<div class="item-scroll">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:class="['item-card', props.getItemClass?.(item)]"
|
||||
:ref="(el) => (itemRefs[item.id] = el)"
|
||||
@click.stop="handleClicked(item)"
|
||||
>
|
||||
<slot name="item" :item="item">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.child-list-container {
|
||||
background: var(--child-list-bg, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
color: var(--child-list-title-color, #fff);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-track {
|
||||
background: var(--child-list-scrollbar-track, rgba(255, 255, 255, 0.05));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
background: var(
|
||||
--child-list-scrollbar-thumb,
|
||||
linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8))
|
||||
);
|
||||
border-radius: 10px;
|
||||
border: var(--child-list-scrollbar-thumb-border, 2px solid rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: var(
|
||||
--child-list-scrollbar-thumb-hover,
|
||||
linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1))
|
||||
);
|
||||
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.item-scroll {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-width: min-content;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Fallback for browsers that don't support flex gap */
|
||||
.item-card + .item-card {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
position: relative;
|
||||
background: var(--item-card-bg, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.18s ease;
|
||||
border: var(--item-card-border, 1px solid rgba(255, 255, 255, 0.08));
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none; /* Prevent image selection */
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--item-card-hover-shadow, 0 8px 20px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
@keyframes ready-glow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 #667eea00;
|
||||
border-color: inherit;
|
||||
}
|
||||
100% {
|
||||
box-shadow: var(--item-card-ready-shadow, 0 0 0 3px #667eea88, 0 0 12px #667eea44);
|
||||
border-color: var(--item-card-ready-border, #667eea);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.item-name) {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--item-name-color, #fff);
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Image styling */
|
||||
:deep(.item-image) {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
margin: 0 auto 0.4rem auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.item-card {
|
||||
min-width: 110px;
|
||||
max-width: 150px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
:deep(.item-name) {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
:deep(.item-image) {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto 0.3rem auto;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div v-if="message" class="success-message" aria-live="polite">{{ message }}</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{ message: string }>()
|
||||
</script>
|
||||
171
frontend/vue-app/src/components/task/ChildTaskList.vue
Normal file
171
frontend/vue-app/src/components/task/ChildTaskList.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
import type { Task } from '@/common/models'
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
taskIds: string[]
|
||||
childId: string | number | null
|
||||
isParentAuthenticated: boolean
|
||||
filterType?: number | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'trigger-task', task: Task): void
|
||||
}>()
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const scrollWrapper = ref<HTMLDivElement | null>(null)
|
||||
const taskRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
|
||||
const lastCenteredTaskId = ref<string | null>(null)
|
||||
const readyTaskId = ref<string | null>(null)
|
||||
|
||||
const fetchTasks = async () => {
|
||||
const taskPromises = props.taskIds.map((id) =>
|
||||
fetch(`/api/task/${id}`).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
const results = await Promise.all(taskPromises)
|
||||
tasks.value = results
|
||||
|
||||
// Fetch images for each task (uses shared imageCache)
|
||||
await Promise.all(tasks.value.map(fetchImage))
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
||||
console.error('Error fetching tasks:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchImage = async (task: Task) => {
|
||||
if (!task.image_id) {
|
||||
console.log(`No image ID for task: ${task.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await getCachedImageUrl(task.image_id, imageCacheName)
|
||||
task.image_url = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching image for task', task.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
const centerTask = async (taskId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = taskRefs.value[taskId]
|
||||
if (wrapper && card) {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const wrapperScrollLeft = wrapper.scrollLeft
|
||||
const cardCenter = cardRect.left + cardRect.width / 2
|
||||
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
|
||||
const scrollOffset = cardCenter - wrapperCenter
|
||||
wrapper.scrollTo({
|
||||
left: wrapperScrollLeft + scrollOffset,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const triggerTask = (taskId: string) => {
|
||||
const task = tasks.value.find((t) => t.id === taskId)
|
||||
if (task) emit('trigger-task', task)
|
||||
}
|
||||
|
||||
const handleTaskClick = async (taskId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = taskRefs.value[taskId]
|
||||
if (!wrapper || !card) return
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const cardCenter = cardRect.left + cardRect.width / 2
|
||||
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
|
||||
|
||||
if (!cardFullyVisible || lastCenteredTaskId.value !== taskId) {
|
||||
// Center the task, but don't trigger
|
||||
await centerTask(taskId)
|
||||
lastCenteredTaskId.value = taskId
|
||||
readyTaskId.value = taskId
|
||||
return
|
||||
}
|
||||
|
||||
// If already centered and visible, emit to parent
|
||||
triggerTask(taskId)
|
||||
readyTaskId.value = null
|
||||
}
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (props.filterType == 1) {
|
||||
return tasks.value.filter((t) => t.is_good)
|
||||
} else if (props.filterType == 2) {
|
||||
return tasks.value.filter((t) => !t.is_good)
|
||||
}
|
||||
return tasks.value
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.taskIds,
|
||||
(newTaskIds) => {
|
||||
if (newTaskIds && newTaskIds.length > 0) {
|
||||
fetchTasks()
|
||||
} else {
|
||||
tasks.value = []
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// revoke all created object URLs when component unmounts
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="child-list-container">
|
||||
<h3>{{ title }}</h3>
|
||||
|
||||
<div v-if="loading" class="loading">Loading tasks...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
<div v-else-if="filteredTasks.length === 0" class="empty">No {{ title }}</div>
|
||||
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
||||
<div class="item-scroll">
|
||||
<div
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
class="item-card"
|
||||
:class="{ good: task.is_good, bad: !task.is_good, ready: readyTaskId === task.id }"
|
||||
:ref="(el) => (taskRefs[task.id] = el)"
|
||||
@click="() => handleTaskClick(task.id)"
|
||||
>
|
||||
<div class="item-name">{{ task.name }}</div>
|
||||
<img v-if="task.image_url" :src="task.image_url" alt="Task Image" class="task-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': task.is_good, 'bad-points': !task.is_good }"
|
||||
>
|
||||
{{ task.is_good ? task.points : -task.points }} Points
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
255
frontend/vue-app/src/components/task/TaskEditView.vue
Normal file
255
frontend/vue-app/src/components/task/TaskEditView.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, defineEmits, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import '@/assets/edit-forms.css'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const emit = defineEmits<{
|
||||
(e: 'updated'): void
|
||||
}>()
|
||||
// Define props
|
||||
const props = defineProps<{
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
const name = ref('')
|
||||
const points = ref(0)
|
||||
const isGood = ref(true)
|
||||
const selectedImageId = ref<string | null>(null)
|
||||
const localImageFile = ref<File | null>(null)
|
||||
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Load task if editing
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/task/${props.id}`)
|
||||
if (!resp.ok) throw new Error('Failed to load task')
|
||||
const data = await resp.json()
|
||||
name.value = data.name
|
||||
points.value = Number(data.points) || 0
|
||||
isGood.value = data.is_good
|
||||
selectedImageId.value = data.image_id
|
||||
} catch (e) {
|
||||
error.value = 'Could not load task.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
// Delay focus until after DOM updates and event propagation
|
||||
await nextTick()
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
} else {
|
||||
// For create, also use nextTick
|
||||
await nextTick()
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
let imageId = selectedImageId.value
|
||||
error.value = null
|
||||
if (!name.value.trim()) {
|
||||
error.value = 'Task name is required.'
|
||||
return
|
||||
}
|
||||
if (points.value < 1) {
|
||||
error.value = 'Points must be at least 1.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
|
||||
// If the selected image is a local upload, upload it first
|
||||
if (imageId === 'local-upload' && localImageFile.value) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', localImageFile.value)
|
||||
formData.append('type', '2')
|
||||
formData.append('permanent', 'false')
|
||||
try {
|
||||
const resp = await fetch('/api/image/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId = data.id
|
||||
} catch (err) {
|
||||
alert('Failed to upload image.')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Now update or create the task
|
||||
try {
|
||||
let resp
|
||||
if (isEdit.value) {
|
||||
resp = await fetch(`/api/task/${props.id}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
points: points.value,
|
||||
is_good: isGood.value,
|
||||
image_id: imageId,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
resp = await fetch('/api/task/add', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
points: points.value,
|
||||
is_good: isGood.value,
|
||||
image_id: imageId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (!resp.ok) throw new Error('Failed to save task')
|
||||
emit('updated')
|
||||
await router.push({ name: 'TaskView' })
|
||||
} catch (err) {
|
||||
alert('Failed to save task.')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// Handle new image from ImagePicker
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="task-edit-view">
|
||||
<h2>{{ isEdit ? 'Edit Task' : 'Create Task' }}</h2>
|
||||
<div v-if="loading" class="loading-message">Loading task...</div>
|
||||
<form v-else @submit.prevent="submit" class="task-form">
|
||||
<div class="group">
|
||||
<label for="task-name">
|
||||
Task Name
|
||||
<input
|
||||
id="task-name"
|
||||
ref="nameInput"
|
||||
v-model="name"
|
||||
type="text"
|
||||
required
|
||||
maxlength="64"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="task-points">
|
||||
Task Points
|
||||
<input
|
||||
id="task-points"
|
||||
v-model.number="points"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="task-type">
|
||||
Task Type
|
||||
<div class="good-bad-toggle" id="task-type">
|
||||
<button
|
||||
type="button"
|
||||
:class="['toggle-btn', isGood ? 'good-active' : '']"
|
||||
@click="isGood = true"
|
||||
>
|
||||
Good
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['toggle-btn', !isGood ? 'bad-active' : '']"
|
||||
@click="isGood = false"
|
||||
>
|
||||
Bad
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="task-image">Image</label>
|
||||
<ImagePicker
|
||||
id="task-image"
|
||||
v-model="selectedImageId"
|
||||
:image-type="2"
|
||||
@add-image="onAddImage"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div class="actions">
|
||||
<button type="button" @click="handleCancel" :disabled="loading" class="btn btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="loading" class="btn btn-primary">
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.good-bad-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.1rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
button.toggle-btn {
|
||||
flex: 1 1 0;
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-width: 2px;
|
||||
border-radius: 7px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s,
|
||||
color 0.18s,
|
||||
border-style 0.18s;
|
||||
outline: none;
|
||||
border-style: outset; /* Default style */
|
||||
background: var(--toggle-btn-bg);
|
||||
color: var(--toggle-btn-color);
|
||||
border-color: var(--toggle-btn-border);
|
||||
}
|
||||
|
||||
button.toggle-btn.good-active {
|
||||
background: var(--toggle-btn-good-bg);
|
||||
color: var(--toggle-btn-good-color);
|
||||
box-shadow: 0 2px 8px var(--toggle-btn-good-shadow);
|
||||
transform: translateY(2px) scale(0.97);
|
||||
border-style: ridge;
|
||||
border-color: var(--toggle-btn-good-border);
|
||||
}
|
||||
|
||||
button.toggle-btn.bad-active {
|
||||
background: var(--toggle-btn-bad-bg);
|
||||
color: var(--toggle-btn-bad-color);
|
||||
box-shadow: 0 2px 8px var(--toggle-btn-bad-shadow);
|
||||
transform: translateY(2px) scale(0.97);
|
||||
border-style: ridge;
|
||||
border-color: var(--toggle-btn-bad-border);
|
||||
}
|
||||
</style>
|
||||
116
frontend/vue-app/src/components/task/TaskView.vue
Normal file
116
frontend/vue-app/src/components/task/TaskView.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="task-view">
|
||||
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
|
||||
<span> <button class="round-btn" @click="createTask">Create</button> a task </span>
|
||||
</MessageBlock>
|
||||
|
||||
<ItemList
|
||||
v-else
|
||||
fetchUrl="/api/task/list"
|
||||
itemKey="tasks"
|
||||
:itemFields="TASK_FIELDS"
|
||||
imageField="image_id"
|
||||
deletable
|
||||
@clicked="(task: Task) => $router.push({ name: 'EditTask', params: { id: task.id } })"
|
||||
@delete="confirmDeleteTask"
|
||||
@loading-complete="(count) => (taskCountRef = count)"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<img v-if="item.image_url" :src="item.image_url" />
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="value">{{ item.points }} pts</span>
|
||||
</template>
|
||||
</ItemList>
|
||||
|
||||
<FloatingActionButton aria-label="Create Task" @click="createTask" />
|
||||
|
||||
<DeleteModal
|
||||
:show="showConfirm"
|
||||
message="Are you sure you want to delete this task?"
|
||||
@confirm="deleteTask"
|
||||
@cancel="showConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
import '@/assets/button-shared.css'
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import DeleteModal from '../shared/DeleteModal.vue'
|
||||
import type { Task } from '@/common/models'
|
||||
import { TASK_FIELDS } from '@/common/models'
|
||||
|
||||
const $router = useRouter()
|
||||
|
||||
const showConfirm = ref(false)
|
||||
const taskToDelete = ref<string | null>(null)
|
||||
const taskListRef = ref()
|
||||
const taskCountRef = ref<number>(-1)
|
||||
|
||||
function confirmDeleteTask(taskId: string) {
|
||||
taskToDelete.value = taskId
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
const deleteTask = async () => {
|
||||
if (!taskToDelete.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/task/${taskToDelete.value}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
// Refresh the task list after successful delete
|
||||
taskListRef.value?.refresh()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete task:', err)
|
||||
} finally {
|
||||
showConfirm.value = false
|
||||
taskToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// New function to handle task creation
|
||||
const createTask = () => {
|
||||
// Route to your create task page or open a create dialog
|
||||
// Example:
|
||||
$router.push({ name: 'CreateTask' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.good) {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
}
|
||||
:deep(.bad) {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
</style>
|
||||
372
frontend/vue-app/src/components/utils/ImagePicker.vue
Normal file
372
frontend/vue-app/src/components/utils/ImagePicker.vue
Normal file
@@ -0,0 +1,372 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string | null // selected image id or local-upload
|
||||
imageType?: number // 1 or 2, default 1
|
||||
}>()
|
||||
const emit = defineEmits(['update:modelValue', 'add-image'])
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const localImageUrl = ref<string | null>(null)
|
||||
const showCamera = ref(false)
|
||||
const cameraStream = ref<MediaStream | null>(null)
|
||||
const cameraVideo = ref<HTMLVideoElement | null>(null)
|
||||
const cameraError = ref<string | null>(null)
|
||||
const capturedImageUrl = ref<string | null>(null)
|
||||
const cameraFile = ref<File | null>(null)
|
||||
|
||||
const availableImages = ref<{ id: string; url: string }[]>([])
|
||||
const loadingImages = ref(false)
|
||||
|
||||
const typeParam = computed(() => props.imageType ?? 1)
|
||||
|
||||
const selectImage = (id: string | undefined) => {
|
||||
if (!id) {
|
||||
console.warn('selectImage called with null id')
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', id)
|
||||
}
|
||||
|
||||
const addFromLocal = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const onFileChange = async (event: Event) => {
|
||||
const files = (event.target as HTMLInputElement).files
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0]
|
||||
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
|
||||
const { blob, url } = await resizeImageFile(file, 512)
|
||||
localImageUrl.value = url
|
||||
updateLocalImage(url, new File([blob], file.name, { type: 'image/png' }))
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
|
||||
})
|
||||
|
||||
const addFromCamera = async () => {
|
||||
cameraError.value = null
|
||||
capturedImageUrl.value = null
|
||||
showCamera.value = true
|
||||
await nextTick()
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
|
||||
cameraStream.value = stream
|
||||
if (cameraVideo.value) {
|
||||
cameraVideo.value.srcObject = stream
|
||||
await cameraVideo.value.play()
|
||||
}
|
||||
} catch (err) {
|
||||
cameraError.value = 'Unable to access camera'
|
||||
cameraStream.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const takePhoto = async () => {
|
||||
if (!cameraVideo.value) return
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = cameraVideo.value.videoWidth
|
||||
canvas.height = cameraVideo.value.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(cameraVideo.value, 0, 0, canvas.width, canvas.height)
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
capturedImageUrl.value = dataUrl
|
||||
}
|
||||
}
|
||||
|
||||
const confirmPhoto = async () => {
|
||||
if (capturedImageUrl.value) {
|
||||
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
|
||||
// Convert dataURL to Blob
|
||||
const res = await fetch(capturedImageUrl.value)
|
||||
const originalBlob = await res.blob()
|
||||
const { blob, url } = await resizeImageFile(originalBlob, 512)
|
||||
localImageUrl.value = url
|
||||
cameraFile.value = new File([blob], 'camera.png', { type: 'image/png' })
|
||||
updateLocalImage(url, cameraFile.value)
|
||||
}
|
||||
closeCamera()
|
||||
}
|
||||
|
||||
const retakePhoto = async () => {
|
||||
capturedImageUrl.value = null
|
||||
cameraFile.value = null
|
||||
await resumeCameraStream()
|
||||
}
|
||||
|
||||
const closeCamera = () => {
|
||||
showCamera.value = false
|
||||
capturedImageUrl.value = null
|
||||
if (cameraStream.value) {
|
||||
cameraStream.value.getTracks().forEach((track) => track.stop())
|
||||
cameraStream.value = null
|
||||
}
|
||||
if (cameraVideo.value) {
|
||||
cameraVideo.value.srcObject = null
|
||||
}
|
||||
}
|
||||
|
||||
const resumeCameraStream = async () => {
|
||||
await nextTick()
|
||||
if (cameraVideo.value && cameraStream.value) {
|
||||
cameraVideo.value.srcObject = cameraStream.value
|
||||
try {
|
||||
await cameraVideo.value.play()
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch images on mount
|
||||
onMounted(async () => {
|
||||
loadingImages.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/image/list?type=${typeParam.value}`)
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
const ids = data.ids || []
|
||||
// Fetch URLs for each image id using the cache
|
||||
const urls = await Promise.all(
|
||||
ids.map(async (id: string) => {
|
||||
try {
|
||||
const url = await getCachedImageUrl(id)
|
||||
return { id, url }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
const images = urls.filter(Boolean) as { id: string; url: string }[]
|
||||
// Move the selected image to the front if it exists
|
||||
if (props.modelValue) {
|
||||
const idx = images.findIndex((img) => img.id === props.modelValue)
|
||||
if (idx > 0) {
|
||||
const [selected] = images.splice(idx, 1)
|
||||
images.unshift(selected)
|
||||
}
|
||||
}
|
||||
availableImages.value = images
|
||||
}
|
||||
} catch (err) {
|
||||
// Optionally handle error
|
||||
} finally {
|
||||
loadingImages.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function resizeImageFile(
|
||||
file: File | Blob,
|
||||
maxDim = 512,
|
||||
): Promise<{ blob: Blob; url: string }> {
|
||||
const img = new window.Image()
|
||||
const url = URL.createObjectURL(file)
|
||||
img.src = url
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve
|
||||
})
|
||||
|
||||
let { width, height } = img
|
||||
if (width > maxDim || height > maxDim) {
|
||||
if (width > height) {
|
||||
height = Math.round((height * maxDim) / width)
|
||||
width = maxDim
|
||||
} else {
|
||||
width = Math.round((width * maxDim) / height)
|
||||
height = maxDim
|
||||
}
|
||||
}
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx?.drawImage(img, 0, 0, width, height)
|
||||
const blob: Blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'))
|
||||
URL.revokeObjectURL(url)
|
||||
return { blob, url: URL.createObjectURL(blob) }
|
||||
}
|
||||
|
||||
function updateLocalImage(url: string, file: File) {
|
||||
const idx = availableImages.value.findIndex((img) => img.id === 'local-upload')
|
||||
if (idx === -1) {
|
||||
availableImages.value.unshift({ id: 'local-upload', url })
|
||||
} else {
|
||||
availableImages.value[idx].url = url
|
||||
}
|
||||
emit('add-image', { id: 'local-upload', url, file })
|
||||
emit('update:modelValue', 'local-upload')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="picker">
|
||||
<div class="image-scroll">
|
||||
<div v-if="loadingImages" class="loading-images">Loading images...</div>
|
||||
<div v-else class="image-list">
|
||||
<img
|
||||
v-for="img in availableImages"
|
||||
:key="img.id"
|
||||
:src="img.url"
|
||||
class="selectable-image"
|
||||
:class="{ selected: modelValue === img.id }"
|
||||
:alt="`Image ${img.id}`"
|
||||
@click="selectImage(img.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
|
||||
capture="environment"
|
||||
style="display: none"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div class="image-actions">
|
||||
<button type="button" class="icon-btn" @click="addFromLocal" aria-label="Add from device">
|
||||
<span class="icon">+</span>
|
||||
</button>
|
||||
<button type="button" class="icon-btn" @click="addFromCamera" aria-label="Add from camera">
|
||||
<span class="icon">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect x="3" y="6" width="14" height="10" rx="2" stroke="#667eea" stroke-width="1.5" />
|
||||
<circle cx="10" cy="11" r="3" stroke="#667eea" stroke-width="1.5" />
|
||||
<rect x="7" y="3" width="6" height="3" rx="1" stroke="#667eea" stroke-width="1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Camera modal -->
|
||||
<div v-if="showCamera" class="modal-backdrop">
|
||||
<div class="modal camera-modal">
|
||||
<h3>Take a photo</h3>
|
||||
<div v-if="cameraError" class="error">{{ cameraError }}</div>
|
||||
<div v-else>
|
||||
<div v-if="!capturedImageUrl">
|
||||
<video ref="cameraVideo" autoplay playsinline class="camera-display"></video>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-primary" @click="takePhoto">Capture</button>
|
||||
<button type="button" class="btn btn-secondary" @click="closeCamera">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<img :src="capturedImageUrl" class="camera-display" alt="Preview" />
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-primary" @click="confirmPhoto">Choose</button>
|
||||
<button type="button" class="btn btn-secondary" @click="retakePhoto">Retake</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-scroll {
|
||||
width: 100%;
|
||||
margin: 0.7rem 0 0.2rem 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.image-list {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
min-width: min-content;
|
||||
align-items: center;
|
||||
}
|
||||
.selectable-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--selectable-image-border);
|
||||
background: var(--selectable-image-bg);
|
||||
cursor: pointer;
|
||||
transition: border 0.18s;
|
||||
}
|
||||
.selectable-image:hover,
|
||||
.selectable-image.selected {
|
||||
border-color: var(--selectable-image-selected);
|
||||
box-shadow: 0 0 0 2px #667eea55;
|
||||
}
|
||||
.loading-images {
|
||||
color: var(--loading-text);
|
||||
font-size: 0.98rem;
|
||||
padding: 0.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
gap: 4rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.2rem; /* Increased space below images */
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: var(--icon-btn-bg);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 56px; /* Increased size */
|
||||
height: 56px; /* Increased size */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s;
|
||||
font-size: 2.2rem; /* Bigger + icon */
|
||||
color: var(--icon-btn-color);
|
||||
box-shadow: var(--icon-btn-shadow);
|
||||
}
|
||||
.icon-btn svg {
|
||||
width: 32px; /* Bigger camera icon */
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Camera modal styles */
|
||||
.camera-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--modal-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--modal-shadow);
|
||||
z-index: 1300;
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.camera-display {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
50
frontend/vue-app/src/layout/AuthLayout.vue
Normal file
50
frontend/vue-app/src/layout/AuthLayout.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="layout-root">
|
||||
<header class="topbar">
|
||||
<div class="back-btn-container">
|
||||
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0">← Back</button>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="spacer"></div>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const handleBack = () => {
|
||||
// route to the auth landing page instead of using browser history
|
||||
router.push({ name: 'AuthLanding' }).catch(() => {
|
||||
// fallback to a safe path if named route isn't available
|
||||
window.location.href = '/auth'
|
||||
})
|
||||
}
|
||||
|
||||
// hide back button specifically on the Auth landing route
|
||||
const showBack = computed(
|
||||
() =>
|
||||
route.name !== 'AuthLanding' && route.name !== 'VerifySignup' && route.name !== 'ResetPassword',
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Only keep styles unique to ChildLayout */
|
||||
|
||||
.topbar > .spacer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
</style>
|
||||
49
frontend/vue-app/src/layout/ChildLayout.vue
Normal file
49
frontend/vue-app/src/layout/ChildLayout.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="layout-root">
|
||||
<header class="topbar">
|
||||
<div class="back-btn-container">
|
||||
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0">← Back</button>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="login-btn-container">
|
||||
<LoginButton />
|
||||
</div>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import LoginButton from '../components/shared/LoginButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/child')
|
||||
}
|
||||
}
|
||||
|
||||
const showBack = computed(() => route.path !== '/child')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Only keep styles unique to ChildLayout */
|
||||
|
||||
.topbar > .spacer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
</style>
|
||||
212
frontend/vue-app/src/layout/ParentLayout.vue
Normal file
212
frontend/vue-app/src/layout/ParentLayout.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import LoginButton from '../components/shared/LoginButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/child')
|
||||
}
|
||||
}
|
||||
|
||||
const showBack = computed(
|
||||
() =>
|
||||
!(
|
||||
route.path === '/parent' ||
|
||||
route.name === 'TaskView' ||
|
||||
route.name === 'RewardView' ||
|
||||
route.name === 'NotificationView'
|
||||
),
|
||||
)
|
||||
|
||||
// Version fetching
|
||||
const appVersion = ref('')
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/version')
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
appVersion.value = data.version || ''
|
||||
}
|
||||
} catch (e) {
|
||||
appVersion.value = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-root">
|
||||
<header class="topbar">
|
||||
<div class="back-btn-container">
|
||||
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0">← Back</button>
|
||||
</div>
|
||||
<nav class="view-selector">
|
||||
<button
|
||||
:class="{
|
||||
active: [
|
||||
'ParentChildrenListView',
|
||||
'ParentView',
|
||||
'ChildEditView',
|
||||
'CreateChild',
|
||||
'TaskAssignView',
|
||||
'RewardAssignView',
|
||||
].includes(String(route.name)),
|
||||
}"
|
||||
@click="router.push({ name: 'ParentChildrenListView' })"
|
||||
aria-label="Children"
|
||||
title="Children"
|
||||
>
|
||||
<!-- Children Icon -->
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="8" cy="10" r="3" />
|
||||
<circle cx="16" cy="10" r="3" />
|
||||
<path d="M2 20c0-2.5 3-4.5 6-4.5s6 2 6 4.5" />
|
||||
<path d="M10 20c0-2 2-3.5 6-3.5s6 1.5 6 3.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: ['TaskView', 'EditTask', 'CreateTask'].includes(String(route.name)) }"
|
||||
@click="router.push({ name: 'TaskView' })"
|
||||
aria-label="Tasks"
|
||||
title="Tasks"
|
||||
>
|
||||
<!-- Book Icon -->
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M20 22V6a2 2 0 0 0-2-2H6.5A2.5 2.5 0 0 0 4 6.5v13" />
|
||||
<path d="M16 2v4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
:class="{
|
||||
active: ['RewardView', 'EditReward', 'CreateReward'].includes(String(route.name)),
|
||||
}"
|
||||
@click="router.push({ name: 'RewardView' })"
|
||||
aria-label="Rewards"
|
||||
title="Rewards"
|
||||
>
|
||||
<!-- Trophy Icon -->
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M8 21h8" />
|
||||
<path d="M12 17v4" />
|
||||
<path d="M17 17a5 5 0 0 0 5-5V7h-4" />
|
||||
<path d="M7 17a5 5 0 0 1-5-5V7h4" />
|
||||
<rect x="7" y="2" width="10" height="15" rx="5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: ['NotificationView'].includes(String(route.name)) }"
|
||||
@click="router.push({ name: 'NotificationView' })"
|
||||
aria-label="Notifications"
|
||||
title="Notifications"
|
||||
>
|
||||
<!-- Notification/Bell Icon -->
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 16v-5a6 6 0 1 0-12 0v5" />
|
||||
<path d="M2 16h20" />
|
||||
<path d="M8 20a4 4 0 0 0 8 0" />
|
||||
<circle cx="19" cy="7" r="2" fill="#ef4444" stroke="none" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
<LoginButton class="login-btn-container" />
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<div v-if="appVersion" class="app-version">v{{ appVersion }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Only keep styles unique to ParentLayout */
|
||||
|
||||
.view-selector {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 2 1 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.view-selector button {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--button-bg);
|
||||
color: var(--button-text);
|
||||
border: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s,
|
||||
color 0.18s;
|
||||
font-size: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.08);
|
||||
}
|
||||
|
||||
.view-selector button.active {
|
||||
background: var(--button-active-bg);
|
||||
color: var(--button-active-text);
|
||||
}
|
||||
|
||||
.view-selector button.active svg {
|
||||
stroke: var(--button-active-text);
|
||||
}
|
||||
|
||||
.view-selector button:hover:not(.active) {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.view-selector button {
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
14
frontend/vue-app/src/main.ts
Normal file
14
frontend/vue-app/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import '@/assets/global.css'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import '@/assets/actions-shared.css'
|
||||
import '@/assets/layout-shared.css'
|
||||
import '@/assets/button-shared.css'
|
||||
import '@/assets/view-shared.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
205
frontend/vue-app/src/router/index.ts
Normal file
205
frontend/vue-app/src/router/index.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { watch } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import ChildLayout from '../layout/ChildLayout.vue'
|
||||
import ParentLayout from '../layout/ParentLayout.vue'
|
||||
import ChildrenListView from '../components/shared/ChildrenListView.vue'
|
||||
import ChildView from '../components/child/ChildView.vue'
|
||||
import ParentView from '../components/child/ParentView.vue'
|
||||
import TaskView from '../components/task/TaskView.vue'
|
||||
import RewardView from '../components/reward/RewardView.vue'
|
||||
import TaskEditView from '@/components/task/TaskEditView.vue'
|
||||
import RewardEditView from '@/components/reward/RewardEditView.vue'
|
||||
import ChildEditView from '@/components/child/ChildEditView.vue'
|
||||
import TaskAssignView from '@/components/child/TaskAssignView.vue'
|
||||
import RewardAssignView from '@/components/child/RewardAssignView.vue'
|
||||
import NotificationView from '@/components/notification/NotificationView.vue'
|
||||
import AuthLayout from '@/layout/AuthLayout.vue'
|
||||
import Signup from '@/components/auth/Signup.vue'
|
||||
import AuthLanding from '@/components/auth/AuthLanding.vue'
|
||||
import Login from '@/components/auth/Login.vue'
|
||||
import { isUserLoggedIn, isParentAuthenticated, isAuthReady } from '../stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/auth',
|
||||
component: AuthLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'AuthLanding',
|
||||
component: AuthLanding,
|
||||
},
|
||||
{
|
||||
path: 'signup',
|
||||
name: 'Signup',
|
||||
component: Signup,
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
},
|
||||
{
|
||||
path: 'verify',
|
||||
name: 'VerifySignup',
|
||||
component: () => import('@/components/auth/VerifySignup.vue'),
|
||||
},
|
||||
{
|
||||
path: 'forgot-password',
|
||||
name: 'ForgotPassword',
|
||||
component: () => import('@/components/auth/ForgotPassword.vue'),
|
||||
},
|
||||
{
|
||||
path: 'reset-password',
|
||||
name: 'ResetPassword',
|
||||
component: () => import('@/components/auth/ResetPassword.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/child',
|
||||
component: ChildLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ChildrenListView',
|
||||
component: ChildrenListView,
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
name: 'ChildView',
|
||||
component: ChildView,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/parent',
|
||||
component: ParentLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ParentChildrenListView',
|
||||
component: ChildrenListView,
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
name: 'ParentView',
|
||||
component: ParentView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'children/create',
|
||||
name: 'CreateChild',
|
||||
component: ChildEditView,
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
name: 'ChildEditView',
|
||||
component: ChildEditView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'TaskView',
|
||||
component: TaskView,
|
||||
props: false,
|
||||
},
|
||||
{
|
||||
path: 'tasks/create',
|
||||
name: 'CreateTask',
|
||||
component: TaskEditView,
|
||||
},
|
||||
{
|
||||
path: 'tasks/:id/edit',
|
||||
name: 'EditTask',
|
||||
component: TaskEditView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'rewards',
|
||||
name: 'RewardView',
|
||||
component: RewardView,
|
||||
props: false,
|
||||
},
|
||||
{
|
||||
path: 'rewards/create',
|
||||
name: 'CreateReward',
|
||||
component: RewardEditView,
|
||||
},
|
||||
{
|
||||
path: 'rewards/:id/edit',
|
||||
name: 'EditReward',
|
||||
component: RewardEditView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: ':id/assign-tasks/:type?',
|
||||
name: 'TaskAssignView',
|
||||
component: TaskAssignView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: ':id/assign-rewards',
|
||||
name: 'RewardAssignView',
|
||||
component: RewardAssignView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'NotificationView',
|
||||
component: NotificationView,
|
||||
props: false,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'UserProfile',
|
||||
component: () => import('@/components/profile/UserProfile.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/child',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// Auth guard
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
if (!isAuthReady.value) {
|
||||
await new Promise((resolve) => {
|
||||
const stop = watch(isAuthReady, (ready) => {
|
||||
if (ready) {
|
||||
stop()
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Always allow access to /auth routes
|
||||
if (to.path.startsWith('/auth')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// If not logged in, redirect to /auth
|
||||
if (!isUserLoggedIn.value) {
|
||||
return next('/auth')
|
||||
}
|
||||
|
||||
// If logged in but not parent-authenticated, redirect to /child (unless already there)
|
||||
if (!isParentAuthenticated.value && !to.path.startsWith('/child')) {
|
||||
return next('/child')
|
||||
}
|
||||
|
||||
// Otherwise, allow navigation
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
40
frontend/vue-app/src/stores/auth.ts
Normal file
40
frontend/vue-app/src/stores/auth.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const isParentAuthenticated = ref(false)
|
||||
export const isUserLoggedIn = ref(false)
|
||||
export const isAuthReady = ref(false)
|
||||
export const currentUserId = ref('')
|
||||
|
||||
export function authenticateParent() {
|
||||
isParentAuthenticated.value = true
|
||||
}
|
||||
|
||||
export function logoutParent() {
|
||||
isParentAuthenticated.value = false
|
||||
}
|
||||
|
||||
export function loginUser() {
|
||||
isUserLoggedIn.value = true
|
||||
}
|
||||
|
||||
export function logoutUser() {
|
||||
isUserLoggedIn.value = false
|
||||
}
|
||||
|
||||
export async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch('/api/me', { method: 'GET' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
currentUserId.value = data.id
|
||||
isUserLoggedIn.value = true
|
||||
} else {
|
||||
isUserLoggedIn.value = false
|
||||
currentUserId.value = ''
|
||||
}
|
||||
} catch {
|
||||
isUserLoggedIn.value = false
|
||||
currentUserId.value = ''
|
||||
}
|
||||
isAuthReady.value = true
|
||||
}
|
||||
12
frontend/vue-app/tsconfig.app.json
Normal file
12
frontend/vue-app/tsconfig.app.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
frontend/vue-app/tsconfig.json
Normal file
14
frontend/vue-app/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
frontend/vue-app/tsconfig.node.json
Normal file
19
frontend/vue-app/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
11
frontend/vue-app/tsconfig.vitest.json
Normal file
11
frontend/vue-app/tsconfig.vitest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
33
frontend/vue-app/vite.config.ts
Normal file
33
frontend/vue-app/vite.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
//import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import fs from 'fs'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue() /*vueDevTools()*/],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
https: {
|
||||
key: fs.readFileSync('./192.168.1.102+1-key.pem'),
|
||||
cert: fs.readFileSync('./192.168.1.102+1.pem'),
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://192.168.1.102:5000',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||
},
|
||||
'/events': {
|
||||
target: 'http://192.168.1.102:5000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
14
frontend/vue-app/vitest.config.ts
Normal file
14
frontend/vue-app/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user