Initial Code

First Commit
This commit is contained in:
2025-06-16 01:13:41 +02:00
commit b9095962f8
50 changed files with 15586 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

24
README.md Executable file
View 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
View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

24
certs/dev-cert.pem Executable file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

54
package.json Executable file
View 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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
public/favicon/favicon-96x96.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
public/favicon/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
public/favicon/favicon.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 105 KiB

21
public/favicon/site.webmanifest Executable file
View 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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

17
public/index.html Executable file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

49
public/sw.js Executable file
View 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
View 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

Binary file not shown.

57
src/App.vue Executable file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

10
src/assets/main.css Executable file
View 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
View 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 Pythons 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 Čapeks 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 Asimovs Three Laws of Robotics influence many AI ethics discussions today.",
"Did you know? The worlds 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 Palahniuks 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 'Ill 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 brands 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 programs 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

Binary file not shown.

37
src/components/AppFooter.vue Executable file
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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">Heres 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 = `Todays 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
View 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
View 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
View 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'
}
});