feat: Implement push notifications in Budget Manager PWA
- Added functionality for subscribing to push notifications in Settings.vue. - Integrated service worker registration and notification permission requests. - Enhanced Vite configuration for PWA with manifest and caching strategies. - Created a script for generating PDF reports. - Added project report markdown file detailing the PWA implementation and features.
This commit is contained in:
@@ -134,3 +134,6 @@ dist
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
#custom
|
||||
/report
|
||||
@@ -1,7 +1,70 @@
|
||||
# Aplikacja do zarządzania finansami z powiadomieniami push
|
||||
# Budget Manager PWA
|
||||
|
||||
- Zaprojektuj aplikację PWA umożliwiającą śledzenie wydatków i przychodów.
|
||||
- Wykorzystaj Local Storage do przechowywania danych użytkownika.
|
||||
- Zaimplementuj funkcje budżetowania, raportowania i analizy finansowej.
|
||||
- Dodaj powiadomienia push, aby informować użytkowników o ważnych wydarzeniach finansowych.
|
||||
- Wykorzystaj MongoDB do przechowywania danych.
|
||||
A Progressive Web App for managing personal finances with push notifications.
|
||||
|
||||
## Features
|
||||
|
||||
- Track expenses and income
|
||||
- Manage budget categories with custom colors
|
||||
- View financial reports and analytics
|
||||
- Receive push notifications for new transactions
|
||||
- Works offline with local data storage
|
||||
- Full PWA support for mobile installation
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Backend
|
||||
|
||||
1. Navigate to the backend directory:
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. Generate VAPID keys for push notifications:
|
||||
```bash
|
||||
cd scripts
|
||||
bun run generate-vapid-keys.js
|
||||
```
|
||||
|
||||
4. Copy the `.env.example` to `.env` and update with your VAPID keys:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
5. Start the backend server:
|
||||
```bash
|
||||
bun start
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
## Push Notifications
|
||||
|
||||
The app supports push notifications for the following events:
|
||||
- New transaction added
|
||||
- Budget threshold exceeded
|
||||
|
||||
To enable push notifications:
|
||||
1. Go to the Settings page
|
||||
2. Toggle "Push Notifications" to On
|
||||
3. Grant notification permissions when prompted
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# JWT Secret for authentication
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
|
||||
# MongoDB connection
|
||||
MONGODB_URI=mongodb://localhost:27017/budget_manager
|
||||
|
||||
# VAPID Keys for Web Push Notifications
|
||||
# Generate your own keys using the script in /scripts/generate-vapid-keys.js
|
||||
VAPID_PUBLIC_KEY=BEHsROsRzfqdMv4g50BEKUbFEL0NNdig0yxdmd-d9j5ZNLOOWbwsTZY3q_W_Y2E-h-hCCJmC5r3uATdFKQTN4z0
|
||||
VAPID_PRIVATE_KEY=dhty4Wi3rKZlKRs_7zSPmcW6qEK8sCMZ23Qw9iMGuBM
|
||||
|
||||
# App settings
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
@@ -0,0 +1,39 @@
|
||||
# use the official Bun image
|
||||
# see all versions at https://hub.docker.com/r/oven/bun/tags
|
||||
FROM oven/bun:1 AS base
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# install dependencies into temp directory
|
||||
# this will cache them and speed up future builds
|
||||
FROM base AS install
|
||||
RUN mkdir -p /temp/dev
|
||||
COPY package.json bun.lockb /temp/dev/
|
||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
||||
|
||||
# install with --production (exclude devDependencies)
|
||||
RUN mkdir -p /temp/prod
|
||||
COPY package.json bun.lockb /temp/prod/
|
||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||
|
||||
# copy node_modules from temp directory
|
||||
# then copy all (non-ignored) project files into the image
|
||||
FROM base AS prerelease
|
||||
COPY --from=install /temp/dev/node_modules node_modules
|
||||
COPY . .
|
||||
|
||||
# [optional] tests & build
|
||||
ENV NODE_ENV=production
|
||||
RUN bun run generate-vapid
|
||||
|
||||
# copy production dependencies and source code into final image
|
||||
FROM base AS release
|
||||
COPY --from=install /temp/prod/node_modules node_modules
|
||||
COPY --from=prerelease /usr/src/app/index.ts .
|
||||
COPY --from=prerelease /usr/src/app/src ./src
|
||||
COPY --from=prerelease /usr/src/app/package.json .
|
||||
COPY --from=prerelease /usr/src/app/.env .
|
||||
|
||||
# run the app
|
||||
USER bun
|
||||
EXPOSE 3000/tcp
|
||||
ENTRYPOINT [ "bun", "run", "index.ts" ]
|
||||
@@ -12,6 +12,7 @@
|
||||
"koa-jwt": "^4.0.4",
|
||||
"koa-router": "^13.0.1",
|
||||
"mongodb": "^6.16.0",
|
||||
"web-push": "^3.6.7",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
@@ -90,10 +91,16 @@
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
|
||||
|
||||
"aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="],
|
||||
|
||||
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
|
||||
|
||||
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
|
||||
|
||||
"bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="],
|
||||
|
||||
"bson": ["bson@6.10.3", "", {}, "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
@@ -164,6 +171,10 @@
|
||||
|
||||
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
||||
|
||||
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
@@ -216,6 +227,10 @@
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mongodb": ["mongodb@6.16.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.1.9", "bson": "^6.10.3", "mongodb-connection-string-url": "^3.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", "snappy": "^7.2.2", "socks": "^2.7.1" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-D1PNcdT0y4Grhou5Zi/qgipZOYeWrhLEpk33n3nm6LGtz61jvO88WlrWCK/bigMjpnOdAUKKQwsGIl0NtWMyYw=="],
|
||||
|
||||
"mongodb-connection-string-url": ["mongodb-connection-string-url@3.0.2", "", { "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" } }, "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA=="],
|
||||
@@ -286,6 +301,8 @@
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
|
||||
@@ -300,6 +317,8 @@
|
||||
|
||||
"type-is/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
|
||||
|
||||
"web-push/jws": ["jws@4.0.0", "", { "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg=="],
|
||||
|
||||
"co-body/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
|
||||
"http-assert/http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="],
|
||||
@@ -309,5 +328,7 @@
|
||||
"koa-bodyparser/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
|
||||
"type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"web-push/jws/jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "bun run index.ts",
|
||||
"dev": "bun --watch run index.ts",
|
||||
"generate-vapid": "bun run scripts/generate-vapid-keys.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/bun": "latest",
|
||||
@@ -23,6 +28,7 @@
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-jwt": "^4.0.4",
|
||||
"koa-router": "^13.0.1",
|
||||
"mongodb": "^6.16.0"
|
||||
"mongodb": "^6.16.0",
|
||||
"web-push": "^3.6.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Script to generate VAPID keys for Web Push Notifications
|
||||
* Run with: bun run generate-vapid-keys.js
|
||||
*/
|
||||
const webpush = require('web-push');
|
||||
|
||||
// Generate VAPID keys (public/private key pair)
|
||||
const vapidKeys = webpush.generateVAPIDKeys();
|
||||
|
||||
console.log('VAPID Keys generated successfully:');
|
||||
console.log('===============================');
|
||||
console.log('Public Key:');
|
||||
console.log(vapidKeys.publicKey);
|
||||
console.log('===============================');
|
||||
console.log('Private Key:');
|
||||
console.log(vapidKeys.privateKey);
|
||||
console.log('===============================');
|
||||
console.log('Add these to your .env file as:');
|
||||
console.log('VAPID_PUBLIC_KEY="' + vapidKeys.publicKey + '"');
|
||||
console.log('VAPID_PRIVATE_KEY="' + vapidKeys.privateKey + '"');
|
||||
console.log('===============================');
|
||||
@@ -3,6 +3,7 @@ import { ObjectId } from "mongodb";
|
||||
import dbConfig from "../config/database";
|
||||
import type { Transaction } from "../models";
|
||||
import DatabaseService from "../services/database";
|
||||
import pushController from "./push";
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
@@ -118,6 +119,17 @@ export default {
|
||||
console.log("Inserting transaction:", newTransaction);
|
||||
const result = await transactionsCollection.insertOne(newTransaction);
|
||||
|
||||
// Send push notification for the new transaction
|
||||
try {
|
||||
await pushController.sendTransactionNotification(
|
||||
userId,
|
||||
{ ...newTransaction, _id: result.insertedId }
|
||||
);
|
||||
} catch (notificationError) {
|
||||
console.error("Failed to send notification:", notificationError);
|
||||
// Continue even if notification fails
|
||||
}
|
||||
|
||||
ctx.status = 201;
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -6,8 +6,9 @@ import webpush from "web-push";
|
||||
|
||||
// Configure web-push with VAPID keys
|
||||
const vapidKeys = {
|
||||
publicKey: process.env.VAPID_PUBLIC_KEY || 'your-public-key',
|
||||
privateKey: process.env.VAPID_PRIVATE_KEY || 'your-private-key'
|
||||
// These should be replaced with generated keys from the script
|
||||
publicKey: process.env.VAPID_PUBLIC_KEY || 'BEHsROsRzfqdMv4g50BEKUbFEL0NNdig0yxdmd-d9j5ZNLOOWbwsTZY3q_W_Y2E-h-hCCJmC5r3uATdFKQTN4z0',
|
||||
privateKey: process.env.VAPID_PRIVATE_KEY || 'dhty4Wi3rKZlKRs_7zSPmcW6qEK8sCMZ23Qw9iMGuBM'
|
||||
};
|
||||
|
||||
webpush.setVapidDetails(
|
||||
@@ -21,7 +22,9 @@ const db = DatabaseService.getInstance();
|
||||
export default {
|
||||
// Get VAPID public key
|
||||
async getVapidPublicKey(ctx: Context) {
|
||||
ctx.set('Content-Type', 'text/plain');
|
||||
ctx.body = vapidKeys.publicKey;
|
||||
console.log("Returning VAPID public key:", vapidKeys.publicKey);
|
||||
},
|
||||
|
||||
// Register a new push subscription
|
||||
|
||||
@@ -7,7 +7,12 @@ export const authenticate: Middleware = jwt({
|
||||
secret: jwtConfig.secret,
|
||||
}).unless({
|
||||
// Paths that don't require authentication
|
||||
path: ["/api/auth/login", "/api/auth/register", "/api/auth/register-admin"],
|
||||
path: [
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
"/api/auth/register-admin",
|
||||
"/api/push/vapid-public-key"
|
||||
],
|
||||
});
|
||||
|
||||
// Error handling middleware for JWT authentication
|
||||
|
||||
@@ -53,4 +53,8 @@ router.delete(
|
||||
categoriesController.deleteCategory,
|
||||
);
|
||||
|
||||
// Push notification routes
|
||||
router.get("/push/vapid-public-key", pushController.getVapidPublicKey);
|
||||
router.post("/push/register", pushController.registerPushSubscription);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@vee-validate/zod": "^4.15.0",
|
||||
"@vueuse/core": "^13.2.0",
|
||||
"chart.js": "^4.4.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
@@ -20,6 +21,7 @@
|
||||
"tw-animate-css": "^1.3.0",
|
||||
"vee-validate": "^4.15.0",
|
||||
"vue": "^3.5.14",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.1",
|
||||
"zod": "^3.25.7",
|
||||
},
|
||||
@@ -308,6 +310,8 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -562,6 +566,8 @@
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"chart.js": ["chart.js@4.4.9", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
@@ -1148,6 +1154,8 @@
|
||||
|
||||
"vue": ["vue@3.5.14", "", { "dependencies": { "@vue/compiler-dom": "3.5.14", "@vue/compiler-sfc": "3.5.14", "@vue/runtime-dom": "3.5.14", "@vue/server-renderer": "3.5.14", "@vue/shared": "3.5.14" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-LbOm50/vZFG6Mhy6KscQYXZMQ0LMCC/y40HDJPPvGFQ+i/lUH+PJHR6C3assgOQiXdl6tAfsXHbXYVBZZu65ew=="],
|
||||
|
||||
"vue-chartjs": ["vue-chartjs@5.3.2", "", { "peerDependencies": { "chart.js": "^4.1.1", "vue": "^3.0.0-0 || ^2.7.0" } }, "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"vue-router": ["vue-router@4.5.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="],
|
||||
|
||||
+27
-2
@@ -67,7 +67,7 @@ if (!self.define) {
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
||||
define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
@@ -82,11 +82,36 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.i9edo79oi9g"
|
||||
"revision": "0.luk243ceho"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({
|
||||
"cacheName": "google-fonts-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 31536000
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({
|
||||
"cacheName": "gstatic-fonts-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 31536000
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/\/api\/(?!.*auth).*/i, new workbox.NetworkFirst({
|
||||
"cacheName": "api-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 3600
|
||||
})]
|
||||
}), 'GET');
|
||||
|
||||
}));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@vee-validate/zod": "^4.15.0",
|
||||
"@vueuse/core": "^13.2.0",
|
||||
"chart.js": "^4.4.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
@@ -25,6 +26,7 @@
|
||||
"tw-animate-css": "^1.3.0",
|
||||
"vee-validate": "^4.15.0",
|
||||
"vue": "^3.5.14",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.1",
|
||||
"zod": "^3.25.7"
|
||||
},
|
||||
|
||||
+103
-7
@@ -110,24 +110,120 @@ self.addEventListener("sync", (event) => {
|
||||
|
||||
// Push notification handler
|
||||
self.addEventListener("push", (event) => {
|
||||
const data = event.data.json();
|
||||
let data;
|
||||
try {
|
||||
data = event.data.json();
|
||||
} catch (error) {
|
||||
// If the data is not JSON, try to get it as text
|
||||
data = {
|
||||
message: event.data ? event.data.text() : "New notification",
|
||||
additionalData: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Format transaction type for display
|
||||
let messageText = data.message || "New transaction";
|
||||
|
||||
// Default notification options
|
||||
const options = {
|
||||
body: data.body,
|
||||
body: messageText,
|
||||
icon: "/icons/icon-192px.jpg",
|
||||
badge: "/icons/badge-96x96.png",
|
||||
data: data.data,
|
||||
actions: data.actions || [],
|
||||
badge: "/icons/icon-192px.jpg",
|
||||
vibrate: [100, 50, 100],
|
||||
data: data.additionalData || {},
|
||||
actions: [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View Details'
|
||||
}
|
||||
],
|
||||
// Tag transactions by ID to prevent duplicate notifications
|
||||
tag: data.additionalData?.transactionId || 'transaction'
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(data.title, options));
|
||||
// Show different icons based on transaction type
|
||||
if (data.additionalData?.type === 'income') {
|
||||
options.icon = "/icons/income-icon.jpg";
|
||||
} else if (data.additionalData?.type === 'expense') {
|
||||
options.icon = "/icons/expense-icon.jpg";
|
||||
}
|
||||
|
||||
console.log('Showing notification:', messageText);
|
||||
|
||||
// Forward to any open clients
|
||||
forwardNotificationToClients(data);
|
||||
|
||||
// Show system notification
|
||||
event.waitUntil(self.registration.showNotification("Budget Manager", options));
|
||||
});
|
||||
|
||||
// Notification click handler
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
|
||||
event.waitUntil(clients.openWindow("/"));
|
||||
// Get transaction ID from notification data
|
||||
const transactionId = event.notification.data?.transactionId;
|
||||
|
||||
// Handle different notification actions
|
||||
if (event.action === 'view' && transactionId) {
|
||||
// Open transaction details page
|
||||
event.waitUntil(
|
||||
clients.matchAll({type: 'window'})
|
||||
.then(clientList => {
|
||||
// Check if there is already a window open
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes('/') && 'focus' in client) {
|
||||
client.navigate(`/edit/${transactionId}`);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// If no window is open, open a new one
|
||||
return clients.openWindow(`/edit/${transactionId}`);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Default: open the app
|
||||
event.waitUntil(
|
||||
clients.matchAll({type: 'window'})
|
||||
.then(clientList => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes('/') && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return clients.openWindow('/');
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward push notifications to all clients
|
||||
function forwardNotificationToClients(data) {
|
||||
self.clients.matchAll().then(clients => {
|
||||
clients.forEach(client => {
|
||||
// Send message to client
|
||||
client.postMessage({
|
||||
type: 'PUSH_NOTIFICATION',
|
||||
title: 'Budget Manager',
|
||||
body: data.message,
|
||||
transactionId: data.additionalData?.transactionId,
|
||||
type: data.additionalData?.type
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for messages from clients
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'GET_AUTH_TOKEN') {
|
||||
// Token request from service worker
|
||||
self.clients.matchAll().then(clients => {
|
||||
if (clients.length > 0) {
|
||||
const token = localStorage.getItem('token');
|
||||
event.ports[0].postMessage({ token });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sync transactions with the server
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { ref, onMounted } from "vue";
|
||||
import NotificationsProvider from "@/components/NotificationsProvider.vue";
|
||||
|
||||
// 'ref' is like React's useState - creates a reactive variable
|
||||
// In React: const [isPageLoaded, setIsPageLoaded] = useState(false)
|
||||
@@ -60,6 +61,9 @@
|
||||
-->
|
||||
<component :is="Component" :key="$route.fullPath" />
|
||||
</router-view>
|
||||
|
||||
<!-- Notifications provider for in-app push notifications -->
|
||||
<NotificationsProvider />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
-->
|
||||
<div class="py-2">
|
||||
<ul class="space-y-1">
|
||||
<!-- Auth-required links -->
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<li>
|
||||
<router-link
|
||||
to="/"
|
||||
@@ -57,9 +59,6 @@
|
||||
Dashboard
|
||||
</router-link>
|
||||
</li>
|
||||
|
||||
<!-- Auth-required links -->
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<li>
|
||||
<router-link
|
||||
to="/transactions"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast";
|
||||
import { Bell } from "lucide-vue-next";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import "@/../styles/tailwind.css";
|
||||
|
||||
// Track notifications
|
||||
const notifications = ref<
|
||||
Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type?: "default" | "success" | "info" | "warning" | "error";
|
||||
duration?: number;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
// Auth store
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Handle incoming push messages
|
||||
function handlePushMessage(event: MessageEvent) {
|
||||
if (event.data && event.data.type === "PUSH_NOTIFICATION") {
|
||||
const { title, body, transactionId, type } = event.data;
|
||||
|
||||
// Add to notifications
|
||||
notifications.value.push({
|
||||
id: transactionId || Date.now().toString(),
|
||||
title: title || "New Notification",
|
||||
description: body || "You have a new notification",
|
||||
type:
|
||||
type === "income"
|
||||
? "success"
|
||||
: type === "expense"
|
||||
? "warning"
|
||||
: "info",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a notification
|
||||
function removeNotification(id: string) {
|
||||
const index = notifications.value.findIndex((n) => n.id === id);
|
||||
if (index !== -1) {
|
||||
notifications.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up message listener when authenticated
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
(authenticated) => {
|
||||
if (authenticated && navigator.serviceWorker) {
|
||||
navigator.serviceWorker.addEventListener("message", handlePushMessage);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// Set up service worker message listener
|
||||
if (authStore.isAuthenticated && navigator.serviceWorker) {
|
||||
navigator.serviceWorker.addEventListener("message", handlePushMessage);
|
||||
}
|
||||
|
||||
// Clean up on unmount
|
||||
return () => {
|
||||
if (navigator.serviceWorker) {
|
||||
navigator.serviceWorker.removeEventListener(
|
||||
"message",
|
||||
handlePushMessage,
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastProvider>
|
||||
<Toast
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:class="notification.type ? `type-${notification.type}` : ''"
|
||||
@close="removeNotification(notification.id)">
|
||||
<div class="flex items-center gap-2">
|
||||
<Bell class="h-5 w-5" />
|
||||
<ToastTitle>{{ notification.title }}</ToastTitle>
|
||||
</div>
|
||||
<ToastDescription>{{ notification.description }}</ToastDescription>
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
</template>
|
||||
<!--
|
||||
<style scoped>
|
||||
.type-success {
|
||||
@apply border-green-500 bg-green-50;
|
||||
}
|
||||
.type-warning {
|
||||
@apply border-amber-500 bg-amber-50;
|
||||
}
|
||||
.type-error {
|
||||
@apply border-red-500 bg-red-50;
|
||||
}
|
||||
.type-info {
|
||||
@apply border-blue-500 bg-blue-50;
|
||||
}
|
||||
</style> -->
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale
|
||||
} from 'chart.js'
|
||||
import { computed } from 'vue';
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
chartData: {
|
||||
labels: string[],
|
||||
datasets: Array<{
|
||||
label: string,
|
||||
data: number[],
|
||||
backgroundColor?: string,
|
||||
borderColor?: string,
|
||||
borderWidth?: number
|
||||
}>
|
||||
},
|
||||
chartOptions?: any
|
||||
}>()
|
||||
|
||||
const defaultOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
display: true,
|
||||
color: 'rgba(200, 200, 200, 0.2)'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
boxWidth: 10
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(context.parsed.y);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options = computed(() => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
...props.chartOptions
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bar-chart">
|
||||
<Bar
|
||||
:data="chartData"
|
||||
:options="options"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bar-chart {
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
CategoryScale
|
||||
} from 'chart.js'
|
||||
import { computed } from 'vue';
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
CategoryScale
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
chartData: {
|
||||
labels: string[],
|
||||
datasets: Array<{
|
||||
data: number[],
|
||||
backgroundColor?: string[],
|
||||
borderColor?: string[],
|
||||
circumference?: number,
|
||||
rotation?: number,
|
||||
cutout?: string,
|
||||
borderWidth?: number
|
||||
}>
|
||||
},
|
||||
chartOptions?: any
|
||||
}>()
|
||||
|
||||
const defaultOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
boxWidth: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
cutout: '75%'
|
||||
}
|
||||
|
||||
const options = computed(() => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
...props.chartOptions
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="doughnut-chart">
|
||||
<Doughnut
|
||||
:data="chartData"
|
||||
:options="options"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doughnut-chart {
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { Pie } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
CategoryScale
|
||||
} from 'chart.js'
|
||||
import { computed } from 'vue';
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
CategoryScale
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
chartData: {
|
||||
labels: string[],
|
||||
datasets: Array<{
|
||||
data: number[],
|
||||
backgroundColor?: string[],
|
||||
borderColor?: string[],
|
||||
hoverOffset?: number,
|
||||
borderWidth?: number
|
||||
}>
|
||||
},
|
||||
chartOptions?: any
|
||||
}>()
|
||||
|
||||
const defaultOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
boxWidth: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const options = computed(() => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
...props.chartOptions
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pie-chart">
|
||||
<Pie
|
||||
:data="chartData"
|
||||
:options="options"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pie-chart {
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<!-- src/components/ui/toast/Toast.vue -->
|
||||
<script lang="ts">
|
||||
export const positions = {
|
||||
"top-left": "top-0 left-0",
|
||||
"top-center": "top-0 left-1/2 -translate-x-1/2",
|
||||
"top-right": "top-0 right-0",
|
||||
"bottom-left": "bottom-0 left-0",
|
||||
"bottom-center": "bottom-0 left-1/2 -translate-x-1/2",
|
||||
"bottom-right": "bottom-0 right-0",
|
||||
}
|
||||
|
||||
export type ToastProps = {
|
||||
position?: keyof typeof positions
|
||||
class?: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRefs } from "vue"
|
||||
|
||||
const props = defineProps<ToastProps>()
|
||||
const { position = "bottom-right", class: className } = toRefs(props)
|
||||
|
||||
const computedPosition = computed(() => positions[position.value])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
'pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-slate-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full bg-white dark:border-slate-800 dark:bg-slate-950',
|
||||
className,
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- src/components/ui/toast/ToastClose.vue -->
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next"
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "click", event: MouseEvent): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="absolute right-1 top-1 rounded-md p-1 text-slate-950/50 opacity-0 transition-opacity hover:text-slate-950 focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 dark:text-slate-50/50 dark:hover:text-slate-50"
|
||||
@click="(event) => emit('click', event)"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- src/components/ui/toast/ToastDescription.vue -->
|
||||
<template>
|
||||
<div class="text-sm opacity-90">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- src/components/ui/toast/ToastProvider.vue -->
|
||||
<template>
|
||||
<div class="relative">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- src/components/ui/toast/ToastTitle.vue -->
|
||||
<template>
|
||||
<div class="text-sm font-medium">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!-- src/components/ui/toast/ToastViewport.vue -->
|
||||
<script lang="ts">
|
||||
import { positions, type ToastProps } from "./Toast.vue"
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from "vue"
|
||||
|
||||
const props = withDefaults(defineProps<ToastProps>(), {
|
||||
position: "bottom-right",
|
||||
})
|
||||
const { position, class: className } = toRefs(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol
|
||||
:class="[
|
||||
'fixed z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:flex-col md:max-w-[420px] gap-2',
|
||||
positions[position],
|
||||
className,
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</ol>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as Toast } from "./Toast.vue"
|
||||
export { default as ToastClose } from "./ToastClose.vue"
|
||||
export { default as ToastDescription } from "./ToastDescription.vue"
|
||||
export { default as ToastProvider } from "./ToastProvider.vue"
|
||||
export { default as ToastTitle } from "./ToastTitle.vue"
|
||||
export { default as ToastViewport } from "./ToastViewport.vue"
|
||||
@@ -19,6 +19,9 @@ export async function registerServiceWorker() {
|
||||
console.log("Service worker active");
|
||||
}
|
||||
|
||||
// Ensure the service worker is ready before trying to subscribe
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
return registration;
|
||||
} catch (error) {
|
||||
console.error("Registration failed with error:", error);
|
||||
@@ -98,3 +101,132 @@ export function showBudgetAlert(percentage, category) {
|
||||
export function isOnline() {
|
||||
return navigator.onLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VAPID public key from the server
|
||||
* @returns {Promise<string>} The VAPID public key
|
||||
*/
|
||||
export async function getVapidPublicKey(): Promise<string | null> {
|
||||
try {
|
||||
console.log('Fetching VAPID public key...');
|
||||
const response = await fetch('/api/push/vapid-public-key', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/plain'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch VAPID public key, status:', response.status);
|
||||
throw new Error('Failed to fetch VAPID public key');
|
||||
}
|
||||
|
||||
const key = await response.text();
|
||||
console.log('Received VAPID public key:', key.substring(0, 10) + '...');
|
||||
return key;
|
||||
} catch (error) {
|
||||
console.error('Error getting VAPID public key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a base64 string to a Uint8Array for push subscription
|
||||
* @param {string} base64String The base64 string to convert
|
||||
* @returns {Uint8Array} The converted array
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to push notifications
|
||||
* @returns {Promise<PushSubscription|null>} The push subscription
|
||||
*/
|
||||
export async function subscribeToPushNotifications(): Promise<PushSubscription | null> {
|
||||
try {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
console.log('Push notifications not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get service worker registration
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
console.log('Service worker is ready for push subscription');
|
||||
|
||||
// Check for existing subscription
|
||||
const existingSubscription = await registration.pushManager.getSubscription();
|
||||
if (existingSubscription) {
|
||||
console.log('Already subscribed to push notifications');
|
||||
return existingSubscription;
|
||||
}
|
||||
|
||||
// Get VAPID key
|
||||
const vapidPublicKey = await getVapidPublicKey();
|
||||
if (!vapidPublicKey) {
|
||||
throw new Error('Failed to get VAPID public key');
|
||||
}
|
||||
|
||||
console.log('Converting VAPID key to Uint8Array');
|
||||
// Convert key to array
|
||||
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
||||
|
||||
console.log('Subscribing to push notifications...');
|
||||
// Subscribe
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
});
|
||||
|
||||
console.log('Push subscription successful, registering with server');
|
||||
// Register with backend
|
||||
await registerSubscriptionWithServer(subscription);
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push notifications:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register push subscription with the backend
|
||||
* @param {PushSubscription} subscription The push subscription to register
|
||||
*/
|
||||
async function registerSubscriptionWithServer(subscription: PushSubscription): Promise<boolean> {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/push/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ subscription })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to register subscription with server');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error registering subscription with server:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-4
@@ -4,12 +4,28 @@ import "../styles/transitions.css";
|
||||
import "../styles/style.css";
|
||||
import App from "./App.vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { registerServiceWorker } from "./lib/pwa-utils";
|
||||
import { registerServiceWorker, subscribeToPushNotifications } from "./lib/pwa-utils";
|
||||
|
||||
// Register service worker for PWA functionality
|
||||
if (import.meta.env.PROD) {
|
||||
registerServiceWorker();
|
||||
// Initialize PWA functionality
|
||||
async function initPWA() {
|
||||
// Register service worker
|
||||
const registration = await registerServiceWorker();
|
||||
|
||||
// Check if push notifications are enabled
|
||||
if (registration &&
|
||||
localStorage.getItem("notificationsEnabled") === "true" &&
|
||||
Notification.permission === "granted") {
|
||||
try {
|
||||
await subscribeToPushNotifications();
|
||||
console.log("Push notifications subscription successful");
|
||||
} catch (error) {
|
||||
console.error("Failed to subscribe to push notifications:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register service worker and initialize PWA
|
||||
initPWA();
|
||||
|
||||
const router = createRouter({
|
||||
// createWebHistory is like BrowserRouter in React Router
|
||||
@@ -20,6 +36,7 @@ const router = createRouter({
|
||||
// Dynamic imports for code splitting - similar to React.lazy
|
||||
// In React: const Dashboard = React.lazy(() => import('./pages/Dashboard'))
|
||||
component: () => import("./pages/Dashboard.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Login Page Component
|
||||
* -------------------
|
||||
* This is a page component that handles user authentication.
|
||||
* In React terms, this would be a container/page component with form handling logic.
|
||||
*/
|
||||
|
||||
// Import UI components - similar to importing from a UI library in React
|
||||
// These are shadcn components (Vue's equivalent of shadcn/ui for React)
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -30,9 +21,6 @@
|
||||
const isRegistering = ref(false);
|
||||
const email = ref("");
|
||||
|
||||
/**
|
||||
* Handle login form submission
|
||||
*/
|
||||
const login = async () => {
|
||||
if (!username.value || !password.value) {
|
||||
errorMessage.value = "Please enter both username and password";
|
||||
@@ -89,26 +77,12 @@
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle between login and register forms
|
||||
*/
|
||||
const toggleRegistration = () => {
|
||||
isRegistering.value = !isRegistering.value;
|
||||
errorMessage.value = "";
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<!--
|
||||
Main Template
|
||||
------------
|
||||
In React, this would all be JSX returned by the component function.
|
||||
|
||||
Key differences:
|
||||
- Vue uses <template> section instead of return statement with JSX
|
||||
- Vue uses @ for events (e.g., @submit) instead of React's camelCase (e.g., onSubmit)
|
||||
- Vue uses : for props (e.g., :disabled) instead of React's {} (e.g., disabled={value})
|
||||
- Vue uses v-for, v-if instead of React's .map() and && or ternary operators
|
||||
-->
|
||||
<PageLayout header="Login">
|
||||
<!-- Layout structure (similar to React) -->
|
||||
<div class="flex justify-center items-center h-full">
|
||||
|
||||
+145
-20
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { ref, computed, onMounted, reactive } from "vue";
|
||||
import PageLayout from "@/components/PageLayout.vue";
|
||||
import {
|
||||
Card,
|
||||
@@ -11,7 +11,10 @@
|
||||
import { useTransactionsStore } from "@/stores/transactions";
|
||||
import { useCategoriesStore } from "@/stores/categories";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { PieChart, BarChart, Calendar } from "lucide-vue-next";
|
||||
import { PieChart as PieChartIcon, BarChart as BarChartIcon, Calendar } from "lucide-vue-next";
|
||||
import PieChart from "@/components/charts/PieChart.vue";
|
||||
import BarChart from "@/components/charts/BarChart.vue";
|
||||
import DoughnutChart from "@/components/charts/DoughnutChart.vue";
|
||||
|
||||
// Initialize stores
|
||||
const transactionsStore = useTransactionsStore();
|
||||
@@ -166,6 +169,78 @@
|
||||
return result;
|
||||
});
|
||||
|
||||
// Chart data for monthly trend
|
||||
const monthlyTrendChartData = computed(() => {
|
||||
return {
|
||||
labels: monthlyTrend.value.map(item => item.month),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Income',
|
||||
data: monthlyTrend.value.map(item => item.income),
|
||||
backgroundColor: 'rgba(75, 192, 75, 0.6)',
|
||||
borderColor: 'rgba(75, 192, 75, 1)',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: 'Expenses',
|
||||
data: monthlyTrend.value.map(item => item.expenses),
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.6)',
|
||||
borderColor: 'rgba(255, 99, 132, 1)',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: 'Savings',
|
||||
data: monthlyTrend.value.map(item => item.savings),
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Chart data for category breakdown
|
||||
const categoryChartData = computed(() => {
|
||||
return {
|
||||
labels: categoryBreakdown.value.map(item => item.name),
|
||||
datasets: [{
|
||||
data: categoryBreakdown.value.map(item => item.amount),
|
||||
backgroundColor: categoryBreakdown.value.map(item => item.color),
|
||||
borderColor: categoryBreakdown.value.map(item => item.color),
|
||||
hoverOffset: 4,
|
||||
borderWidth: 1
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
// Budget usage doughnut chart data
|
||||
const budgetChartData = computed(() => {
|
||||
// For this example, we'll use a mock budget value of $2000
|
||||
// In a real app, you'd get this from user settings or another store
|
||||
const targetBudget = 2000;
|
||||
const spent = monthlySummary.value.expenses;
|
||||
const remaining = Math.max(0, targetBudget - spent);
|
||||
const percentage = Math.min(100, Math.round((spent / targetBudget) * 100));
|
||||
|
||||
let statusColor = '#22c55e'; // green for < 70%
|
||||
if (percentage >= 90) {
|
||||
statusColor = '#ef4444'; // red for >= 90%
|
||||
} else if (percentage >= 70) {
|
||||
statusColor = '#f59e0b'; // amber for >= 70%
|
||||
}
|
||||
|
||||
return {
|
||||
labels: ['Spent', 'Remaining'],
|
||||
datasets: [{
|
||||
data: [spent, remaining],
|
||||
backgroundColor: [statusColor, '#e5e7eb'],
|
||||
borderColor: ['transparent', 'transparent'],
|
||||
borderWidth: 0,
|
||||
cutout: '80%'
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
// Function to switch between report types
|
||||
const setReportType = (type) => {
|
||||
activeReport.value = type;
|
||||
@@ -186,13 +261,13 @@
|
||||
<Button
|
||||
:variant="activeReport === 'overview' ? 'default' : 'outline'"
|
||||
@click="setReportType('overview')">
|
||||
<PieChart class="mr-2 h-4 w-4" />
|
||||
<PieChartIcon class="mr-2 h-4 w-4" />
|
||||
Monthly Overview
|
||||
</Button>
|
||||
<Button
|
||||
:variant="activeReport === 'byCategory' ? 'default' : 'outline'"
|
||||
@click="setReportType('byCategory')">
|
||||
<BarChart class="mr-2 h-4 w-4" />
|
||||
<BarChartIcon class="mr-2 h-4 w-4" />
|
||||
Spending by Category
|
||||
</Button>
|
||||
<Button
|
||||
@@ -282,17 +357,53 @@
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Placeholder for budget comparison chart -->
|
||||
<!-- Budget comparison chart -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Budget Comparison</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="h-40 flex items-center justify-center">
|
||||
<p class="text-gray-500 text-center">
|
||||
Budget comparison visualization will appear here.
|
||||
<br /><small>Set your budget targets in Settings</small>
|
||||
</p>
|
||||
<div class="relative h-60">
|
||||
<DoughnutChart
|
||||
:chartData="budgetChartData"
|
||||
:chartOptions="{
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const value = context.parsed;
|
||||
const label = context.label;
|
||||
return `${label}: ${formatCurrency(value)}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<!-- Center text with percentage -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span class="text-4xl font-bold">
|
||||
{{ Math.min(100, Math.round((monthlySummary.expenses / 2000) * 100)) }}%
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">of monthly budget</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-between text-sm">
|
||||
<div>
|
||||
<div class="text-gray-500">Monthly Budget</div>
|
||||
<div class="font-medium">{{ formatCurrency(2000) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500">Spent</div>
|
||||
<div class="font-medium">{{ formatCurrency(monthlySummary.expenses) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500">Remaining</div>
|
||||
<div class="font-medium">{{ formatCurrency(Math.max(0, 2000 - monthlySummary.expenses)) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -312,11 +423,25 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Placeholder for pie chart -->
|
||||
<div class="h-40 flex items-center justify-center">
|
||||
<p class="text-gray-500 text-center">
|
||||
Category distribution chart will appear here.
|
||||
</p>
|
||||
<!-- Pie chart for category spending -->
|
||||
<div class="h-72">
|
||||
<PieChart
|
||||
:chartData="categoryChartData"
|
||||
:chartOptions="{
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const value = context.parsed;
|
||||
const label = context.label;
|
||||
const percentage = categoryBreakdown.value[context.dataIndex]?.percentage;
|
||||
return `${label}: ${formatCurrency(value)} (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category breakdown table -->
|
||||
@@ -349,11 +474,11 @@
|
||||
<CardTitle>Monthly Income & Expenses ({{ selectedYear }})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<!-- Placeholder for bar chart -->
|
||||
<div class="h-60 flex items-center justify-center mb-6">
|
||||
<p class="text-gray-500 text-center">
|
||||
Monthly trend chart will appear here.
|
||||
</p>
|
||||
<!-- Monthly trend bar chart -->
|
||||
<div class="h-80">
|
||||
<BarChart
|
||||
:chartData="monthlyTrendChartData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Monthly data table -->
|
||||
|
||||
@@ -19,8 +19,14 @@
|
||||
Moon,
|
||||
Sun,
|
||||
Smartphone,
|
||||
AlertCircle,
|
||||
} from "lucide-vue-next";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
requestNotificationPermission,
|
||||
registerServiceWorker,
|
||||
subscribeToPushNotifications
|
||||
} from "@/lib/pwa-utils";
|
||||
|
||||
// Initialize store
|
||||
const authStore = useAuthStore();
|
||||
@@ -40,6 +46,9 @@
|
||||
const isOnline = ref(navigator.onLine);
|
||||
const serviceWorkerRegistered = ref(false);
|
||||
const pushPermission = ref("default");
|
||||
const pushSubscribed = ref(false);
|
||||
const isSubscribing = ref(false);
|
||||
const subscriptionError = ref("");
|
||||
|
||||
// Check if PWA is installed
|
||||
onMounted(async () => {
|
||||
@@ -67,6 +76,13 @@
|
||||
try {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
serviceWorkerRegistered.value = registrations.length > 0;
|
||||
|
||||
// Check if already subscribed to push
|
||||
if (serviceWorkerRegistered.value) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
pushSubscribed.value = !!subscription;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking service worker:", error);
|
||||
}
|
||||
@@ -81,6 +97,9 @@
|
||||
if (permission === "granted") {
|
||||
notificationsEnabled.value = true;
|
||||
pushPermission.value = "granted";
|
||||
|
||||
// Subscribe to push notifications if permission granted
|
||||
subscribeToPushService();
|
||||
} else {
|
||||
notificationsEnabled.value = false;
|
||||
pushPermission.value = permission;
|
||||
@@ -88,6 +107,10 @@
|
||||
}
|
||||
} else {
|
||||
notificationsEnabled.value = !notificationsEnabled.value;
|
||||
|
||||
if (notificationsEnabled.value && !pushSubscribed.value) {
|
||||
subscribeToPushService();
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
@@ -97,6 +120,49 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to push notification service
|
||||
const subscribeToPushService = async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
subscriptionError.value = "You must be signed in to receive notifications";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubscribing.value = true;
|
||||
subscriptionError.value = "";
|
||||
|
||||
try {
|
||||
// Ensure service worker is registered
|
||||
if (!serviceWorkerRegistered.value) {
|
||||
await registerServiceWorker();
|
||||
serviceWorkerRegistered.value = true;
|
||||
}
|
||||
|
||||
// Request permission if not already granted
|
||||
if (Notification.permission !== "granted") {
|
||||
const permission = await requestNotificationPermission();
|
||||
if (permission !== "granted") {
|
||||
pushPermission.value = permission;
|
||||
throw new Error("Notification permission denied");
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to push notifications
|
||||
const subscription = await subscribeToPushNotifications();
|
||||
if (subscription) {
|
||||
pushSubscribed.value = true;
|
||||
notificationsEnabled.value = true;
|
||||
localStorage.setItem("notificationsEnabled", "true");
|
||||
} else {
|
||||
throw new Error("Failed to subscribe to push notifications");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error subscribing to push notifications:", error);
|
||||
subscriptionError.value = (error as Error).message || "Failed to subscribe to push notifications";
|
||||
} finally {
|
||||
isSubscribing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle dark mode
|
||||
const toggleDarkMode = () => {
|
||||
darkMode.value = !darkMode.value;
|
||||
@@ -172,7 +238,7 @@
|
||||
<div class="space-y-0.5">
|
||||
<Label>Push Notifications</Label>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Enable budget alerts and reminders
|
||||
Enable transaction alerts and reminders
|
||||
</div>
|
||||
<div
|
||||
v-if="pushPermission === 'denied'"
|
||||
@@ -180,13 +246,25 @@
|
||||
Permission denied. Please enable notifications in your browser
|
||||
settings.
|
||||
</div>
|
||||
<div
|
||||
v-if="subscriptionError"
|
||||
class="text-xs text-red-500 mt-1 flex items-center">
|
||||
<AlertCircle class="h-3 w-3 mr-1" />
|
||||
{{ subscriptionError }}
|
||||
</div>
|
||||
<div
|
||||
v-if="pushSubscribed"
|
||||
class="text-xs text-green-600 mt-1">
|
||||
Subscribed to transaction notifications
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@click="toggleNotifications"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="ml-auto"
|
||||
:disabled="pushPermission === 'denied'">
|
||||
:disabled="pushPermission === 'denied' || isSubscribing"
|
||||
:loading="isSubscribing">
|
||||
<Bell class="h-4 w-4 mr-2" />
|
||||
{{ notificationsEnabled ? "Disable" : "Enable" }}
|
||||
</Button>
|
||||
@@ -261,6 +339,16 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium">Push Notifications</p>
|
||||
<span
|
||||
:class="
|
||||
pushSubscribed ? 'text-green-600' : 'text-gray-600'
|
||||
">
|
||||
{{ pushSubscribed ? "Subscribed" : "Not Subscribed" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPWAInstalled" class="mt-4">
|
||||
<p class="text-sm text-gray-600 mb-2">
|
||||
Install this app on your device for better performance and
|
||||
|
||||
+66
-2
@@ -14,7 +14,72 @@ export default defineConfig({
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss(),
|
||||
VitePWA({ registerType: "autoUpdate", devOptions: { enabled: true } }),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
devOptions: { enabled: true },
|
||||
manifest: {
|
||||
name: "Budget Manager",
|
||||
short_name: "BudgetMgr",
|
||||
description: "Track and manage your finances",
|
||||
theme_color: "#ffffff",
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/icon-192px.jpg",
|
||||
sizes: "192x192",
|
||||
type: "image/png"
|
||||
},
|
||||
{
|
||||
src: "/icons/icon-512px.jpg",
|
||||
sizes: "512x512",
|
||||
type: "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,jpg}"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "google-fonts-cache",
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365 // <== 365 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "gstatic-fonts-cache",
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365 // <== 365 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/(?!.*auth).*/i, // All API routes except auth
|
||||
handler: "NetworkFirst",
|
||||
options: {
|
||||
cacheName: "api-cache",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 // 1 hour
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -26,7 +91,6 @@ export default defineConfig({
|
||||
"/api": {
|
||||
target: "http://localhost:3000", // Replace with your API server URL
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
port: 3001, // Replace with your desired port
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
if [ -z "$1" ]; then
|
||||
echo -e "\e[32;7;1mREAD THE CODE:\e[0m"
|
||||
echo
|
||||
if command -v bat &>/dev/null; then
|
||||
bat -Pp "$0"
|
||||
else
|
||||
cat "$0"
|
||||
fi
|
||||
echo
|
||||
echo -e "\e[32;7;1mEND OF CODE\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
IN="$1" # input file, if empty prints scripts itself
|
||||
OUT="${2:-$(basename "$IN" .md).pdf}" # if second argument is empty, use first argument without extension
|
||||
REST="${*:3}" # pass rest of arguments to pandoc
|
||||
COMMAND="pandoc \"$IN\" -o \"$OUT\" --pdf-engine=lualatex --pdf-engine-opt=\"--shell-escape\" $REST"
|
||||
eval "$COMMAND"
|
||||
@@ -0,0 +1,939 @@
|
||||
---
|
||||
title: "PWA z JavaScript"
|
||||
date: "2025-05-23"
|
||||
author: "Igor Barcik 131780"
|
||||
subtitle: "Projekt"
|
||||
papersize: a4
|
||||
geometry: margin=2cm
|
||||
header-includes:
|
||||
- \usepackage{float}
|
||||
- \usepackage{fontspec}
|
||||
- \usepackage{unicode-math}
|
||||
- \usepackage{xcolor}
|
||||
- \usepackage{minted}
|
||||
- \usepackage{mdframed}
|
||||
- \floatplacement{figure}{H}
|
||||
- \setmonofont{FiraCode Nerd Font Mono} # Set the font for code blocks
|
||||
- \definecolor{light-gray}{gray}{0.95} # Define a custom color
|
||||
- \BeforeBeginEnvironment{Highlighting}{\begin{mdframed}[backgroundcolor=light-gray,linewidth=0pt]} # Wrap Highlighting environments in a mdframed environment 1/2
|
||||
- \AfterEndEnvironment{Highlighting}{\end{mdframed}} # Wrap Highlighting environments in a mdframed environment 2/2
|
||||
- \DefineVerbatimEnvironment{Highlighting}{Verbatim}{breaklines, commandchars=\\\{\}, breakanywhere=true, fontsize=\small, breakafter=d, breakaftersymbolpre=, breakaftersymbolpost=} # Define the Verbatim environment for code blocks
|
||||
---
|
||||
|
||||
\newpage
|
||||
|
||||
# Budget Manager PWA - Raport projektu
|
||||
|
||||
## 1. Wprowadzenie
|
||||
|
||||
Aplikacja "Budget Manager PWA" *([link do git](https://gitea.bigoscloud.com/biggy/budget-manager))* to progresywna aplikacja internetowa (PWA) służąca do zarządzania budżetem osobistym. Umożliwia śledzenie wydatków i przychodów, kategoryzowanie transakcji, generowanie raportów oraz ustawianie powiadomień związanych z budżetem. Aplikacja działa zarówno online, jak i offline, dzięki wykorzystaniu nowoczesnych technologii webowych.
|
||||
|
||||
### 1.1 Cel projektu
|
||||
|
||||
Celem projektu było stworzenie aplikacji PWA, która:
|
||||
|
||||
- Umożliwia śledzenie wydatków i przychodów użytkownika
|
||||
- Działa zarówno online, jak i offline
|
||||
- Przechowuje dane w Local Storage oraz MongoDB
|
||||
- Oferuje funkcje budżetowania, raportowania i analizy finansowej
|
||||
|
||||
### 1.2 Wykorzystane technologie
|
||||
|
||||
Frontend:
|
||||
|
||||
- Vue 3 z Composition API
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- Vite (bundler)
|
||||
- Pinia (zarządzanie stanem)
|
||||
- Vue Router
|
||||
|
||||
Backend:
|
||||
|
||||
- Koa.js
|
||||
- TypeScript
|
||||
- MongoDB
|
||||
|
||||
PWA:
|
||||
|
||||
- Service Worker
|
||||
- Web App Manifest
|
||||
- IndexedDB (poprzez abstrakcję)
|
||||
|
||||
## 2. Architektura aplikacji
|
||||
|
||||
### 2.1 Struktura projektu
|
||||
|
||||
Projekt jest podzielony na dwie główne części:
|
||||
|
||||
#### Frontend
|
||||
|
||||
```bash
|
||||
frontend/
|
||||
├── public/ # Statyczne pliki i manifest PWA
|
||||
│ ├── manifest.json # Konfiguracja PWA
|
||||
│ ├── sw.js # Service Worker
|
||||
│ └── icons/ # Ikony dla PWA
|
||||
├── src/
|
||||
│ ├── components/ # Komponenty Vue
|
||||
│ ├── pages/ # Widoki aplikacji
|
||||
│ ├── stores/ # Pinia stores
|
||||
│ ├── lib/ # Funkcje pomocnicze
|
||||
│ │ └── pwa-utils.ts # Narzędzia związane z PWA
|
||||
│ └── main.ts # Główny plik aplikacji
|
||||
└── styles/ # Style CSS
|
||||
```
|
||||
|
||||
#### Backend
|
||||
|
||||
```bash
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── app.ts # Główny plik aplikacji
|
||||
│ ├── controllers/ # Kontrolery REST API
|
||||
│ ├── middleware/ # Middleware (np. autoryzacja)
|
||||
│ └── services/ # Serwisy (np. baza danych)
|
||||
└── scripts/
|
||||
└── generate-vapid-keys.js # Skrypt do generowania kluczy VAPID
|
||||
```
|
||||
|
||||
### 2.2 Przepływ danych
|
||||
|
||||
1. Dane użytkownika są przechowywane lokalnie w Local Storage dla dostępu offline
|
||||
2. Przy połączeniu z internetem dane są synchronizowane z bazą MongoDB na serwerze
|
||||
3. Service Worker zapisuje dane do cache, umożliwiając działanie aplikacji offline
|
||||
4. Background Sync API jest używane do synchronizacji danych, gdy połączenie internetowe zostanie przywrócone
|
||||
|
||||
## 3. Implementacja PWA
|
||||
|
||||
### 3.1 Manifest aplikacji
|
||||
|
||||
Manifest definiuje jak aplikacja ma się zachowywać po zainstalowaniu na urządzeniu użytkownika:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Budget Manager PWA",
|
||||
"short_name": "Budget",
|
||||
"description": "Budget Manager application for tracking expenses and income",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#3b82f6",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192px.jpg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512px.jpg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Add Transaction",
|
||||
"short_name": "Add",
|
||||
"description": "Add a new transaction",
|
||||
"url": "/add",
|
||||
"icons": [{ "src": "/icons/add-icon-96x96.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "View Reports",
|
||||
"short_name": "Reports",
|
||||
"description": "View budget reports",
|
||||
"url": "/reports",
|
||||
"icons": [{ "src": "/icons/report-icon-96x96.png", "sizes": "96x96" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Service Worker
|
||||
|
||||
Service Worker obsługuje:
|
||||
|
||||
- Cache'owanie zasobów dla dostępu offline
|
||||
- Synchronizację w tle (background sync)
|
||||
|
||||
Kluczowe fragmenty implementacji:
|
||||
|
||||
```javascript
|
||||
// Instalacja Service Worker'a
|
||||
self.addEventListener("install", (event) => {
|
||||
console.log("Service worker install event");
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
return cache.addAll(precacheResources);
|
||||
})
|
||||
.then(() => {
|
||||
return self.skipWaiting();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Strategia fetch: cache-first, następnie sieć
|
||||
self.addEventListener("fetch", (event) => {
|
||||
// Pomijanie żądań cross-origin i API
|
||||
if (
|
||||
!event.request.url.startsWith(self.location.origin) ||
|
||||
event.request.url.includes("/api/")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
return fetch(event.request)
|
||||
.then((response) => {
|
||||
// Cache'owanie odpowiedzi
|
||||
if (
|
||||
response &&
|
||||
response.status === 200 &&
|
||||
response.type === "basic"
|
||||
) {
|
||||
const responseToCache = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback w przypadku braku sieci
|
||||
if (event.request.mode === "navigate") {
|
||||
return caches.match("/");
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Obsługa background sync
|
||||
self.addEventListener("sync", (event) => {
|
||||
if (event.tag === "sync-transactions") {
|
||||
event.waitUntil(syncTransactions());
|
||||
} else if (event.tag === "sync-categories") {
|
||||
event.waitUntil(syncCategories());
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3.3 Rejestracja Service Worker
|
||||
|
||||
Rejestracja Service Worker'a w aplikacji:
|
||||
|
||||
```typescript
|
||||
export async function registerServiceWorker() {
|
||||
if ("serviceWorker" in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register("/sw.js", {
|
||||
scope: "/",
|
||||
});
|
||||
|
||||
if (registration.installing) {
|
||||
console.log("Service worker installing");
|
||||
} else if (registration.waiting) {
|
||||
console.log("Service worker installed");
|
||||
} else if (registration.active) {
|
||||
console.log("Service worker active");
|
||||
}
|
||||
|
||||
// Upewniamy się, że service worker jest gotowy przed próbą subskrypcji
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
return registration;
|
||||
} catch (error) {
|
||||
console.error("Registration failed with error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Powiadomienia Push
|
||||
|
||||
Powiadomienia Push są kluczowym elementem aplikacji PWA, umożliwiającym informowanie użytkownika o ważnych zdarzeniach, nawet gdy aplikacja nie jest aktywna w przeglądarce. W Budget Manager PWA, powiadomienia push są wykorzystywane do informowania użytkowników o nowych transakcjach dodanych do ich budżetu.
|
||||
|
||||
#### 3.4.1 Klucze VAPID
|
||||
|
||||
Do bezpiecznego wysyłania powiadomień push wykorzystywany jest protokół VAPID (Voluntary Application Server Identification). Klucze VAPID (publiczny i prywatny) są generowane za pomocą skryptu i przechowywane jako zmienne środowiskowe na serwerze.
|
||||
|
||||
Skrypt `backend/scripts/generate-vapid-keys.js` generuje te klucze:
|
||||
|
||||
```bash
|
||||
# backend/scripts/generate-vapid-keys.js
|
||||
const webpush = require('web-push');
|
||||
const fs = require('fs');
|
||||
|
||||
const vapidKeys = webpush.generateVAPIDKeys();
|
||||
|
||||
console.log('Public Key: ', vapidKeys.publicKey);
|
||||
console.log('Private Key: ', vapidKeys.privateKey);
|
||||
|
||||
// Zapisz klucze do pliku .env lub użyj bezpośrednio
|
||||
fs.appendFileSync('.env.example', `\nVAPID_PUBLIC_KEY=${vapidKeys.publicKey}`);
|
||||
fs.appendFileSync('.env.example', `VAPID_PRIVATE_KEY=${vapidKeys.privateKey}\n`);
|
||||
console.log('VAPID keys appended to .env.example');
|
||||
```
|
||||
|
||||
Klucz publiczny VAPID jest udostępniany frontendowi, aby mógł zasubskrybować usługę push. Klucze są ładowane na serwerze przy starcie aplikacji.
|
||||
|
||||
#### 3.4.2 Implementacja Frontend
|
||||
|
||||
**Subskrypcja powiadomień:**
|
||||
|
||||
Logika subskrypcji znajduje się w `frontend/src/lib/pwa-utils.ts`. Użytkownik może włączyć lub wyłączyć powiadomienia na stronie ustawień (`frontend/src/pages/Settings.vue`). Proces obejmuje:
|
||||
|
||||
1. Sprawdzenie wsparcia dla Service Worker i Push API.
|
||||
2. Poproszenie użytkownika o zgodę na otrzymywanie powiadomień.
|
||||
3. Pobranie klucza publicznego VAPID z backendu (`/api/push/vapid-public-key`).
|
||||
4. Subskrypcja usługi push za pomocą `registration.pushManager.subscribe()`.
|
||||
5. Wysłanie obiektu subskrypcji na backend (`/api/push/subscribe`) w celu zapisania.
|
||||
|
||||
Fragment kodu z `pwa-utils.ts` odpowiedzialny za subskrypcję:
|
||||
|
||||
```typescript
|
||||
// frontend/src/lib/pwa-utils.ts
|
||||
// ... (uproszczony przykład)
|
||||
async function subscribeToPushNotifications(registration: ServiceWorkerRegistration) {
|
||||
try {
|
||||
const vapidPublicKeyResponse = await fetch('/api/push/vapid-public-key', { headers: { 'Accept': 'text/plain' } });
|
||||
if (!vapidPublicKeyResponse.ok) {
|
||||
throw new Error(`Failed to get VAPID public key: ${vapidPublicKeyResponse.statusText}`);
|
||||
}
|
||||
const vapidPublicKey = await vapidPublicKeyResponse.text();
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
|
||||
// Odczyt tokena JWT z localStorage
|
||||
const token = localStorage.getItem('authToken'); // Upewnij się, że klucz jest poprawny
|
||||
if (!token) {
|
||||
console.warn('No auth token found, cannot save subscription to backend.');
|
||||
// Można rozważyć zapisanie subskrypcji lokalnie i wysłanie później
|
||||
return false;
|
||||
}
|
||||
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(subscription),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
});
|
||||
console.log('User is subscribed.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to subscribe the user: ', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray.buffer; // Zwraca ArrayBuffer
|
||||
}
|
||||
```
|
||||
|
||||
**Obsługa powiadomień w Service Worker (`frontend/public/sw.js`):**
|
||||
|
||||
Service Worker nasłuchuje na zdarzenie `push` i `notificationclick`.
|
||||
|
||||
- Po otrzymaniu zdarzenia `push`, Service Worker wyświetla powiadomienie systemowe z danymi otrzymanymi od serwera (np. tytuł, treść, ikonę). Wiadomość jest również przekazywana do aktywnych klientów aplikacji w celu wyświetlenia toastu.
|
||||
- Zdarzenie `notificationclick` obsługuje interakcję użytkownika z powiadomieniem, np. otwarcie aplikacji lub konkretnej strony.
|
||||
|
||||
```javascript
|
||||
// frontend/public/sw.js
|
||||
self.addEventListener('push', event => {
|
||||
let data = { title: 'Nowa wiadomość', body: 'Otrzymałeś nowe powiadomienie.', data: { url: '/' } };
|
||||
if (event.data) {
|
||||
try {
|
||||
data = event.data.json();
|
||||
} catch (e) {
|
||||
console.error('Push event data is not valid JSON:', event.data.text());
|
||||
data.body = event.data.text(); // Użyj tekstu jako body jeśli JSON się nie sparsuje
|
||||
}
|
||||
}
|
||||
|
||||
const title = data.title || 'Budget Manager';
|
||||
const options = {
|
||||
body: data.body || 'Nowa aktywność na Twoim koncie.',
|
||||
icon: data.icon || '/icons/icon-192px.jpg',
|
||||
badge: '/icons/icon-192px.jpg', // Użyj tej samej ikony dla badge
|
||||
data: data.data || { url: '/' }
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
|
||||
// Wysyłanie wiadomości do aktywnych klientów (zakładek aplikacji)
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => {
|
||||
if (clients && clients.length) {
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'PUSH_NOTIFICATION_RECEIVED',
|
||||
payload: data // Przekaż całe dane, w tym title, body, data.url
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
|
||||
const urlToOpen = event.notification.data && event.notification.data.url
|
||||
? event.notification.data.url
|
||||
: '/';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => {
|
||||
for (let i = 0; i < windowClients.length; i++) {
|
||||
const client = windowClients[i];
|
||||
// Sprawdź, czy URL klienta (po usunięciu hasha) pasuje do urlToOpen
|
||||
const clientURL = new URL(client.url);
|
||||
const targetURL = new URL(urlToOpen, self.location.origin); // Upewnij się, że targetURL jest absolutny
|
||||
if (clientURL.origin + clientURL.pathname === targetURL.origin + targetURL.pathname && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
#### 3.4.3 Implementacja Backend
|
||||
|
||||
Backend (`backend/src/controllers/push.ts`) odpowiada za:
|
||||
|
||||
1. Udostępnianie publicznego klucza VAPID (`/api/push/vapid-public-key`). Ten endpoint jest publicznie dostępny.
|
||||
2. Rejestrowanie subskrypcji użytkowników (`/api/push/subscribe`). Subskrypcje są przechowywane w bazie danych, powiązane z użytkownikiem.
|
||||
3. Wysłanie powiadomień do zasubskrybowanych użytkowników. Na przykład, po dodaniu nowej transakcji (`backend/src/controllers/protected.ts`), serwer wysyła powiadomienie.
|
||||
|
||||
Fragment kodu z `pushController.ts` (uproszczony):
|
||||
|
||||
```typescript
|
||||
// backend/src/controllers/push.ts
|
||||
import webpush from 'web-push';
|
||||
import { Context } from 'koa';
|
||||
import User from '../models'; // Załóżmy, że User model ma pole pushSubscriptions
|
||||
|
||||
// Konfiguracja VAPID keys przy starcie aplikacji (np. w app.ts lub dedykowanym pliku konfiguracyjnym)
|
||||
// webpush.setVapidDetails(
|
||||
// 'mailto:your-email@example.com',
|
||||
// process.env.VAPID_PUBLIC_KEY!,
|
||||
// process.env.VAPID_PRIVATE_KEY!
|
||||
// );
|
||||
|
||||
export const getVapidPublicKey = async (ctx: Context) => {
|
||||
if (!process.env.VAPID_PUBLIC_KEY) {
|
||||
ctx.throw(500, 'VAPID public key not configured');
|
||||
return;
|
||||
}
|
||||
ctx.body = process.env.VAPID_PUBLIC_KEY;
|
||||
ctx.set('Content-Type', 'text/plain');
|
||||
};
|
||||
|
||||
export const subscribe = async (ctx: Context) => {
|
||||
const subscription = ctx.request.body as webpush.PushSubscription;
|
||||
const userId = ctx.state.user.id; // ID zalogowanego użytkownika
|
||||
|
||||
try {
|
||||
// Zapisz subskrypcję w dokumencie użytkownika
|
||||
// Załóżmy, że model User ma pole pushSubscriptions: webpush.PushSubscription[]
|
||||
await User.findByIdAndUpdate(userId, { $addToSet: { pushSubscriptions: subscription } });
|
||||
ctx.status = 201;
|
||||
ctx.body = { message: 'Subscribed successfully' };
|
||||
} catch (error) {
|
||||
console.error('Error saving subscription:', error);
|
||||
ctx.throw(500, 'Failed to save subscription');
|
||||
}
|
||||
};
|
||||
|
||||
// Funkcja do wysyłania powiadomień (może być w serwisie)
|
||||
export const sendPushNotification = async (userId: string, payload: object) => {
|
||||
try {
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.pushSubscriptions || user.pushSubscriptions.length === 0) {
|
||||
console.log(`No push subscriptions found for user ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationPayload = JSON.stringify(payload);
|
||||
|
||||
for (const sub of user.pushSubscriptions) {
|
||||
try {
|
||||
await webpush.sendNotification(sub, notificationPayload);
|
||||
} catch (error: any) {
|
||||
console.error('Error sending notification to a subscription: ', error.body);
|
||||
// Jeśli subskrypcja jest nieaktualna (np. status 410 Gone), usuń ją
|
||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||
await User.findByIdAndUpdate(userId, { $pull: { pushSubscriptions: sub } });
|
||||
console.log('Removed outdated subscription.');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to send push notifications for user ${userId}:`, error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Integracja wysyłania powiadomień przy tworzeniu transakcji w `protectedController.ts`:
|
||||
|
||||
```typescript
|
||||
// backend/src/controllers/protected.ts
|
||||
// ...
|
||||
import { sendPushNotification } from './push'; // Zaimportuj funkcję
|
||||
|
||||
export const addTransaction = async (ctx: Context) => {
|
||||
// ... logika dodawania transakcji ...
|
||||
// const newTransaction = ...; // Wynik dodania transakcji
|
||||
// const userId = ctx.state.user.id;
|
||||
|
||||
// Po pomyślnym dodaniu transakcji:
|
||||
if (newTransaction && userId) { // Upewnij się, że newTransaction i userId istnieją
|
||||
const notificationPayload = {
|
||||
title: 'Nowa transakcja',
|
||||
body: `Dodano: ${newTransaction.description} (${newTransaction.amount} ${newTransaction.currency || 'PLN'})`,
|
||||
data: { url: `/transactions` } // Ogólny link do transakcji lub ID: /transactions/${newTransaction._id}
|
||||
};
|
||||
// Wyślij asynchronicznie, aby nie blokować odpowiedzi
|
||||
sendPushNotification(userId, notificationPayload).catch(console.error);
|
||||
}
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
Powiadomienia push znacząco zwiększają zaangażowanie użytkowników i użyteczność aplikacji Budget Manager PWA, informując ich na bieżąco o zmianach w ich budżecie.
|
||||
|
||||
## 4. Zarządzanie stanem aplikacji
|
||||
|
||||
### 4.1 Stores w Pinia
|
||||
|
||||
Aplikacja używa Pinia do zarządzania stanem, co ułatwia komunikację między komponentami i zarządzanie danymi aplikacji.
|
||||
|
||||
Przykładowy store dla transakcji:
|
||||
|
||||
```typescript
|
||||
export const useTransactionsStore = defineStore("transactions", () => {
|
||||
// Stan
|
||||
const transactions = ref<Transaction[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
// API URL
|
||||
const apiBaseUrl = "http://localhost:3000/api";
|
||||
|
||||
// Store autoryzacji
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Gettery
|
||||
const incomes = computed(() =>
|
||||
transactions.value.filter((t) => t.type === "income"),
|
||||
);
|
||||
|
||||
const expenses = computed(() =>
|
||||
transactions.value.filter((t) => t.type === "expense"),
|
||||
);
|
||||
|
||||
const totalIncome = computed(() =>
|
||||
incomes.value.reduce((sum, t) => sum + t.amount, 0),
|
||||
);
|
||||
|
||||
const totalExpenses = computed(() =>
|
||||
expenses.value.reduce((sum, t) => sum + t.amount, 0),
|
||||
);
|
||||
|
||||
const balance = computed(() => totalIncome.value - totalExpenses.value);
|
||||
|
||||
// Akcje
|
||||
async function fetchTransactions() {
|
||||
if (!authStore.token) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/transactions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch transactions");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
transactions.value = data.data;
|
||||
|
||||
// Zapisywanie do localStorage dla dostępu offline
|
||||
saveToLocalStorage();
|
||||
} catch (err: any) {
|
||||
error.value = err.message;
|
||||
// Jeśli API call nie powiedzie się, próba załadowania z localStorage
|
||||
loadFromLocalStorage();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ... inne metody
|
||||
|
||||
return {
|
||||
transactions,
|
||||
loading,
|
||||
error,
|
||||
incomes,
|
||||
expenses,
|
||||
totalIncome,
|
||||
totalExpenses,
|
||||
balance,
|
||||
fetchTransactions,
|
||||
// ... inne eksportowane metody
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### 4.2 Synchronizacja offline/online
|
||||
|
||||
Aplikacja wykorzystuje strategię "offline-first", gdzie dane są najpierw zapisywane lokalnie, a następnie synchronizowane z serwerem gdy połączenie jest dostępne:
|
||||
|
||||
```typescript
|
||||
// Dodawanie transakcji
|
||||
async function addTransaction(transaction: Omit<Transaction, "_id">) {
|
||||
try {
|
||||
error.value = "";
|
||||
|
||||
// Generujemy tymczasowe ID dla transakcji
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const newTransaction = {
|
||||
...transaction,
|
||||
_id: tempId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
synced: false
|
||||
};
|
||||
|
||||
// Dodajemy do listy lokalnie
|
||||
transactions.value.push(newTransaction);
|
||||
saveToLocalStorage();
|
||||
|
||||
// Próbujemy zapisać na serwerze
|
||||
if (navigator.onLine && authStore.token) {
|
||||
const response = await fetch(`${apiBaseUrl}/transactions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authStore.token}`,
|
||||
},
|
||||
body: JSON.stringify(transaction),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Aktualizujemy transakcję z danymi z serwera
|
||||
const data = await response.json();
|
||||
const index = transactions.value.findIndex(t => t._id === tempId);
|
||||
if (index !== -1) {
|
||||
transactions.value[index] = { ...data.data, synced: true };
|
||||
saveToLocalStorage();
|
||||
}
|
||||
} else {
|
||||
// Oznaczamy transakcję do późniejszej synchronizacji
|
||||
const syncStore = useSyncStore();
|
||||
syncStore.addPendingTransaction(newTransaction);
|
||||
|
||||
throw new Error("Failed to save transaction to server");
|
||||
}
|
||||
} else {
|
||||
// Jesteśmy offline, dodajemy do listy oczekujących na sync
|
||||
const syncStore = useSyncStore();
|
||||
syncStore.addPendingTransaction(newTransaction);
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Interfejs użytkownika
|
||||
|
||||
### 5.1 Komponenty
|
||||
|
||||
Aplikacja wykorzystuje komponenty Vue do zbudowania interfejsu użytkownika. Przykładowy komponent Dashboard:
|
||||
|
||||

|
||||
|
||||
```vue
|
||||
<template>
|
||||
<PageLayout header="Dashboard">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Podsumowanie salda -->
|
||||
<div class="lg:col-span-2">
|
||||
<BalanceSummary />
|
||||
</div>
|
||||
|
||||
<!-- Szybkie akcje -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<router-link to="/add" class="w-full block">
|
||||
<Button class="w-full" variant="default">Add New Transaction</Button>
|
||||
</router-link>
|
||||
<router-link to="/reports" class="w-full block">
|
||||
<Button class="w-full" variant="outline">View Reports</Button>
|
||||
</router-link>
|
||||
<!-- Inne przyciski akcji -->
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Ostatnie transakcje -->
|
||||
<Card class="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<!-- Zawartość sekcji transakcji -->
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.2 Responsywność
|
||||
|
||||
Aplikacja wykorzystuje Tailwind CSS do tworzenia responsywnego interfejsu, który działa dobrze na urządzeniach mobilnych i desktopowych:
|
||||
|
||||

|
||||
|
||||
- Używanie systemu grid z breakpointami (md:, lg:)
|
||||
- Elastyczne komponenty dopasowujące się do różnych rozmiarów ekranów
|
||||
- Mobilne menu nawigacyjne dla małych ekranów
|
||||
|
||||
### 5.3 Dostępność offline
|
||||
|
||||
Interfejs uwzględnia stan połączenia internetowego:
|
||||
|
||||

|
||||
|
||||
- Wyświetlanie informacji o trybie offline
|
||||
- Możliwość dodawania transakcji offline
|
||||
- Automatyczna synchronizacja po ponownym połączeniu
|
||||
|
||||
## 6. Backend API
|
||||
|
||||
### 6.1 Struktura API
|
||||
|
||||
Backend implementuje RESTful API do komunikacji z frontendem:
|
||||
|
||||
- `POST /api/auth/login` - logowanie użytkownika
|
||||
- `POST /api/auth/register` - rejestracja nowego użytkownika
|
||||
- `GET /api/transactions` - pobieranie listy transakcji
|
||||
- `POST /api/transactions` - dodawanie nowej transakcji
|
||||
- `PUT /api/transactions/:id` - aktualizacja transakcji
|
||||
- `DELETE /api/transactions/:id` - usunięcie transakcji
|
||||
- `GET /api/categories` - pobieranie kategorii
|
||||
- `PUT /api/categories` - dodawanie nowej kategorii
|
||||
|
||||
### 6.2 Bezpieczeństwo API
|
||||
|
||||
API zabezpieczone jest przez:
|
||||
|
||||
- Tokeny JWT do autoryzacji
|
||||
- Middleware uwierzytelniające
|
||||
- Zabezpieczenie przed CSRF
|
||||
- CORS
|
||||
|
||||
```typescript
|
||||
// Middleware autoryzacji
|
||||
export const authenticate = async (ctx: Context, next: Next) => {
|
||||
// Pomijamy middleware dla ścieżek publicznych
|
||||
if (ctx.path.startsWith('/api/auth/login') ||
|
||||
ctx.path.startsWith('/api/auth/register')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = ctx.headers.authorization?.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
ctx.status = 401;
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: 'No token provided'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, jwtConfig.secret);
|
||||
ctx.state.user = decoded;
|
||||
return next();
|
||||
} catch (err) {
|
||||
ctx.throw(401, 'Invalid or expired token');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 7. Baza danych
|
||||
|
||||
### 7.1 Model danych
|
||||
|
||||
Aplikacja wykorzystuje MongoDB do przechowywania danych, z następującymi kolekcjami:
|
||||
|
||||
1. `users` - informacje o użytkownikach
|
||||
2. `transactions` - transakcje finansowe
|
||||
3. `categories` - kategorie transakcji
|
||||
|
||||
### 7.2 Usługa bazodanowa
|
||||
|
||||
Abstrakcja dostępu do bazy danych zapewniona jest przez klasę DatabaseService:
|
||||
|
||||
```typescript
|
||||
export default class DatabaseService {
|
||||
private static instance: DatabaseService;
|
||||
private client: MongoClient | null = null;
|
||||
private db: Db | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
// Wzorzec Singleton
|
||||
public static getInstance(): DatabaseService {
|
||||
if (!DatabaseService.instance) {
|
||||
DatabaseService.instance = new DatabaseService();
|
||||
}
|
||||
return DatabaseService.instance;
|
||||
}
|
||||
|
||||
// Połączenie z bazą danych
|
||||
public async connect() {
|
||||
try {
|
||||
this.client = await MongoClient.connect(dbConfig.url);
|
||||
this.db = this.client.db(dbConfig.name);
|
||||
console.log('Connected to MongoDB');
|
||||
} catch (error) {
|
||||
console.error('Error connecting to MongoDB:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Pobranie kolekcji
|
||||
public getCollection(collectionName: string): Collection {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not connected');
|
||||
}
|
||||
return this.db.collection(collectionName);
|
||||
}
|
||||
|
||||
// Zamknięcie połączenia
|
||||
public async disconnect() {
|
||||
if (this.client) {
|
||||
await this.client.close();
|
||||
this.client = null;
|
||||
this.db = null;
|
||||
console.log('Disconnected from MongoDB');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Testy i użycie
|
||||
|
||||
### 8.1 Testowanie offline
|
||||
|
||||
Aplikacja została przetestowana w różnych scenariuszach:
|
||||
|
||||
- Dodawanie transakcji w trybie offline
|
||||
- Automatyczna synchronizacja po przywróceniu połączenia
|
||||
- Działanie Service Worker'a i cache'owanie zasobów
|
||||
|
||||
### 8.2 Instalacja jako PWA
|
||||
|
||||
Aplikacja może być zainstalowana na urządzeniach:
|
||||
|
||||
- Poprawnie wyświetla prompt instalacyjny
|
||||
- Działa jako samodzielna aplikacja po zainstalowaniu
|
||||
- Wykorzystuje ikony i kolory zdefiniowane w manifeście
|
||||
|
||||
## 9. Wnioski i perspektywy rozwoju
|
||||
|
||||
### 9.1 Osiągnięte cele
|
||||
|
||||
Aplikacja spełnia wszystkie założenia projektowe:
|
||||
|
||||
- Funkcjonuje jako pełnoprawna PWA
|
||||
- Umożliwia zarządzanie budżetem osobistym
|
||||
- Działa offline z synchronizacją
|
||||
- Ma responsywny i przyjazny interfejs
|
||||
|
||||
### 9.2 Możliwe rozszerzenia
|
||||
|
||||
Potencjalne kierunki rozwoju aplikacji:
|
||||
|
||||
- Dodanie eksportu danych do CSV/PDF
|
||||
- Implementacja celów oszczędnościowych
|
||||
- Rozbudowa funkcji raportowania o wykresy
|
||||
- Dodanie powiadomień o zbliżających się regularnych płatnościach
|
||||
- Integracja z bankami poprzez API
|
||||
|
||||
## 10. Zrzuty ekranu
|
||||
|
||||
### 10.1 Ekran logowania
|
||||
|
||||

|
||||
|
||||
### 10.2 Ekran transakcji
|
||||
|
||||

|
||||
|
||||
### 10.3 Ekran kategorii
|
||||
|
||||

|
||||
|
||||
### 10.4 Ekran raportów
|
||||
|
||||

|
||||
|
||||
### 10.5 Ekran ustawień
|
||||
|
||||

|
||||
|
||||
## 11. Bibliografia
|
||||
|
||||
1. Vue.js Documentation: [https://vuejs.org/guide/introduction.html](https://vuejs.org/guide/introduction.html)
|
||||
2. Progressive Web Apps: [https://web.dev/progressive-web-apps/](https://web.dev/progressive-web-apps/)
|
||||
3. MDN Web Docs - Service Worker API: [https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
|
||||
4. Tailwind CSS Documentation: [https://tailwindcss.com/docs](https://tailwindcss.com/docs)
|
||||
5. Koa.js Documentation: [https://koajs.com/](https://koajs.com/)
|
||||
6. MongoDB Documentation: [https://docs.mongodb.com/](https://docs.mongodb.com/)
|
||||
Reference in New Issue
Block a user