Initial Code
First Commit
24
README.md
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
# eira-frontend
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and hot-reloads for development
|
||||||
|
```
|
||||||
|
npm run serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and minifies for production
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lints and fixes files
|
||||||
|
```
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customize configuration
|
||||||
|
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||||
5
babel.config.js
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
'@vue/cli-plugin-babel/preset'
|
||||||
|
]
|
||||||
|
}
|
||||||
24
certs/dev-cert.pem
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIEDzCCAnegAwIBAgIQQBHdovjLripPF2f5sLZniTANBgkqhkiG9w0BAQsFADBZ
|
||||||
|
MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExFzAVBgNVBAsMDmthc2Jl
|
||||||
|
cmdlbkBlaXJhMR4wHAYDVQQDDBVta2NlcnQga2FzYmVyZ2VuQGVpcmEwHhcNMjUw
|
||||||
|
NjEzMTEzOTQyWhcNMjcwOTEzMTEzOTQyWjBCMScwJQYDVQQKEx5ta2NlcnQgZGV2
|
||||||
|
ZWxvcG1lbnQgY2VydGlmaWNhdGUxFzAVBgNVBAsMDmthc2JlcmdlbkBlaXJhMIIB
|
||||||
|
IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7qTK1LJznOcjXCpcxO6Uz7JL
|
||||||
|
uqarSkCuQ1We3oAD1nZ/fATRphusVFJj4qLAhylmZNvPUWg3OJKmW5pjgXM4YMjp
|
||||||
|
QJazkCM4WT5AkWZJoRFsxw9Tv0e/m/Pzg61Hw7ZqqZS+5vjVajmy3JkMWDaxdQ3f
|
||||||
|
ifEFh2h+48Jvq3qUoQ2E3tkKF+4oD3B7dGends2ZPBkLD6MLtm1js8fvFeGAgp8c
|
||||||
|
r2O/Xp7raLvJGplq6Hp9gyNz+S2Zs5DnDiKPHP1ovA+gXSQxwU+sf8I9LOvfdPIi
|
||||||
|
6Gxojjw12+rUM+3mufJ6Cb6HWKJrz4hscBzd7nPq8IxBSeyd/bR7Dn8aHu4orwID
|
||||||
|
AQABo2owaDAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHwYD
|
||||||
|
VR0jBBgwFoAUvPKMZrOlO33j8c5l4UvXL9Nsm0kwIAYDVR0RBBkwF4IJbG9jYWxo
|
||||||
|
b3N0hwR/AAABhwTAqEYLMA0GCSqGSIb3DQEBCwUAA4IBgQBk9LIomN/MRVZD+LYX
|
||||||
|
Q9m0CklNBuVbSbRNFc5b+DfA+P5cQhiQvh2onhA/p9aQfMzfoHmSNVltC6vSHwDl
|
||||||
|
G5qL8lV6sQ90tITRRwPkND5Dcp7CRhOfEXZ3daOlJvq2e81J/usYnhcQN+PWNRI1
|
||||||
|
cZThDLwRaK3v4IkqRyOgld1I8NB+PAzcNRCUS72m/1a05calrWssQC/csPxXhoqP
|
||||||
|
eAq5RkrFKgSFLw883Rr7rBVm9P9CmuCKpGXY+Iqs9OV0eVqurzyyDBmN+Q5C5ukn
|
||||||
|
VDZ6Rw9gqrChqX63clSxoLC9QmGWgSUV2vFL5iO1IL0pygIrDx45k7mWawbpRtfE
|
||||||
|
bKzuE8wuHK/ca++10IWP8K7T9AznIAhL/WEpEJKSRGTWKLCV6HH5Gt2YWiH0QXL9
|
||||||
|
/fu3L70l3kjQOAv33WmB6gy0mV9De0KJhlOMfXlsygV8P1OhS/Tcl9ySAsDVyjHV
|
||||||
|
T6LXcAM/Eqn7b5+hk0M8HLE0Z05yM3AuBSLcjGm9Dt4NqKE=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
28
certs/dev-key.pem
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDupMrUsnOc5yNc
|
||||||
|
KlzE7pTPsku6pqtKQK5DVZ7egAPWdn98BNGmG6xUUmPiosCHKWZk289RaDc4kqZb
|
||||||
|
mmOBczhgyOlAlrOQIzhZPkCRZkmhEWzHD1O/R7+b8/ODrUfDtmqplL7m+NVqObLc
|
||||||
|
mQxYNrF1Dd+J8QWHaH7jwm+repShDYTe2QoX7igPcHt0Z6d2zZk8GQsPowu2bWOz
|
||||||
|
x+8V4YCCnxyvY79enutou8kamWroen2DI3P5LZmzkOcOIo8c/Wi8D6BdJDHBT6x/
|
||||||
|
wj0s69908iLobGiOPDXb6tQz7ea58noJvodYomvPiGxwHN3uc+rwjEFJ7J39tHsO
|
||||||
|
fxoe7iivAgMBAAECggEARY+Va+BEYOTi4reaaPDeQZhICLUSUsd9xDTN5S9NbxMF
|
||||||
|
YAILli6U0dNeOC6Wjg9cQGPuD12gUwY0JZlgDdinA1cs3l3PI2GQyDqkGX3GUoPA
|
||||||
|
wFlQYP4p9Oxr++Lje0HN33ZzGuJHWvpMj46xEXmHyoXrtcqigPDNo9gGMua9MiAt
|
||||||
|
j8Ury8rDXx4Cb1Cdccn85U5xRlebz3lmKNJm5tV9AaK4JmTk4iJY7/d0R4TUTipT
|
||||||
|
ckNL9CNGS9S4TN0EPOTdbw/U6rkW6289TRBI7gt0Wvmj8dR4LLyS+21HOqJpxnUp
|
||||||
|
5u5D+tXI7pCsiB4bMpdtAb7a2ArBQnk1s3j1X/yvuQKBgQDyzNNMJRuwylX0KlLO
|
||||||
|
BaTqv14WGUsf3u2h9EYPOcBCxvZ5JjOe19+ArkbWjKjF7d2qAuB9S7aDfsXmOgYQ
|
||||||
|
oOWUPstF7NKg9Yf0xLNiO/pciHXgNm2qGmE7Dyp5UM63vlGDvl/VpXi+CgngkJk/
|
||||||
|
cP85z5jXTQNM0ta5Nf89AL0HQwKBgQD7nh7VpF1utVGc3Fb1vVaH3/lN2q9tkA4p
|
||||||
|
QTVbtYeWRaO1yV1up24EnyPoeiwKSnX+w9wfAkb8DzPufPXKYEH8PN8YZ4cG8iiY
|
||||||
|
6usuiE2OypSxEsWWphKLTZy9W/NKJgfL/G+AD6ITqxKgZJPv9YHiSAsjrJBsh4BW
|
||||||
|
FC3I0Ou0JQKBgQC/EBemb/0eXdrNzRBkN2TTpcvwL+9CITd2nrcS/CsjYVZLd5hf
|
||||||
|
gxjKNlpgM0gMmWY5hxIJBy+UwooQ5dAn/bUrt05WtEn7h7t5DeDriK83adr4mVwq
|
||||||
|
459nzkNqPACv7wBOX90iTph5U4T6Rk+R8OWnJIntwxi5t6BpFujHpHEb6QKBgQCn
|
||||||
|
f4QRPmCAMS3KGRe+cLMosl14iUpcyhfTOLh3e2luDJ/FhO2wmr5vTy0NNj8Y/qZ7
|
||||||
|
2RAwiEiOxOQSL5dDiD6E1lTBHzHQ2uVRnpi+mjffBVZkZhWoipcvqpPbrllPk+9+
|
||||||
|
yGXFPyLqqug0Y5/jjnBPHRxlPBvMU4uIQdiUpRczDQKBgQC+1f/8kxRDaV0ZhbrJ
|
||||||
|
Jn7qdJydV1TEN0XtVC7OVRjU/juk/zPhPEHwYA5XnREgvPqmBtO2ODrgybKRWVXc
|
||||||
|
xeJHlvg6qyKVNzYyzLp/4wsi8n0uLz8PLd5z54rkVVoSnbMG1qS/IXrI0+ktBx4p
|
||||||
|
4MlttsHMJ8jq9OrHb/42ukRilA==
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
19
jsconfig.json
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"module": "esnext",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lib": [
|
||||||
|
"esnext",
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"scripthost"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
13088
package-lock.json
generated
Executable file
54
package.json
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "eira-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build",
|
||||||
|
"lint": "vue-cli-service lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vuepic/vue-datepicker": "^11.0.2",
|
||||||
|
"@vueuse/motion": "^3.0.3",
|
||||||
|
"axios": "^1.9.0",
|
||||||
|
"core-js": "^3.8.3",
|
||||||
|
"gridstack": "^12.2.1",
|
||||||
|
"lucide-vue-next": "^0.513.0",
|
||||||
|
"mobile-device-detect": "^0.4.3",
|
||||||
|
"vue": "^3.2.13",
|
||||||
|
"vue-mobile-detection": "^2.0.1",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.12.16",
|
||||||
|
"@babel/eslint-parser": "^7.12.16",
|
||||||
|
"@vue/cli-plugin-babel": "~5.0.0",
|
||||||
|
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||||
|
"@vue/cli-service": "~5.0.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"eslint": "^7.32.0",
|
||||||
|
"eslint-plugin-vue": "^8.0.3",
|
||||||
|
"postcss": "^8.5.4",
|
||||||
|
"tailwindcss": "^4.1.8"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"plugin:vue/vue3-essential",
|
||||||
|
"eslint:recommended"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"parser": "@babel/eslint-parser"
|
||||||
|
},
|
||||||
|
"rules": {}
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"> 1%",
|
||||||
|
"last 2 versions",
|
||||||
|
"not dead",
|
||||||
|
"not ie 11"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
public/.DS_Store
vendored
Executable file
BIN
public/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 15 KiB |
1
public/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 105 KiB |
21
public/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Eira Personal AI Assistant",
|
||||||
|
"short_name": "Eira",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#6f42c1",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
public/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 30 KiB |
17
public/index.html
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Eira" />
|
||||||
|
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||||
|
<title>Eira - Personal AI Assistant</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- Bootstrap Bundle JS (includes Popper.js) -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
BIN
public/logo.png
Executable file
|
After Width: | Height: | Size: 78 KiB |
49
public/sw.js
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
self.addEventListener('push', event => {
|
||||||
|
let data = {
|
||||||
|
title: 'Eira',
|
||||||
|
body: 'You have a new notification',
|
||||||
|
icon: '/icons/eira-icon.png',
|
||||||
|
badge: '/icons/eira-badge.png',
|
||||||
|
data: {
|
||||||
|
url: '/' // default fallback
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
const json = event.data.json();
|
||||||
|
data.title = json.title || data.title;
|
||||||
|
data.body = json.body || data.body;
|
||||||
|
data.icon = json.icon || data.icon;
|
||||||
|
data.badge = json.badge || data.badge;
|
||||||
|
data.data.url = json.data.url || data.data.url;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing push event data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(data.title, {
|
||||||
|
body: data.body,
|
||||||
|
icon: data.icon,
|
||||||
|
badge: data.badge,
|
||||||
|
data: { url: data.data.url }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', event => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
const urlToOpen = event.notification.data.url || '/';
|
||||||
|
|
||||||
|
event.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then(windowClients => {
|
||||||
|
for (const client of windowClients) {
|
||||||
|
if (client.url === urlToOpen && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow(urlToOpen);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
27
rootCA.pem
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIEgTCCAumgAwIBAgIQcJidRwhjY+0dmsH3iDYUrTANBgkqhkiG9w0BAQsFADBZ
|
||||||
|
MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExFzAVBgNVBAsMDmthc2Jl
|
||||||
|
cmdlbkBlaXJhMR4wHAYDVQQDDBVta2NlcnQga2FzYmVyZ2VuQGVpcmEwHhcNMjUw
|
||||||
|
NjEzMTEyODE0WhcNMzUwNjEzMTEyODE0WjBZMR4wHAYDVQQKExVta2NlcnQgZGV2
|
||||||
|
ZWxvcG1lbnQgQ0ExFzAVBgNVBAsMDmthc2JlcmdlbkBlaXJhMR4wHAYDVQQDDBVt
|
||||||
|
a2NlcnQga2FzYmVyZ2VuQGVpcmEwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGK
|
||||||
|
AoIBgQDAYNIuyJfC5wEKb9ZCCujOmwKO9tJVq2Mwa8WbElZQDjJ3l4ETJ4yAv518
|
||||||
|
FepuCkfyEKPxOENp8BBJ5lwL8wJRtss6zHGS8ZN1nEtzC2Pm5SNSGPwr0IZ2Ut9T
|
||||||
|
GYtXv6utO04ltg2cBNauCgspDXUNlNNViV7OtQJaRmwll/W8kKeF+7XUHbkRR9FC
|
||||||
|
sVwyXqY2KBxIF1/1ZqUo5pNbuKeHQjDe/8mQ73JnIfPYfhT4qbTF9BLoqnJ8UYS9
|
||||||
|
IMaSIjdAUVz+J+jB01CyC2bRwODgQE/Yqip2VsuA09P/s30s0ukwjMIJFCTFM8bJ
|
||||||
|
vv2fCjbjYDv6KjYhEUwMwrvleQsSU//Kmpg8zMY1jjaXc/MA1cyMiQUq6YNjAnBX
|
||||||
|
pwZR0TmeDO7yxfQJJP6DoxuhiVVex35c/2YVfVNIBw+uz1Dg3+rglX+T/184gMuU
|
||||||
|
4DmIYIDFS6IWlX7GQMAzDSevw74ubIwOv1sMP8TvTW64LvmnnXHXN4eZHebgABhj
|
||||||
|
DD0urO8CAwEAAaNFMEMwDgYDVR0PAQH/BAQDAgIEMBIGA1UdEwEB/wQIMAYBAf8C
|
||||||
|
AQAwHQYDVR0OBBYEFLzyjGazpTt94/HOZeFL1y/TbJtJMA0GCSqGSIb3DQEBCwUA
|
||||||
|
A4IBgQB42MPfR2s6KxJ1Mvsd0GZoMRC/E/EHT/7BINFO87f2M3YWwilMcwjdlBUi
|
||||||
|
++LqvEnib2cW8f/yxfSwwUYCyVmlCiXzokusUoN5qK2Ep89TCpCKqw0ZMIjKlCbH
|
||||||
|
4dcxD/OxAHE/z1T9trfF7eLC9u9YQG6d8UD6GLdEi/o8AE5woWhV7+Rfu3VcPt2i
|
||||||
|
xUsodIckt2XX+RD/NgIQvhiAlkl5Q6VtCpXq8Xr0nFR3drPwI/3GfTIrUTHlNkBA
|
||||||
|
SFmyGkKTxkwh3QmJ7i4c65MBaOPybVXKuLzYg7AuzBvxlHIvdakji8Aqf1TPOcO+
|
||||||
|
cAIz+6SW+Vag8MDjvLs/OVaHswA0blcAEUb0EFCQp0JoavTSGE88yshP+YN6Glmz
|
||||||
|
EQe6wOfuf5Pbsvol+xtaOgvO1OaPLSAI2/fMVD/Mq9Cr7tvNxHcFdGcBp4RkarTD
|
||||||
|
BXP7bFCWNtB0D9i32+XQR2h4BMmTG+QISmhdv8t1HRzkdhymcIWt11DX1n3+ETwh
|
||||||
|
T95sbQE=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
BIN
src/.DS_Store
vendored
Executable file
57
src/App.vue
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<AppPopups />
|
||||||
|
<div id="app" class="app-container d-flex flex-column flex-md-row">
|
||||||
|
<!-- Desktop Sidebar -->
|
||||||
|
<AppSidebar v-if="!isMobile" />
|
||||||
|
<!-- Main content area -->
|
||||||
|
<div class="d-flex flex-column flex-grow-1 min-vh-100">
|
||||||
|
<!-- Mobile Menu -->
|
||||||
|
<AppMobileMenu v-if="isMobile" />
|
||||||
|
<!-- Header -->
|
||||||
|
<AppHeader v-if="!isMobile" />
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-grow-1 overflow-auto px-3 py-2">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
<!-- Footer -->
|
||||||
|
<AppFooter />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import AppHeader from "./components/AppHeader.vue";
|
||||||
|
import AppSidebar from "./components/AppSidebar.vue";
|
||||||
|
import AppFooter from "./components/AppFooter.vue";
|
||||||
|
import AppMobileMenu from "./components/AppMobileMenu.vue";
|
||||||
|
import AppPopups from "./components/AppPopups.vue"
|
||||||
|
import { isMobileOnly } from "mobile-device-detect";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "App",
|
||||||
|
components: {
|
||||||
|
AppHeader,
|
||||||
|
AppSidebar,
|
||||||
|
AppFooter,
|
||||||
|
AppMobileMenu,
|
||||||
|
AppPopups
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isMobile: isMobileOnly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: hide scrollbars on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
main {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
src/assets/eira-avatar.png
Executable file
|
After Width: | Height: | Size: 1.4 MiB |
10
src/assets/main.css
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
.btn-primary {
|
||||||
|
background-color: #6f42c1 !important;
|
||||||
|
border-color: #6f42c1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #59359e !important;
|
||||||
|
/* optional: darker shade for hover */
|
||||||
|
border-color: #59359e !important;
|
||||||
|
}
|
||||||
109
src/assets/surprises.json
Executable file
@@ -0,0 +1,109 @@
|
|||||||
|
{
|
||||||
|
"surprises": [
|
||||||
|
"Did you know? The first computer virus was created in 1983 and was called 'Elk Cloner.'",
|
||||||
|
"Fun fact: The first video game, 'Tennis for Two,' was made in 1958 on an oscilloscope.",
|
||||||
|
"Tech tip: Using a dark mode UI can reduce eye strain and save battery life on OLED screens.",
|
||||||
|
"Gaming trivia: The longest game of chess ever played lasted 20 hours and 15 minutes!",
|
||||||
|
"Movie fact: 'Blade Runner' inspired the look of many future cyberpunk stories and films.",
|
||||||
|
"Design tip: White space is not empty space — it helps create focus and balance in layouts.",
|
||||||
|
"Programming trivia: The name 'Python' comes from 'Monty Python’s Flying Circus,' not the snake.",
|
||||||
|
"Entertainment fact: The lightsaber sound effect was created by mixing humming from an old film projector and interference from a TV set.",
|
||||||
|
"Sci-fi lore: The word 'robot' was first introduced in Karel Čapek’s 1920 play 'R.U.R.'",
|
||||||
|
"Did you know? The first webcam was used to monitor a coffee pot at Cambridge University.",
|
||||||
|
"Tech tip: Git was created by Linus Torvalds to help develop the Linux kernel efficiently.",
|
||||||
|
"Gaming fact: The original 'Doom' popularized 3D graphics in video games in 1993.",
|
||||||
|
"Movie trivia: 'The Matrix' was inspired by Japanese anime like 'Ghost in the Shell.'",
|
||||||
|
"Design tip: Using a grid system can improve consistency in UI and graphic designs.",
|
||||||
|
"Programming fun: The first computer bug was an actual moth stuck in a relay!",
|
||||||
|
"Entertainment fact: 'Star Wars' stormtroopers' armor was inspired by samurai armor.",
|
||||||
|
"Sci-fi fact: Isaac Asimov’s Three Laws of Robotics influence many AI ethics discussions today.",
|
||||||
|
"Did you know? The world’s first website is still online at info.cern.ch.",
|
||||||
|
"Tech tip: Using meaningful variable names can save debugging time later.",
|
||||||
|
"Gaming trivia: 'Minecraft' was created by Markus Persson in just six days.",
|
||||||
|
"Movie fact: The 'Alien' xenomorph design was inspired by insect anatomy.",
|
||||||
|
"Design tip: Contrast is key for readability—make sure text stands out against backgrounds.",
|
||||||
|
"Programming fun: 'Hello, World!' is traditionally the first program learned in many languages.",
|
||||||
|
"Entertainment fact: 'The Big Lebowski' became a cult classic despite poor initial reviews.",
|
||||||
|
"Sci-fi lore: The phrase 'May the Force be with you' is one of the most quoted movie lines ever.",
|
||||||
|
"Did you know? The first emoji was created in 1999 by Shigetaka Kurita in Japan.",
|
||||||
|
"Tech tip: Keyboard shortcuts can drastically improve coding efficiency.",
|
||||||
|
"Gaming fact: Speedrunning communities can complete games in minutes by exploiting glitches.",
|
||||||
|
"Movie trivia: The sound of the T-800 endoskeleton in 'Terminator' was created with a human skull and a hammer.",
|
||||||
|
"Design tip: Consistency in iconography helps users navigate software intuitively.",
|
||||||
|
"Programming trivia: The first computer programmer was Ada Lovelace in the 1800s.",
|
||||||
|
"Entertainment fact: 'Fight Club' was adapted from Chuck Palahniuk’s novel of the same name.",
|
||||||
|
"Sci-fi fact: 'Neuromancer' by William Gibson is credited with popularizing the cyberpunk genre.",
|
||||||
|
"Did you know? The Linux mascot, Tux the penguin, was chosen by Linus Torvalds himself.",
|
||||||
|
"Tech tip: Using version control like Git prevents lost code and eases collaboration.",
|
||||||
|
"Gaming trivia: 'Pac-Man' was originally called 'Pakkuman' in Japan.",
|
||||||
|
"Movie fact: '2001: A Space Odyssey' influenced visual effects in countless sci-fi movies.",
|
||||||
|
"Design tip: Use a limited color palette to create a cohesive visual identity.",
|
||||||
|
"Programming fun: The term 'bug' in software predates computers and was used in hardware.",
|
||||||
|
"Entertainment fact: 'The Rocky Horror Picture Show' holds the record for longest-running theatrical release.",
|
||||||
|
"Sci-fi lore: The phrase 'I’ll be back' was improvised by Arnold Schwarzenegger in 'The Terminator.'",
|
||||||
|
"Did you know? CAPTCHA tests are used to differentiate humans from bots online.",
|
||||||
|
"Tech tip: Writing modular code improves maintainability and scalability.",
|
||||||
|
"Gaming fact: 'The Legend of Zelda' was one of the first open-world games.",
|
||||||
|
"Movie trivia: 'The Fifth Element' costumes were designed by Jean-Paul Gaultier.",
|
||||||
|
"Design tip: Negative space can form interesting shapes that add meaning to your design.",
|
||||||
|
"Programming trivia: Java was originally called Oak.",
|
||||||
|
"Entertainment fact: 'The Princess Bride' is famous for its witty dialogue and quotable lines.",
|
||||||
|
"Sci-fi fact: The 'Tricorder' device from Star Trek inspired real-world portable diagnostic tools.",
|
||||||
|
"Did you know? The first computer mouse was made of wood.",
|
||||||
|
"Tech tip: Automated testing helps catch bugs before deployment.",
|
||||||
|
"Gaming trivia: The Konami Code (↑↑↓↓←→←→BA) unlocks cheats in many classic games.",
|
||||||
|
"Movie fact: 'Mad Max: Fury Road' used practical effects instead of CGI for most stunts.",
|
||||||
|
"Design tip: Typography affects mood—choose fonts that reflect your brand’s personality.",
|
||||||
|
"Programming fun: The emoji 😄 has a Unicode codepoint of U+1F604.",
|
||||||
|
"Entertainment fact: 'Donnie Darko' became a cult hit after its DVD release.",
|
||||||
|
"Sci-fi lore: The ‘Prime Directive’ in Star Trek forbids interference with alien civilizations.",
|
||||||
|
"Did you know? The first 1GB hard drive weighed over 500 pounds.",
|
||||||
|
"Tech tip: Refactoring code regularly keeps your project clean and efficient.",
|
||||||
|
"Gaming fact: 'Half-Life' was praised for integrating story and gameplay seamlessly.",
|
||||||
|
"Movie trivia: 'Inception' used rotating sets to create zero-gravity effects.",
|
||||||
|
"Design tip: Use color psychology to evoke emotions in your users.",
|
||||||
|
"Programming trivia: The first email was sent in 1971 by Ray Tomlinson.",
|
||||||
|
"Entertainment fact: 'The Shawshank Redemption' initially underperformed but became iconic over time.",
|
||||||
|
"Sci-fi fact: The movie 'Her' explores themes of AI-human relationships.",
|
||||||
|
"Did you know? Early video game graphics used only a few pixels per character.",
|
||||||
|
"Tech tip: Keyboard-driven development can speed up your workflow.",
|
||||||
|
"Gaming trivia: The famous 'Zelda' chest opening sound is from an old cassette tape.",
|
||||||
|
"Movie fact: The voice of HAL 9000 in '2001: A Space Odyssey' is Douglas Rain.",
|
||||||
|
"Design tip: Use alignment to create order and organization in layouts.",
|
||||||
|
"Programming fun: The Python logo is inspired by the Monty Python comedy troupe.",
|
||||||
|
"Entertainment fact: 'The Room' is famous for its bizarre dialogue and cult following.",
|
||||||
|
"Sci-fi lore: 'Dune' features one of the most complex fictional universes ever created.",
|
||||||
|
"Did you know? The QWERTY keyboard was designed to prevent typewriter jams.",
|
||||||
|
"Tech tip: Continuous integration automates testing and deployment.",
|
||||||
|
"Gaming fact: 'Portal' combines puzzle-solving with storytelling in a unique way.",
|
||||||
|
"Movie trivia: 'Ghostbusters' proton packs were inspired by particle accelerators.",
|
||||||
|
"Design tip: Responsive design ensures your site looks good on all devices.",
|
||||||
|
"Programming trivia: The first computer bug was actually a dead moth found in a Harvard Mark II.",
|
||||||
|
"Entertainment fact: 'Pulp Fiction' changed the narrative style of modern movies.",
|
||||||
|
"Sci-fi fact: 'Star Wars' lightsabers were inspired by samurai swords.",
|
||||||
|
"Did you know? The first website was published in 1991 by Tim Berners-Lee.",
|
||||||
|
"Tech tip: Use linters to catch syntax errors before running code.",
|
||||||
|
"Gaming trivia: 'Final Fantasy' was named because it was supposed to be the last game by its creator.",
|
||||||
|
"Movie fact: 'The Lord of the Rings' trilogy was filmed over 8 years.",
|
||||||
|
"Design tip: Use hierarchy to guide users through your content.",
|
||||||
|
"Programming fun: The JavaScript language was created in just 10 days.",
|
||||||
|
"Entertainment fact: 'Back to the Future' used a DeLorean as a time machine.",
|
||||||
|
"Sci-fi lore: The 'Matrix' movie introduced 'bullet time' visual effects.",
|
||||||
|
"Did you know? The term 'debugging' was popularized by Grace Hopper.",
|
||||||
|
"Tech tip: Profiling tools help optimize your program’s performance.",
|
||||||
|
"Gaming fact: 'The Witcher 3' features over 100 hours of gameplay content.",
|
||||||
|
"Movie trivia: 'Jurassic Park' was groundbreaking in CGI dinosaur effects.",
|
||||||
|
"Design tip: Use consistent spacing to create a balanced layout.",
|
||||||
|
"Programming trivia: The first computer game was 'Spacewar!' created in 1962.",
|
||||||
|
"Entertainment fact: 'Fight Club' explores themes of identity and consumerism.",
|
||||||
|
"Sci-fi fact: The 'Star Trek' communicator inspired early mobile phones.",
|
||||||
|
"Did you know? The first emoji was just 176 pixels.",
|
||||||
|
"Tech tip: Keeping dependencies updated prevents security vulnerabilities.",
|
||||||
|
"Gaming trivia: 'Tetris' was originally developed in the Soviet Union.",
|
||||||
|
"Movie fact: 'The Godfather' features an iconic opening with a wedding scene.",
|
||||||
|
"Design tip: Minimalism often leads to clearer communication.",
|
||||||
|
"Programming fun: The C programming language was created in 1972.",
|
||||||
|
"Entertainment fact: 'Stranger Things' pays homage to 80s sci-fi and horror.",
|
||||||
|
"Sci-fi lore: The 'Doctor Who' TARDIS is bigger on the inside."
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
src/components/.DS_Store
vendored
Executable file
37
src/components/AppFooter.vue
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="app-footer bg-transparent py-2 px-3 text-purple fw-semibold overflow-hidden">
|
||||||
|
<div class="scrolling-text">
|
||||||
|
Welcome to Eira — Your personal AI assistant ready to surprise you every day!
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "AppFooter",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.app-footer {
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrolling-text {
|
||||||
|
display: inline-block;
|
||||||
|
animation: scrollLeft 15s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scrollLeft {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-purple {
|
||||||
|
color: #6b46c1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
src/components/AppHeader.vue
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<header class="app-header border-bottom py-3 px-4 text-purple d-flex align-items-center gap-2">
|
||||||
|
<component :is="getIcon()" class="view-icon" />
|
||||||
|
<h1 class="h4 text-purple m-0">{{ currentViewName }}</h1>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import Icons from "@/components/icons";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "AppHeader",
|
||||||
|
setup() {
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const currentViewName = computed(() => route.name || "Unknown View");
|
||||||
|
|
||||||
|
const getIcon = () => Icons[route.meta.icon] || Icons["dashboard"];
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentViewName,
|
||||||
|
getIcon
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.text-purple {
|
||||||
|
color: #6b46c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-icon {
|
||||||
|
height: 1.5em;
|
||||||
|
/* Match approx height of .h4 (1.5rem) */
|
||||||
|
width: auto;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
109
src/components/AppMobileMenu.vue
Executable file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="mobile-menu bg-white border-bottom p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<img src="@/assets/eira-avatar.png" alt="Eira" class="rounded-circle border" style="width: 50px; height: 50px; object-fit: cover; border-color: #6b46c1;" />
|
||||||
|
<span class="fw-semibold text-purple fs-5">Eira – {{ currentViewName }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-purple menu-toggle-btn" @click="toggleMenu" aria-label="Toggle menu">
|
||||||
|
<Menu size="28" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref="menuRef" class="menu-list mt-4" v-show="visible">
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li v-for="route in sidebarRoutes" :key="route.path" class="mb-2">
|
||||||
|
<router-link :to="route.path" class="link-button text-purple text-decoration-none d-flex align-items-center gap-2" active-class="active-link" exact @click="menuOpen = false">
|
||||||
|
<component :is="getIcon(route.meta.icon)" />
|
||||||
|
{{ route.name }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { defineComponent, ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useMotion } from '@vueuse/motion';
|
||||||
|
import router from '@/router';
|
||||||
|
import Icons from '@/components/icons';
|
||||||
|
import { Menu } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'AppMobileMenu',
|
||||||
|
components: {
|
||||||
|
Menu
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const sidebarRoutes = computed(() =>
|
||||||
|
router.options.routes.filter((r) => r.meta && r.meta.sidebar)
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentViewName = computed(() => route.name || 'Unknown View');
|
||||||
|
|
||||||
|
const getIcon = (name) => Icons[name] || Icons.dashboard;
|
||||||
|
|
||||||
|
const menuOpen = ref(false);
|
||||||
|
const visible = ref(false);
|
||||||
|
const menuRef = ref(null);
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
menuOpen.value = !menuOpen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
useMotion(menuRef, {
|
||||||
|
initial: { opacity: 0, y: -20 },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 400 } }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(menuOpen, (isOpen) => {
|
||||||
|
visible.value = isOpen;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sidebarRoutes,
|
||||||
|
currentViewName,
|
||||||
|
getIcon,
|
||||||
|
menuRef,
|
||||||
|
menuOpen,
|
||||||
|
visible,
|
||||||
|
toggleMenu,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.text-purple {
|
||||||
|
color: #6b46c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle-btn {
|
||||||
|
background-color: #6b46c1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background-color: #f9f5ff;
|
||||||
|
border: 1px solid #e2d8fb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: #6b46c1;
|
||||||
|
transition: background-color 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-link {
|
||||||
|
background-color: #e2d8fb;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid #6b46c1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
38
src/components/AppPopups.vue
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<section class="popup-container" v-if="popupStore.messages.length > 0">
|
||||||
|
<transition-group name="fade" tag="div">
|
||||||
|
<div v-for="msg in popupStore.messages" :key="msg.id" class="alert alert-info popup" role="alert">
|
||||||
|
{{ msg.text }}
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { popupStore } from '@/stores/popupStore.js';
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.popup-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade animation */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
83
src/components/AppSidebar.vue
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="app-sidebar d-flex flex-column bg-white border-end p-3" style="width: 220px; flex-shrink: 0;">
|
||||||
|
<figure class="mx-auto mb-4" style="width: 10rem; height: 10rem;">
|
||||||
|
<img src="@/assets/eira-avatar.png" alt="Eira Avatar" class="rounded-circle border border-4" :style="{ borderColor: '#6b46c1' }" style="width: 100%; height: 100%; object-fit: cover;" />
|
||||||
|
</figure>
|
||||||
|
<p class="text-center fs-5 fw-semibold text-purple mb-4">
|
||||||
|
Eira Personal AI Assistant
|
||||||
|
</p>
|
||||||
|
<ul class="nav nav-pills flex-column mb-auto">
|
||||||
|
<li v-for="route in sidebarRoutes" :key="route.path" class="nav-item">
|
||||||
|
<router-link :to="route.path" class="nav-link d-flex align-items-center gap-2" :class="{ active: isActiveRoute(route.path) }" style="color: #6b46c1;" aria-current="page">
|
||||||
|
<span class="d-flex align-items-center justify-content-center">
|
||||||
|
<component :is="getIcon(route.meta.icon)" />
|
||||||
|
</span>
|
||||||
|
{{ route.name }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="sidebar-footer mt-auto text-white rounded-3 p-3 shadow" :style="{
|
||||||
|
backgroundColor: '#6b46c1',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
position: 'relative',
|
||||||
|
}">
|
||||||
|
<p class="mb-0">💬 Fun fact: Eira loves purple, naturally!</p>
|
||||||
|
<span class="sidebar-footer-tail position-absolute" style="
|
||||||
|
bottom: -10px;
|
||||||
|
left: 30px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 10px solid transparent;
|
||||||
|
border-right: 10px solid transparent;
|
||||||
|
border-top: 10px solid #6b46c1;
|
||||||
|
"></span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { defineComponent, computed } from "vue";
|
||||||
|
import Icons from "@/components/icons";
|
||||||
|
import router from "@/router";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "AppSidebar",
|
||||||
|
setup() {
|
||||||
|
const route = useRoute();
|
||||||
|
const allRoutes = router.options.routes;
|
||||||
|
|
||||||
|
const sidebarRoutes = computed(() =>
|
||||||
|
allRoutes.filter((r) => r.meta && r.meta.sidebar)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isActiveRoute = (path) => route.path === path;
|
||||||
|
|
||||||
|
const getIcon = (iconName) => Icons[iconName] || Icons["dashboard"];
|
||||||
|
|
||||||
|
return {
|
||||||
|
sidebarRoutes,
|
||||||
|
isActiveRoute,
|
||||||
|
getIcon,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.text-purple {
|
||||||
|
color: #6b46c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Bootstrap nav-link active background & text */
|
||||||
|
.nav-link.active {
|
||||||
|
background-color: #e2d8fb !important;
|
||||||
|
color: #5a3e99 !important;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effect for nav-link */
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: #f5f0ff;
|
||||||
|
color: #5a3e99 !important;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
src/components/CurrentWeatherCard.vue
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card p-3 weather-card" v-if="current">
|
||||||
|
<h2 class="h5 fw-semibold mb-3">🌤 Current Weather</h2>
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<p class="mb-1">🌡 Temp: {{ current.Temperature.Metric.Value }}°C</p>
|
||||||
|
<p class="mb-0">🤔 Feels Like: {{ current.RealFeelTemperature.Metric.Value }}°C</p>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<p class="mb-1">🌬 Wind: {{ current.Wind.Speed.Metric.Value }} km/h ({{ current.Wind.Direction.Localized }})</p>
|
||||||
|
<p class="mb-0">💧 Humidity: {{ current.RelativeHumidity }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 mb-0">
|
||||||
|
<a :href="current.Link || current.MobileLink" target="_blank" class="small text-decoration-none">
|
||||||
|
Full current conditions ↗
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "CurrentWeatherCard",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
current: null,
|
||||||
|
observationTime: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://192.168.70.11:8000/weather/current");
|
||||||
|
const json = await res.json();
|
||||||
|
this.current = json.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch current weather:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.weather-card {
|
||||||
|
border-left: 5px solid #6b46c1;
|
||||||
|
/* purple accent */
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 4px 8px rgba(107, 70, 193, 0.15);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
src/components/DailyMemeCard.vue
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card p-3 meme-card">
|
||||||
|
<h2 class="h5 mb-3">🖼 Daily Meme</h2>
|
||||||
|
<div v-if="loading" class="text-center text-muted">Loading meme...</div>
|
||||||
|
<div v-else>
|
||||||
|
<img :src="memeUrl" alt="Daily AI Meme" class="img-fluid rounded mt-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "DailyMemeCard",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
memeUrl: "",
|
||||||
|
loading: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchMeme();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchMeme() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://192.168.70.11:8000/meme/daily");
|
||||||
|
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
this.memeUrl = data.data.image || "";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch meme", error);
|
||||||
|
this.memeUrl = "";
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.meme-card {
|
||||||
|
border-left: 5px solid #6b46c1;
|
||||||
|
/* purple accent */
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 4px 8px rgba(107, 70, 193, 0.15);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
135
src/components/EiraBubble.vue
Executable file
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div class="eira-bubble d-flex align-items-center justify-content-center position-fixed" :class="{ playing: isPlaying, mobile: isMobile }" @click="togglePlay" title="Play/Pause briefing audio">
|
||||||
|
<img src="@/assets/eira-avatar.png" alt="Eira" class="eira-avatar rounded-circle" />
|
||||||
|
<div class="play-overlay d-flex align-items-center justify-content-center">
|
||||||
|
<span v-if="!isPlaying">
|
||||||
|
<CirclePlay class="icon" :size="isMobile ? 18 : 36" />
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<CirclePause class="icon" :size="isMobile ? 18 : 36" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<audio ref="audioPlayer" :src="audioUrl" @ended="onAudioEnded" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { CirclePlay, CirclePause } from 'lucide-vue-next';
|
||||||
|
import { isMobileOnly } from 'mobile-device-detect';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "EiraBubble",
|
||||||
|
components: {
|
||||||
|
CirclePlay,
|
||||||
|
CirclePause
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
audioUrl: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isPlaying: false,
|
||||||
|
isMobile: isMobileOnly,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
togglePlay() {
|
||||||
|
const audio = this.$refs.audioPlayer;
|
||||||
|
if (!audio) return;
|
||||||
|
if (this.isPlaying) {
|
||||||
|
audio.pause();
|
||||||
|
} else {
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
this.isPlaying = !this.isPlaying;
|
||||||
|
},
|
||||||
|
onAudioEnded() {
|
||||||
|
this.isPlaying = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.icon {
|
||||||
|
stroke-width: 1.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eira-bubble {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
right: 30px;
|
||||||
|
width: 144px;
|
||||||
|
height: 144px;
|
||||||
|
background-color: #f6f4ff;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 0 12px rgba(155, 109, 255, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 9999;
|
||||||
|
user-select: none;
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eira-bubble.mobile {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eira-avatar {
|
||||||
|
width: 112px;
|
||||||
|
height: 112px;
|
||||||
|
filter: drop-shadow(0 0 5px rgba(155, 109, 255, 0.7));
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eira-bubble.mobile .eira-avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background-color: #6b46c1;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 44px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 0 6px rgba(155, 109, 255, 0.8);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eira-bubble.mobile .play-overlay {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes pulseGlow {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 12px 4px #9b6dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px 8px #d2cbff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
33
src/components/InfoTooltip.vue
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<span ref="iconRef" class="d-inline-block" tabindex="0" data-bs-toggle="tooltip" data-bs-placement="left" :title="text" style="cursor: pointer;">
|
||||||
|
<HelpCircle size="1rem" class="text-muted" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { HelpCircle } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'InfoTooltip',
|
||||||
|
components: { HelpCircle },
|
||||||
|
props: {
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const iconRef = ref(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (window.bootstrap && iconRef.value) {
|
||||||
|
new window.bootstrap.Tooltip(iconRef.value)
|
||||||
|
} else {
|
||||||
|
console.warn('Bootstrap JS not loaded — tooltip not initialized.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { iconRef }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
165
src/components/ParcelTrackerCard.vue
Executable file
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card p-3 parcel-card">
|
||||||
|
<h2 class="h5 fw-semibold mb-3">📦 PostNL Parcels</h2>
|
||||||
|
<!-- Parcel List -->
|
||||||
|
<div v-if="parcels.length" class="mb-3">
|
||||||
|
<div v-for="parcel in parcels" :key="parcel.tracking_code" class="border rounded p-2 mb-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<strong>{{ parcel.nickname }}</strong><br />
|
||||||
|
<small class="text-muted">{{ parcel.tracking_code }} • {{ parcel.postal_code }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end align-items-center gap-2 mt-1">
|
||||||
|
<a :href="getPostNLUrl(parcel.tracking_code, parcel.postal_code)" class="btn btn-sm btn-outline-primary px-2 py-1" target="_blank" title="Track parcel">
|
||||||
|
🔍
|
||||||
|
</a>
|
||||||
|
<button @click="removeParcel(parcel.tracking_code)" class="btn btn-sm btn-outline-danger px-2 py-1" title="Remove parcel">
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-muted mb-3">No parcels tracked yet.</div>
|
||||||
|
<!-- Sticky action bar -->
|
||||||
|
<div class="sticky-bar">
|
||||||
|
<button class="btn btn-primary w-100" @click="showAddModal = true">
|
||||||
|
➕ Add Parcel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Add Parcel Modal -->
|
||||||
|
<div class="modal fade" tabindex="-1" :class="{ show: showAddModal }" style="display: block;" v-if="showAddModal">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form @submit.prevent="addParcel">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Add New Parcel</h5>
|
||||||
|
<button type="button" class="btn-close" @click="showAddModal = false"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
<input v-model="newNickname" type="text" placeholder="Parcel Nickname" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<input v-model="newTrackingCode" type="text" placeholder="Tracking Code" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<input v-model="newPostalCode" type="text" placeholder="Postal Code (e.g. 4133EX)" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
|
<button type="button" class="btn btn-secondary" @click="showAddModal = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "ParcelTrackerCard",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
parcels: [],
|
||||||
|
newNickname: "",
|
||||||
|
newTrackingCode: "",
|
||||||
|
newPostalCode: "",
|
||||||
|
showAddModal: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.fetchParcels();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchParcels() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://192.168.70.11:8000/api/parcels");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
this.parcels = data.parcels;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching parcels:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async addParcel() {
|
||||||
|
if (!this.newNickname || !this.newTrackingCode || !this.newPostalCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://192.168.70.11:8000/api/parcels/add", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
nickname: this.newNickname,
|
||||||
|
tracking_code: this.newTrackingCode,
|
||||||
|
postal_code: this.newPostalCode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
this.newNickname = "";
|
||||||
|
this.newTrackingCode = "";
|
||||||
|
this.newPostalCode = "";
|
||||||
|
this.showAddModal = false;
|
||||||
|
await this.fetchParcels();
|
||||||
|
} else {
|
||||||
|
const errorData = await res.json();
|
||||||
|
alert("Failed to add parcel: " + (errorData.detail || "Unknown error"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("Error adding parcel: " + error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async removeParcel(trackingCode) {
|
||||||
|
if (!confirm("Are you sure you want to remove this parcel?")) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://192.168.70.11:8000/api/parcels/remove/${trackingCode}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await this.fetchParcels();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert("Failed to remove parcel: " + (err.detail || "Unknown error"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("Error removing parcel: " + error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getPostNLUrl(trackingCode, postalCode) {
|
||||||
|
return `https://jouw.postnl.nl/track-and-trace/${trackingCode}-NL-${postalCode}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.parcel-card {
|
||||||
|
border-left: 5px solid #6b46c1;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 4px 8px rgba(107, 70, 193, 0.15);
|
||||||
|
background: white;
|
||||||
|
padding-bottom: 4rem;
|
||||||
|
/* give space for sticky bar */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky bottom bar */
|
||||||
|
.sticky-bar {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bootstrap modal override for inline display */
|
||||||
|
.modal {
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show .modal-dialog {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
src/components/SurpriseCard.vue
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card p-3 surprise-card">
|
||||||
|
<h2 class="h6 text-purple mb-2">🎉 Surprise of the Day</h2>
|
||||||
|
<p>{{ surprise || "Loading a fun surprise..." }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "SurpriseCard",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
surprise: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchSurprise() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://192.168.70.11:8000/surprise/daily");
|
||||||
|
const data = await res.json();
|
||||||
|
this.surprise = data.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch surprise:", err);
|
||||||
|
this.surprise = "Oops! Couldn't load a surprise right now.";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchSurprise();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.surprise-card {
|
||||||
|
border-left: 5px solid #9f7aea;
|
||||||
|
/* lighter purple accent */
|
||||||
|
background: #f9f7ff;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 4px 8px rgba(159, 122, 234, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-purple {
|
||||||
|
color: #6b46c1 !important;
|
||||||
|
/* consistent purple text */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
src/components/WeatherCard.vue
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card p-3 weather-card" v-if="forecast">
|
||||||
|
<h2 class="h5 fw-semibold mb-3">🌤 Weather Forecast</h2>
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<p class="mb-1">🥶 Min: {{ forecast.Temperature.Minimum.Value }}°C</p>
|
||||||
|
<p class="mb-0">🥵 Max: {{ forecast.Temperature.Maximum.Value }}°C</p>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<p class="mb-1"><strong>🌞 Day:</strong> {{ forecast.Day.IconPhrase }}</p>
|
||||||
|
<p class="mb-0"><strong>🌙 Night:</strong> {{ forecast.Night.IconPhrase }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 mb-0">
|
||||||
|
<a :href="forecast.Link" target="_blank" class="small text-decoration-none">
|
||||||
|
Full forecast ↗
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "WeatherCard",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
forecast: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://192.168.70.11:8000/weather/daily");
|
||||||
|
const json = await res.json();
|
||||||
|
this.forecast = json.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch weather forecast:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.weather-card {
|
||||||
|
border-left: 5px solid #6b46c1;
|
||||||
|
/* purple accent */
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
/* rounded */
|
||||||
|
box-shadow: 0 4px 8px rgba(107, 70, 193, 0.15);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
src/components/icons/index.js
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
import { BotMessageSquare, Home, LayoutList, Newspaper, Settings, ShieldUser, Sun } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
dashboard: Home,
|
||||||
|
morning: Sun,
|
||||||
|
midday: Newspaper,
|
||||||
|
admin: ShieldUser,
|
||||||
|
tasks: LayoutList,
|
||||||
|
settings: Settings,
|
||||||
|
chat: BotMessageSquare
|
||||||
|
};
|
||||||
22
src/main.js
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
import './assets/main.css';
|
||||||
|
|
||||||
|
import { MotionPlugin } from '@vueuse/motion';
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import VueMobileDetection from 'vue-mobile-detection'
|
||||||
|
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
|
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(router);
|
||||||
|
app.use(VueMobileDetection);
|
||||||
|
app.use(MotionPlugin);
|
||||||
|
app.mount('#app');
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js?v=2')
|
||||||
|
.then(() => console.log('✅ Service Worker registered'))
|
||||||
|
.catch(
|
||||||
|
err => console.error('⚠️ Service Worker registration failed:', err));
|
||||||
|
}
|
||||||
53
src/router/index.js
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', redirect: '/dashboard' }, {
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('@/views/DashboardView.vue'),
|
||||||
|
meta: { sidebar: true, icon: 'dashboard' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/chat',
|
||||||
|
name: 'Chat with Eira',
|
||||||
|
component: () => import('@/views/ChatView.vue'),
|
||||||
|
meta: { sidebar: true, icon: 'chat' } // add a relevant icon if you want
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tasks',
|
||||||
|
name: 'Tasks',
|
||||||
|
component: () => import('@/views/TasksView.vue'),
|
||||||
|
meta: { sidebar: true, icon: 'tasks' } // add a relevant icon if you want
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/morning-briefing',
|
||||||
|
name: 'Morning Briefing',
|
||||||
|
component: () => import('@/views/MorningBriefingView.vue'),
|
||||||
|
meta: { sidebar: true, icon: 'morning' } // add a relevant icon if you want
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/midday-news',
|
||||||
|
name: 'Midday News',
|
||||||
|
component: () => import('@/views/MiddayNewsView.vue'),
|
||||||
|
meta: { sidebar: true, icon: 'midday' } // add a relevant icon if you want
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin-panel',
|
||||||
|
name: 'Admin Panel',
|
||||||
|
component: () => import('@/views/AdminView.vue'),
|
||||||
|
meta: { sidebar: true, icon: 'admin' } // add a relevant icon if you want
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: () => import('@/views/SettingsView.vue'),
|
||||||
|
meta: {
|
||||||
|
sidebar: true,
|
||||||
|
icon: 'settings'
|
||||||
|
} // add a relevant icon if you want
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({ history: createWebHistory(), routes })
|
||||||
|
|
||||||
|
export default router
|
||||||
14
src/stores/popupStore.js
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
|
export const popupStore = reactive({
|
||||||
|
messages: [],
|
||||||
|
addPopupMessage(text) {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
this.messages.push({ id, text });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const index = this.messages.findIndex(msg => msg.id === id);
|
||||||
|
if (index !== -1) this.messages.splice(index, 1);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
151
src/views/AdminView.vue
Executable file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<section class="py-3 px-4">
|
||||||
|
<div class="job-popup-container">
|
||||||
|
<transition-group name="fade" tag="div">
|
||||||
|
<div v-for="msg in jobMessages" :key="msg.id" class="alert alert-info job-popup" role="alert">
|
||||||
|
{{ msg.text }}
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
|
<div class="container-fluid px-0">
|
||||||
|
<h1 class="mb-4">🛠 Jobs List:</h1>
|
||||||
|
<div v-if="jobs.length === 0" class="text-muted">
|
||||||
|
No jobs found.
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div v-for="job in jobs" :key="job.id" class="card mb-3">
|
||||||
|
<div class="card-body d-flex justify-content-between align-items-center flex-wrap">
|
||||||
|
<div class="mb-2 mb-md-0">
|
||||||
|
<strong>{{ job.id }}</strong>
|
||||||
|
<span class="badge bg-info text-dark ms-2">{{ job.status }}</span>
|
||||||
|
<span v-if="job.last_run" class="badge bg-light text-muted ms-2">
|
||||||
|
Last run: {{ formatDate(job.last_run) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="job.next_run" class="badge bg-light text-muted ms-2">
|
||||||
|
Next: {{ formatDate(job.next_run) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="job.error" class="badge bg-danger text-light ms-2">
|
||||||
|
❗ {{ job.error }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-success me-2" @click="runJobNow(job.id)" :disabled="runningJobs.has(job.id)" type="button">
|
||||||
|
Run now
|
||||||
|
<Play class="icon" size="0.7rem" />
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @click="toggleDetails(job.id)" type="button">
|
||||||
|
{{ expanded[job.id] ? "Hide" : "Show" }} Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="expanded[job.id]" class="card-body pt-0">
|
||||||
|
<pre v-if="job.data" class="bg-light p-3 small rounded overflow-auto">
|
||||||
|
{{ formatJSON(job.data) }}
|
||||||
|
</pre>
|
||||||
|
<p v-else class="fst-italic text-muted mb-0">
|
||||||
|
No cached data available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import axios from "axios";
|
||||||
|
import { Play } from 'lucide-vue-next';
|
||||||
|
import { popupStore } from '@/stores/popupStore.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "AdminView",
|
||||||
|
components: {
|
||||||
|
Play
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
jobs: [],
|
||||||
|
expanded: {},
|
||||||
|
runningJobs: new Set(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchJobs() {
|
||||||
|
const res = await axios.get("https://192.168.70.11:8000/admin/jobs");
|
||||||
|
this.jobs = res.data;
|
||||||
|
},
|
||||||
|
async runJobNow(jobId) {
|
||||||
|
if (this.runningJobs.has(jobId)) return;
|
||||||
|
|
||||||
|
this.runningJobs.add(jobId);
|
||||||
|
try {
|
||||||
|
popupStore.addPopupMessage(`⚙️ Trying to run Job "${jobId}".`);
|
||||||
|
const res = await fetch(
|
||||||
|
`https://192.168.70.11:8000/admin/jobs/${jobId}/run`, {
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
popupStore.addPopupMessage(`✅ Job "${jobId}" triggered successfully.`);
|
||||||
|
} else {
|
||||||
|
const errText = await res.text();
|
||||||
|
popupStore.addPopupMessage(`⚠️ Failed to trigger job "${jobId}": ${errText}`);
|
||||||
|
}
|
||||||
|
setTimeout(() => this.fetchJobs(), 1000);
|
||||||
|
} catch (error) {
|
||||||
|
popupStore.addPopupMessage(`❌ Error: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
this.runningJobs.delete(jobId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleDetails(id) {
|
||||||
|
this.expanded[id] = !this.expanded[id];
|
||||||
|
},
|
||||||
|
formatDate(iso) {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
},
|
||||||
|
formatJSON(obj) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(obj, null, 2);
|
||||||
|
} catch {
|
||||||
|
return "[Unserializable Data]";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchJobs();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-left: 4px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-popup-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-popup {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade animation */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
src/views/ChatView.vue
Executable file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container py-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body d-flex flex-column" style="min-height: 70vh">
|
||||||
|
<h1 class="card-title text-primary mb-3">💬 Chat with Eira</h1>
|
||||||
|
<p class="lead mb-3">Ask anything or get help with your day.</p>
|
||||||
|
<!-- Chat Messages -->
|
||||||
|
<div class="flex-grow-1 overflow-auto mb-3 bg-light rounded p-3 shadow-sm" style="max-height: 50vh">
|
||||||
|
<div v-for="(message, index) in messages" :key="index" class="mb-2">
|
||||||
|
<div :class="message.sender === 'user' ? 'text-end' : 'text-start'">
|
||||||
|
<span :class="['d-inline-block px-3 py-2 rounded', message.sender === 'user' ? 'bg-primary text-white' : 'bg-white text-dark shadow-sm']">
|
||||||
|
{{ message.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Input -->
|
||||||
|
<div class="input-group">
|
||||||
|
<input v-model="userInput" @keydown.enter="sendMessage" type="text" class="form-control" placeholder="Type your message..." />
|
||||||
|
<button class="btn btn-primary" :disabled="loading || !userInput.trim()" @click="sendMessage">
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "ChatView",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
userInput: "",
|
||||||
|
messages: [],
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async sendMessage() {
|
||||||
|
const input = this.userInput.trim();
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
// Add user message to history
|
||||||
|
this.messages.push({ sender: "user", text: input });
|
||||||
|
this.userInput = "";
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://192.168.70.11:8000/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: input }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Failed to get Eira's response");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const reply = data.reply || "I'm not sure how to respond to that.";
|
||||||
|
|
||||||
|
this.messages.push({ sender: "eira", text: reply });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Chat error:", error);
|
||||||
|
this.messages.push({ sender: "eira", text: "Sorry, something went wrong." });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const container = this.$el.querySelector(".overflow-auto");
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.text-primary {
|
||||||
|
color: #6f42c1 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
109
src/views/DashboardView.vue
Executable file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-4">
|
||||||
|
<h1 class="title mb-4">Welcome to Eira</h1>
|
||||||
|
<div ref="grid" class="grid-stack" style="min-height: 500px;">
|
||||||
|
<div v-for="(text, n) in cardTexts" :key="`card-${n}`" class="grid-stack-item" :id="`card-${n}`">
|
||||||
|
<div class="grid-stack-item-content card">
|
||||||
|
<h2>Card #{{ n + 1 }}</h2>
|
||||||
|
<p>{{ text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="fab" @click="toggleEditMode" :title="editMode ? 'Disable Edit' : 'Enable Edit'">
|
||||||
|
<span v-if="editMode">🔒</span>
|
||||||
|
<span v-else>✏️</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { GridStack } from 'gridstack'
|
||||||
|
import 'gridstack/dist/gridstack.min.css'
|
||||||
|
|
||||||
|
const grid = ref(null)
|
||||||
|
const editMode = ref(false)
|
||||||
|
|
||||||
|
const cardTexts = [
|
||||||
|
'Short text.',
|
||||||
|
'More content to test layout.',
|
||||||
|
'A longer paragraph to test behavior.',
|
||||||
|
'Tiny.',
|
||||||
|
'Medium length.',
|
||||||
|
'Very long content. Lorem ipsum dolor sit amet...'
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const gridInstance = GridStack.init({
|
||||||
|
column: 12,
|
||||||
|
float: true,
|
||||||
|
cellHeight: 80,
|
||||||
|
margin: 10,
|
||||||
|
resizable: { handles: 'se' },
|
||||||
|
draggable: { handle: '.grid-stack-item-content' },
|
||||||
|
disableDrag: true,
|
||||||
|
disableResize: true
|
||||||
|
},
|
||||||
|
grid.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const positions = [
|
||||||
|
{ x: 0, y: 0, w: 4, h: 2 },
|
||||||
|
{ x: 4, y: 0, w: 4, h: 2 },
|
||||||
|
{ x: 8, y: 0, w: 4, h: 2 },
|
||||||
|
{ x: 0, y: 2, w: 4, h: 2 },
|
||||||
|
{ x: 4, y: 2, w: 4, h: 2 },
|
||||||
|
{ x: 8, y: 2, w: 4, h: 2 }
|
||||||
|
]
|
||||||
|
|
||||||
|
positions.forEach((pos, i) => {
|
||||||
|
gridInstance.update(grid.value.children[i], pos)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store it for toggleEditMode
|
||||||
|
grid.value.__gridInstance = gridInstance
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleEditMode() {
|
||||||
|
editMode.value = !editMode.value
|
||||||
|
const instance = grid.value.__gridInstance
|
||||||
|
instance.enableMove(editMode.value)
|
||||||
|
instance.enableResize(editMode.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #6b46c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-left: 5px solid #6b46c1;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #6b46c1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
background-color: #5930a3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
100
src/views/MiddayNewsView.vue
Executable file
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container py-4">
|
||||||
|
<div class="card border-0 shadow-none p-0">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<h1 class="h1 text-primary mb-3">🗞️ Hey Collin!</h1>
|
||||||
|
<p class="h5 mb-4">Catch up on the latest:</p>
|
||||||
|
<!-- Equal height layout -->
|
||||||
|
<div class="row gx-3 gy-4">
|
||||||
|
<div v-for="(article, index) in headlines" :key="index" class="col-md-6 d-flex">
|
||||||
|
<div class="card flex-fill d-flex flex-column shadow-sm p-3">
|
||||||
|
<!-- Image or fallback -->
|
||||||
|
<div class="mb-3" style="height: 160px;">
|
||||||
|
<img v-if="article.urlToImage" :src="article.urlToImage" alt="thumbnail" class="w-100 h-100 object-fit-cover rounded" />
|
||||||
|
<div v-else class="d-flex align-items-center justify-content-center bg-primary text-white rounded text-uppercase" style="width: 100%; height: 100%; font-size: 2rem;">
|
||||||
|
{{ article.title.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-grow-1 d-flex flex-column">
|
||||||
|
<h5 class="fw-semibold mb-1 text-dark">
|
||||||
|
{{ article.title }}
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small mb-2">
|
||||||
|
{{ article.source?.name }} • {{ formatDate(article.publishedAt) }}
|
||||||
|
</p>
|
||||||
|
<p class="small text-secondary flex-grow-1">
|
||||||
|
{{ article.description || "No summary available." }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-auto">
|
||||||
|
<a :href="article.url" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-primary w-100">
|
||||||
|
Read more →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Additional Cards -->
|
||||||
|
<div class="card mt-5 bg-white shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6 fw-semibold mb-3">📈 Market Snapshot</h2>
|
||||||
|
<p>{{ marketSnapshot }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mt-4 bg-white shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6 fw-semibold mb-3">💬 Quick Insight</h2>
|
||||||
|
<p>{{ insight }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "MiddayNewsView",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
headlines: [],
|
||||||
|
marketSnapshot: "NASDAQ up 1.3%, S&P steady, Bitcoin rises to $68,000.",
|
||||||
|
insight: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatDate(dateStr) {
|
||||||
|
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
fetch("https://192.168.70.11:8000/news/relevant")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
this.headlines = data.data;
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
|
||||||
|
fetch("https://192.168.70.11:8000/insight/daily")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
this.insight = data.data;
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.text-primary {
|
||||||
|
color: #6b46c1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-fit-cover {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
174
src/views/MorningBriefingView.vue
Executable file
@@ -0,0 +1,174 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container py-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 class="card-title text-primary mb-3">🌅 Good Morning Collin!</h1>
|
||||||
|
<p class="lead mb-4">Here’s everything you need to know today:</p>
|
||||||
|
<div class="mb-4 p-3 bg-white rounded shadow-sm">
|
||||||
|
<h2 class="h6 fw-semibold mb-2">📅 Today's Date</h2>
|
||||||
|
<p>{{ currentTime }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 p-3 bg-white rounded shadow-sm">
|
||||||
|
<h2 class="h6 fw-semibold mb-3">✅ Tasks</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-auto text-end pe-3">
|
||||||
|
<p v-for="task in tasks" :key="task.id" class="mb-1 text-muted small monospace">
|
||||||
|
{{ formatTime(task) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<p v-for="task in tasks" :key="task.id" class="mb-1">
|
||||||
|
{{ task.content }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 p-3 bg-white rounded shadow-sm">
|
||||||
|
<h2 class="h6 fw-semibold mb-2">🌤 Weather</h2>
|
||||||
|
<p>{{ weatherSummary }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-white rounded shadow-sm">
|
||||||
|
<h2 class="h6 fw-semibold mb-2">🧥 Dressing Advice</h2>
|
||||||
|
<p>{{ dressingAdvice }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EiraBubble :audioUrl="audioUrl" class="mt-4" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import EiraBubble from "@/components/EiraBubble.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "MorningBriefingView",
|
||||||
|
components: { EiraBubble },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
audioUrl: null,
|
||||||
|
isPlaying: false,
|
||||||
|
progress: 0,
|
||||||
|
audio: null,
|
||||||
|
currentTime: "Monday, June 9, 2025",
|
||||||
|
tasks: [],
|
||||||
|
weatherSummary: "Loading weather...",
|
||||||
|
dressingAdvice: "Loading advice...",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadAudio();
|
||||||
|
this.updateCurrentTime();
|
||||||
|
this.fetchTasks();
|
||||||
|
this.fetchWeather();
|
||||||
|
this.fetchDressingAdvice();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadAudio() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
"https://192.168.70.11:8000/audio/morning_briefing.wav/get", { method: "POST" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Audio fetch failed");
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
this.audioUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
this.audio = new Audio(this.audioUrl);
|
||||||
|
this.audio.addEventListener("ended", () => {
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.progress = 0;
|
||||||
|
});
|
||||||
|
this.audio.addEventListener("timeupdate", () => {
|
||||||
|
if (this.audio && this.audio.duration > 0) {
|
||||||
|
this.progress = (this.audio.currentTime / this.audio.duration) * 100;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading audio:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateCurrentTime() {
|
||||||
|
const now = new Date();
|
||||||
|
const options = {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
};
|
||||||
|
this.currentTime = now.toLocaleDateString(undefined, options);
|
||||||
|
},
|
||||||
|
async fetchTasks() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://192.168.70.11:8000/todo/today");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
this.tasks = result.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch tasks:", error);
|
||||||
|
this.tasks = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchWeather() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://192.168.70.11:8000/weather/daily");
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
const forecast = json.data;
|
||||||
|
|
||||||
|
if (forecast && forecast.Temperature && forecast.Day && forecast.Night) {
|
||||||
|
const minTemp = Math.round(forecast.Temperature.Minimum.Value);
|
||||||
|
const maxTemp = Math.round(forecast.Temperature.Maximum.Value);
|
||||||
|
const dayPhrase = forecast.Day.IconPhrase || "Unknown";
|
||||||
|
const nightPhrase = forecast.Night.IconPhrase || "Unknown";
|
||||||
|
|
||||||
|
this.weatherSummary = `Today’s forecast: ${dayPhrase} during the day, ${nightPhrase} at night. ${minTemp}°C - ${maxTemp}°C.`;
|
||||||
|
} else {
|
||||||
|
this.weatherSummary = "Weather data not available.";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.weatherSummary = "Failed to load weather.";
|
||||||
|
console.error("Weather fetch error:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchDressingAdvice() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://192.168.70.11:8000/advice/dressing");
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
const advice = json.data;
|
||||||
|
|
||||||
|
if (advice) {
|
||||||
|
this.dressingAdvice = advice;
|
||||||
|
} else {
|
||||||
|
this.dressingAdvice = "Dressing advice data not available.";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.dressingAdvice = "Failed to load dressing advice.";
|
||||||
|
console.error("Dressing advice fetch error:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatTime(task) {
|
||||||
|
if (task.due && task.due.datetime) {
|
||||||
|
const date = new Date(task.due.datetime);
|
||||||
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.monospace {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: #6f42c1 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
246
src/views/SettingsView.vue
Executable file
@@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container py-4">
|
||||||
|
<h1 class="mb-4">⚙️ App Settings</h1>
|
||||||
|
<!-- Notifications Section -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-bold d-flex justify-content-between align-items-center">
|
||||||
|
🔔 Notifications
|
||||||
|
<InfoTooltip text="Enable or disable app-wide push notifications for this device." />
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-3">Notifications are enabled per device. Manage them for <strong>this device and browser</strong> specifically.</p>
|
||||||
|
<div v-if="isSubscribed">
|
||||||
|
<p class="text-success">✅ You are subscribed to notifications.</p>
|
||||||
|
<button class="btn btn-danger me-2" @click="unsubscribeFromPush">Unsubscribe</button>
|
||||||
|
<button class="btn btn-secondary" @click="sendTestNotification">Send Test Notification</button>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<p class="text-muted">🔕 You are not subscribed to notifications yet.</p>
|
||||||
|
<button class="btn btn-primary me-2" @click="subscribeToPush">Enable Notifications</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Personalization -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-bold d-flex justify-content-between align-items-center">
|
||||||
|
🎨 Personalization
|
||||||
|
<InfoTooltip text="Customize the app's appearance and language preferences." />
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Theme</label>
|
||||||
|
<select class="form-select">
|
||||||
|
<option>Light</option>
|
||||||
|
<option>Dark</option>
|
||||||
|
<option>System Default</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">App Language</label>
|
||||||
|
<select class="form-select">
|
||||||
|
<option>English</option>
|
||||||
|
<option>Spanish</option>
|
||||||
|
<option>French</option>
|
||||||
|
<option>German</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Privacy -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-bold d-flex justify-content-between align-items-center">
|
||||||
|
🔐 Privacy & Security
|
||||||
|
<InfoTooltip text="Control how your data is handled and secure your sessions." />
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="dataTracking" />
|
||||||
|
<label class="form-check-label" for="dataTracking">
|
||||||
|
Allow anonymous usage data collection
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="autoLogout" />
|
||||||
|
<label class="form-check-label" for="autoLogout">
|
||||||
|
Auto-logout after 15 minutes of inactivity
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Integrations -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-bold d-flex justify-content-between align-items-center">
|
||||||
|
🔌 Integrations
|
||||||
|
<InfoTooltip text="Connect third-party services to expand app capabilities." />
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Manage connected services and API keys.</p>
|
||||||
|
<button class="btn btn-outline-secondary">Connect Google Calendar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Experimental Features -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-bold d-flex justify-content-between align-items-center">
|
||||||
|
🧪 Experimental Features
|
||||||
|
<InfoTooltip text="Try out beta or in-development features before official release." />
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="betaFeatures" />
|
||||||
|
<label class="form-check-label" for="betaFeatures">
|
||||||
|
Enable beta features
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import InfoTooltip from "@/components/InfoTooltip.vue"
|
||||||
|
import { popupStore } from '@/stores/popupStore.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "SettingsView",
|
||||||
|
components: {
|
||||||
|
InfoTooltip
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isSubscribed: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async checkSubscription() {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
const res = await fetch("https://192.168.70.11:8000/api/check-subscription", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(subscription)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
this.isSubscribed = data.exists;
|
||||||
|
} else {
|
||||||
|
this.isSubscribed = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
popupStore.addPopupMessage(`❌ Subscription check failed: ${error.message}`);
|
||||||
|
this.isSubscribed = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async subscribeToPush() {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
|
||||||
|
if (permission !== "granted") {
|
||||||
|
popupStore.addPopupMessage(`❌ Permission denied for notifications.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vapidKey = "BNf7z00erjRX3kUBfGZK-TE1Tz6Zypb1I4aDVZkaWO1113xV_L6hDMbe_Evv8ruUiu-E88xPhRNIEL4ayjqcL5o";
|
||||||
|
const convertedKey = this.urlBase64ToUint8Array(vapidKey);
|
||||||
|
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: convertedKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetch("https://192.168.70.11:8000/api/subscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(subscription),
|
||||||
|
});
|
||||||
|
|
||||||
|
popupStore.addPopupMessage(`✅ Subscribed successfully.`);
|
||||||
|
} catch (error) {
|
||||||
|
popupStore.addPopupMessage(`❌ Subscription failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
setTimeout(() => this.checkSubscription(), 1000);
|
||||||
|
},
|
||||||
|
async unsubscribeFromPush() {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
// Send full subscription to backend
|
||||||
|
await fetch("https://192.168.70.11:8000/api/unsubscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(subscription),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe on client too
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
|
||||||
|
popupStore.addPopupMessage(`✅ Unsubscribed successfully.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
popupStore.addPopupMessage(`❌ Unsubscribe failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
setTimeout(() => this.checkSubscription(), 1000);
|
||||||
|
},
|
||||||
|
async sendTestNotification() {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
popupStore.addPopupMessage(`❌ No push subscription found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("https://192.168.70.11:8000/api/send-test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(subscription),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
popupStore.addPopupMessage(`❌ Failed to send test notification.`);
|
||||||
|
} else {
|
||||||
|
popupStore.addPopupMessage(`✅ Test notification send successfully.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
popupStore.addPopupMessage(`❌ Error sending test notification: ${error.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
urlBase64ToUint8Array(base64String) {
|
||||||
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.checkSubscription();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.help-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6c757d;
|
||||||
|
/* Bootstrap muted color */
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-icon:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
246
src/views/TasksView.vue
Executable file
@@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container py-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 class="card-title text-primary mb-3">📝 Tasks</h1>
|
||||||
|
<!-- Today's Tasks -->
|
||||||
|
<div class="mb-4 p-3 bg-white rounded shadow-sm">
|
||||||
|
<h2 class="h6 fw-semibold mb-2">📅 Today</h2>
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li v-for="task in todaysTasks" :key="task.id" class="mb-1 d-flex align-items-center gap-2">
|
||||||
|
<input type="checkbox" :checked="task.is_completed" @change="toggleTaskCompletion(task)" />
|
||||||
|
<span :style="{ textDecoration: task.is_completed ? 'line-through' : 'none' }">
|
||||||
|
{{ formatTask(task) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!-- Future Tasks grouped by day -->
|
||||||
|
<div class="mb-4 p-3 bg-white rounded shadow-sm">
|
||||||
|
<h2 class="h6 fw-semibold mb-2">📆 Upcoming</h2>
|
||||||
|
<div v-for="(tasks, date) in groupedFutureTasks" :key="date" class="mb-3">
|
||||||
|
<h3 class="h6 text-secondary mb-1">{{ formatDateHeader(date) }}</h3>
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li v-for="task in tasks" :key="task.id" class="mb-1">
|
||||||
|
{{ formatTask(task) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- FAB Add Task Button -->
|
||||||
|
<button class="fab-button" @click="showAddTaskModal = true">
|
||||||
|
<Plus size="20" color="white" />
|
||||||
|
</button>
|
||||||
|
<!-- Add Task Modal -->
|
||||||
|
<div v-if="showAddTaskModal" class="modal-backdrop">
|
||||||
|
<div class="modal-content p-4 bg-white rounded shadow">
|
||||||
|
<h5 class="mb-3">Add Task</h5>
|
||||||
|
<!-- Task name -->
|
||||||
|
<input v-model="newTaskContent" type="text" placeholder="Task name" class="form-control mb-1" :class="{ 'is-invalid': errors.content }" />
|
||||||
|
<div v-if="errors.content" class="text-danger small mb-2">{{ errors.content }}</div>
|
||||||
|
<!-- Date & Time picker -->
|
||||||
|
<VueDatePicker v-model="newTaskDateTime" :is-range="false" :enable-time-picker="true" input-class-name="form-control" format="dd-MM-yyyy HH:mm" placeholder="Pick date & time" auto-apply :class="{ 'is-invalid': errors.datetime }" />
|
||||||
|
<div v-if="errors.datetime" class="text-danger small mt-1">{{ errors.datetime }}</div>
|
||||||
|
<div class="d-flex justify-content-end mt-4 gap-2">
|
||||||
|
<button class="btn btn-sm btn-secondary" @click="closeModal">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-primary" @click="submitTask">Add Task</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { Plus } from 'lucide-vue-next';
|
||||||
|
import VueDatePicker from '@vuepic/vue-datepicker';
|
||||||
|
import '@vuepic/vue-datepicker/dist/main.css';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Plus, VueDatePicker },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
todaysTasks: [],
|
||||||
|
futureTasks: [],
|
||||||
|
showAddTaskModal: false,
|
||||||
|
newTaskContent: '',
|
||||||
|
newTaskDateTime: null,
|
||||||
|
errors: {
|
||||||
|
content: '',
|
||||||
|
datetime: ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
groupedFutureTasks() {
|
||||||
|
const groups = {};
|
||||||
|
this.futureTasks.forEach(task => {
|
||||||
|
const date = task.due.date;
|
||||||
|
if (!groups[date]) groups[date] = [];
|
||||||
|
groups[date].push(task);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async toggleTaskCompletion(task) {
|
||||||
|
// Flip completion locally for optimistic UI update
|
||||||
|
task.is_completed = !task.is_completed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encodedTitle = encodeURIComponent(task.content);
|
||||||
|
const encodedDatetime = encodeURIComponent(task.due.datetime.replace('Z', ''));
|
||||||
|
|
||||||
|
const url = `https://192.168.70.11:8000/todo/${encodedTitle}/${encodedDatetime}/complete`;
|
||||||
|
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh tasks from backend to reflect accurate state
|
||||||
|
await this.fetchTasks();
|
||||||
|
} catch (error) {
|
||||||
|
// Revert UI change if API call fails
|
||||||
|
task.is_completed = !task.is_completed;
|
||||||
|
alert('Failed to mark task as completed.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchTasks() {
|
||||||
|
const res = await fetch('https://192.168.70.11:8000/todo/all');
|
||||||
|
const json = await res.json();
|
||||||
|
const allTasks = json.data || [];
|
||||||
|
|
||||||
|
// Get local today's date in 'YYYY-MM-DD' format
|
||||||
|
const todayDate = new Date();
|
||||||
|
const yyyy = todayDate.getFullYear();
|
||||||
|
const mm = String(todayDate.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(todayDate.getDate()).padStart(2, '0');
|
||||||
|
const today = `${yyyy}-${mm}-${dd}`;
|
||||||
|
|
||||||
|
this.todaysTasks = allTasks.filter(
|
||||||
|
t => t.due && t.due.date === today
|
||||||
|
);
|
||||||
|
|
||||||
|
this.futureTasks = allTasks.filter(
|
||||||
|
t => t.due && t.due.date > today
|
||||||
|
);
|
||||||
|
},
|
||||||
|
formatTask(task) {
|
||||||
|
const time = new Date(task.due.datetime).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
return `${time} ${task.content}`;
|
||||||
|
},
|
||||||
|
formatDateHeader(dateStr) {
|
||||||
|
const options = { weekday: 'long', month: 'short', day: 'numeric' };
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString(undefined, options); // e.g. "Friday, Jun 12"
|
||||||
|
},
|
||||||
|
closeModal() {
|
||||||
|
this.showAddTaskModal = false;
|
||||||
|
this.newTaskContent = '';
|
||||||
|
this.newTaskDateTime = null;
|
||||||
|
this.errors = { content: '', datetime: '' };
|
||||||
|
},
|
||||||
|
async submitTask() {
|
||||||
|
this.errors = { content: '', datetime: '' };
|
||||||
|
|
||||||
|
let valid = true;
|
||||||
|
if (!this.newTaskContent.trim()) {
|
||||||
|
this.errors.content = 'Please enter a task name';
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
if (!this.newTaskDateTime) {
|
||||||
|
this.errors.datetime = 'Please pick a date and time';
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
|
// Format datetime in local ISO 8601 format WITHOUT timezone ("Z")
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
const dt = this.newTaskDateTime;
|
||||||
|
const dueDatetime =
|
||||||
|
`${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T` +
|
||||||
|
`${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
|
||||||
|
|
||||||
|
const encodedTitle = encodeURIComponent(this.newTaskContent);
|
||||||
|
const encodedDatetime = encodeURIComponent(dueDatetime);
|
||||||
|
|
||||||
|
await fetch(`https://192.168.70.11:8000/todo/${encodedTitle}/${encodedDatetime}/create`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.closeModal();
|
||||||
|
this.fetchTasks();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchTasks();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.text-primary {
|
||||||
|
color: #6f42c1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #6f42c1;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-button:hover {
|
||||||
|
background-color: #59359e !important;
|
||||||
|
border-color: #59359e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-invalid {
|
||||||
|
border: 1px solid #dc3545 !important;
|
||||||
|
animation: flash 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flash {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0px rgba(220, 53, 69, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 6px rgba(220, 53, 69, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
vue.config.js
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const { defineConfig } = require('@vue/cli-service');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
transpileDependencies: true,
|
||||||
|
|
||||||
|
devServer: {
|
||||||
|
https: {
|
||||||
|
key: fs.readFileSync('./certs/dev-key.pem'),
|
||||||
|
cert: fs.readFileSync('./certs/dev-cert.pem')
|
||||||
|
},
|
||||||
|
host: '0.0.0.0', // or '0.0.0.0' if you want LAN access
|
||||||
|
port: 8080, // or your preferred port
|
||||||
|
allowedHosts: 'all'
|
||||||
|
}
|
||||||
|
});
|
||||||