feat: add global styles and transitions for dark mode support

- Created style.css for base styles and color variables for light and dark themes.
- Added tailwind.css for Tailwind CSS integration and custom theme variables.
- Introduced transitions.css to manage global transitions and reduce motion preferences.
- Updated tsconfig.app.json to include path aliases for easier imports.
- Modified tsconfig.json to set baseUrl and paths for module resolution.
- Added typed-router.d.ts for auto-generated route types.
- Enhanced vite.config.ts with Vue Router plugin, Tailwind CSS integration, and server proxy settings.
This commit is contained in:
2025-05-23 21:35:43 +02:00
parent 97908db3e0
commit 26e3b76b11
102 changed files with 14233 additions and 668 deletions
+6
View File
@@ -4,6 +4,7 @@
"": {
"name": "backend",
"dependencies": {
"@koa/cors": "^5.0.0",
"bcrypt": "^6.0.0",
"jsonwebtoken": "^9.0.2",
"koa": "^3.0.0",
@@ -19,6 +20,7 @@
"@types/koa": "^2.15.0",
"@types/koa-bodyparser": "^4.3.12",
"@types/koa-router": "^7.4.8",
"@types/koa__cors": "^5.0.0",
},
"peerDependencies": {
"typescript": "^5",
@@ -28,6 +30,8 @@
"packages": {
"@hapi/bourne": ["@hapi/bourne@3.0.0", "", {}, "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w=="],
"@koa/cors": ["@koa/cors@5.0.0", "", { "dependencies": { "vary": "^1.1.2" } }, "sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw=="],
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.2.2", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-EB0O3SCSNRUFk66iRCpI+cXzIjdswfCs7F6nOC3RAGJ7xr5YhaicvsRwJ9eyzYvYRlCSDUO/c7g4yNulxKC1WA=="],
"@types/accepts": ["@types/accepts@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ=="],
@@ -64,6 +68,8 @@
"@types/koa-router": ["@types/koa-router@7.4.8", "", { "dependencies": { "@types/koa": "*" } }, "sha512-SkWlv4F9f+l3WqYNQHnWjYnyTxYthqt8W9az2RTdQW7Ay8bc00iRZcrb8MC75iEfPqnGcg2csEl8tTG1NQPD4A=="],
"@types/koa__cors": ["@types/koa__cors@5.0.0", "", { "dependencies": { "@types/koa": "*" } }, "sha512-LCk/n25Obq5qlernGOK/2LUwa/2YJb2lxHUkkvYFDOpLXlVI6tKcdfCHRBQnOY4LwH6el5WOLs6PD/a8Uzau6g=="],
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
+1 -1
View File
@@ -1,2 +1,2 @@
// Import and run the Koa.js application
import './src/app';
import "./src/app";
+4 -2
View File
@@ -1,5 +1,5 @@
{
"name": "backend",
"name": "budget-manager-pwa-backend",
"module": "index.ts",
"type": "module",
"private": true,
@@ -9,12 +9,14 @@
"@types/jsonwebtoken": "^9.0.9",
"@types/koa": "^2.15.0",
"@types/koa-bodyparser": "^4.3.12",
"@types/koa-router": "^7.4.8"
"@types/koa-router": "^7.4.8",
"@types/koa__cors": "^5.0.0"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@koa/cors": "^5.0.0",
"bcrypt": "^6.0.0",
"jsonwebtoken": "^9.0.2",
"koa": "^3.0.0",
+40 -29
View File
@@ -1,8 +1,9 @@
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import router from './routes';
import { authenticate, handleJwtError } from './middleware/auth';
import DatabaseService from './services/database';
import Koa from "koa";
import bodyParser from "koa-bodyparser";
import router from "./routes";
import { authenticate, handleJwtError } from "./middleware/auth";
import DatabaseService from "./services/database";
import cors from "@koa/cors";
const app = new Koa();
const PORT = process.env.PORT || 3000;
@@ -10,15 +11,25 @@ const db = DatabaseService.getInstance();
// Connect to database
db.connect()
.then(() => {
console.log('Database connection established');
})
.catch(err => {
console.error('Database connection failed:', err);
process.exit(1);
});
.then(() => {
console.log("Database connection established");
})
.catch((err) => {
console.error("Database connection failed:", err);
process.exit(1);
});
// Middlewares
app.use(
cors({
origin: "*",
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization", "Accept"],
credentials: true,
exposeHeaders: ["WWW-Authenticate", "Server-Authorization"],
maxAge: 86400, // 1 day
}),
);
app.use(bodyParser());
app.use(handleJwtError);
app.use(authenticate);
@@ -26,30 +37,30 @@ app.use(router.routes());
app.use(router.allowedMethods());
// Error handling
app.on('error', (err, ctx) => {
console.error('Server error:', err);
ctx.status = err.status || 500;
ctx.body = {
success: false,
message: err.message || 'Internal server error'
};
app.on("error", (err, ctx) => {
console.error("Server error:", err);
ctx.status = err.status || 500;
ctx.body = {
success: false,
message: err.message || "Internal server error",
};
});
// Start server
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`Server running on http://localhost:${PORT}`);
});
// Handle graceful shutdown
process.on('SIGINT', async () => {
console.log('Shutting down server...');
try {
await db.disconnect();
process.exit(0);
} catch (error) {
console.error('Error during shutdown:', error);
process.exit(1);
}
process.on("SIGINT", async () => {
console.log("Shutting down server...");
try {
await db.disconnect();
process.exit(0);
} catch (error) {
console.error("Error during shutdown:", error);
process.exit(1);
}
});
export default app;
+3 -2
View File
@@ -1,3 +1,4 @@
export default {
adminKey: process.env.ADMIN_KEY || 'admin-secret-key-change-this-in-production'
}
adminKey:
process.env.ADMIN_KEY || "admin-secret-key-change-this-in-production",
};
+7 -6
View File
@@ -1,9 +1,10 @@
export default {
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017',
dbName: process.env.MONGODB_DB_NAME || 'budget-manager',
uri: process.env.MONGODB_URI || "mongodb://localhost:27017",
dbName: process.env.MONGODB_DB_NAME || "budget-manager",
collections: {
users: 'users',
transactions: 'transactions',
categories: 'categories'
}
users: "users",
transactions: "transactions",
categories: "categories",
pushSubscriptions: "push_subscriptions",
},
};
+2 -2
View File
@@ -1,4 +1,4 @@
export default {
secret: process.env.JWT_SECRET || 'your-secret-key-change-this-in-production',
expiresIn: '1d', // Token expires in 1 day
secret: process.env.JWT_SECRET || "your-secret-key-change-this-in-production",
expiresIn: "1d", // Token expires in 1 day
};
+208 -190
View File
@@ -1,197 +1,215 @@
import bcrypt from 'bcrypt';
import jwt, { type SignOptions } from 'jsonwebtoken';
import type { Context } from 'koa';
import appConfig from '../config/app';
import dbConfig from '../config/database';
import jwtConfig from '../config/jwt';
import type { User } from '../models';
import DatabaseService from '../services/database';
import bcrypt from "bcrypt";
import jwt, { type SignOptions } from "jsonwebtoken";
import type { Context } from "koa";
import appConfig from "../config/app";
import dbConfig from "../config/database";
import jwtConfig from "../config/jwt";
import type { User } from "../models";
import DatabaseService from "../services/database";
const db = DatabaseService.getInstance();
export default {
// User registration
async register(ctx: Context) {
const { username, password, email } = ctx.request.body as {
username: string,
password: string,
email?: string
};
// User registration
async register(ctx: Context) {
const { username, password, email } = ctx.request.body as {
username: string;
password: string;
email?: string;
};
// Validate input
if (!username || !password) {
ctx.status = 400;
ctx.body = { success: false, message: 'Username and password are required' };
return;
}
try {
const usersCollection = db.getCollection<User>(dbConfig.collections.users);
// Check if user already exists
const existingUser = await usersCollection.findOne({ username });
if (existingUser) {
ctx.status = 409;
ctx.body = { success: false, message: 'Username already exists' };
return;
}
// Hash password before saving
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// Create new user
const now = new Date();
const newUser: User = {
username,
password: hashedPassword,
email,
createdAt: now,
updatedAt: now
};
// Save user to database
const result = await usersCollection.insertOne(newUser);
ctx.status = 201;
ctx.body = {
success: true,
message: 'User registered successfully',
userId: result.insertedId
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: 'Error registering user',
error: (error as Error).message
};
}
},
// Admin registration
async registerAdmin(ctx: Context) {
const { username, password, email, adminKey } = ctx.request.body as {
username: string,
password: string,
email?: string,
adminKey: string
};
// Validate input
if (!username || !password) {
ctx.status = 400;
ctx.body = { success: false, message: 'Username and password are required' };
return;
}
// Validate admin key
const validAdminKey = appConfig.adminKey;
if (adminKey !== validAdminKey) {
ctx.status = 403;
ctx.body = { success: false, message: 'Invalid admin key' };
return;
}
try {
const usersCollection = db.getCollection<User>(dbConfig.collections.users);
// Check if user already exists
const existingUser = await usersCollection.findOne({ username });
if (existingUser) {
ctx.status = 409;
ctx.body = { success: false, message: 'Username already exists' };
return;
}
// Hash password before saving
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// Create new admin user
const now = new Date();
const newUser: User = {
username,
password: hashedPassword,
email,
role: 'admin', // Add admin role
createdAt: now,
updatedAt: now
};
// Save user to database
const result = await usersCollection.insertOne(newUser);
ctx.status = 201;
ctx.body = {
success: true,
message: 'Admin registered successfully',
userId: result.insertedId
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: 'Error registering admin',
error: (error as Error).message
};
}
},
// User login
async login(ctx: Context) {
const { username, password } = ctx.request.body as { username: string, password: string };
// Validate input
if (!username || !password) {
ctx.status = 400;
ctx.body = { success: false, message: 'Username and password are required' };
return;
}
try {
const usersCollection = db.getCollection<User>(dbConfig.collections.users);
// Check if user exists
const user = await usersCollection.findOne({ username });
if (!user) {
ctx.status = 401;
ctx.body = { success: false, message: 'Invalid credentials' };
return;
}
// Verify password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
ctx.status = 401;
ctx.body = { success: false, message: 'Invalid credentials' };
return;
}
// Generate JWT token
const token = jwt.sign(
{
id: user._id?.toString(),
username: user.username
},
jwtConfig.secret,
{ expiresIn: jwtConfig.expiresIn } as SignOptions
);
ctx.body = {
success: true,
message: 'Login successful',
userId: user._id,
username: user.username,
token
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: 'Error during login',
error: (error as Error).message
};
}
// Validate input
if (!username || !password) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Username and password are required",
};
return;
}
try {
const usersCollection = db.getCollection<User>(
dbConfig.collections.users,
);
// Check if user already exists
const existingUser = await usersCollection.findOne({ username });
if (existingUser) {
ctx.status = 409;
ctx.body = { success: false, message: "Username already exists" };
return;
}
// Hash password before saving
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// Create new user
const now = new Date();
const newUser: User = {
username,
password: hashedPassword,
email,
createdAt: now,
updatedAt: now,
};
// Save user to database
const result = await usersCollection.insertOne(newUser);
ctx.status = 201;
ctx.body = {
success: true,
message: "User registered successfully",
userId: result.insertedId,
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: "Error registering user",
error: (error as Error).message,
};
}
},
// Admin registration
async registerAdmin(ctx: Context) {
const { username, password, email, adminKey } = ctx.request.body as {
username: string;
password: string;
email?: string;
adminKey: string;
};
// Validate input
if (!username || !password) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Username and password are required",
};
return;
}
// Validate admin key
const validAdminKey = appConfig.adminKey;
if (adminKey !== validAdminKey) {
ctx.status = 403;
ctx.body = { success: false, message: "Invalid admin key" };
return;
}
try {
const usersCollection = db.getCollection<User>(
dbConfig.collections.users,
);
// Check if user already exists
const existingUser = await usersCollection.findOne({ username });
if (existingUser) {
ctx.status = 409;
ctx.body = { success: false, message: "Username already exists" };
return;
}
// Hash password before saving
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// Create new admin user
const now = new Date();
const newUser: User = {
username,
password: hashedPassword,
email,
role: "admin", // Add admin role
createdAt: now,
updatedAt: now,
};
// Save user to database
const result = await usersCollection.insertOne(newUser);
ctx.status = 201;
ctx.body = {
success: true,
message: "Admin registered successfully",
userId: result.insertedId,
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: "Error registering admin",
error: (error as Error).message,
};
}
},
// User login
async login(ctx: Context) {
const { username, password } = ctx.request.body as {
username: string;
password: string;
};
// Validate input
if (!username || !password) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Username and password are required",
};
return;
}
try {
const usersCollection = db.getCollection<User>(
dbConfig.collections.users,
);
// Check if user exists
const user = await usersCollection.findOne({ username });
if (!user) {
ctx.status = 401;
ctx.body = { success: false, message: "Invalid credentials" };
return;
}
// Verify password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
ctx.status = 401;
ctx.body = { success: false, message: "Invalid credentials" };
return;
}
// Generate JWT token
const token = jwt.sign(
{
id: user._id?.toString(),
username: user.username,
},
jwtConfig.secret,
{ expiresIn: jwtConfig.expiresIn } as SignOptions,
);
ctx.body = {
success: true,
message: "Login successful",
userId: user._id,
username: user.username,
token,
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: "Error during login",
error: (error as Error).message,
};
}
},
};
+405 -157
View File
@@ -1,166 +1,414 @@
import type { Context } from 'koa';
import { ObjectId } from 'mongodb';
import dbConfig from '../config/database';
import type { Category } from '../models';
import DatabaseService from '../services/database';
import type { Context } from "koa";
import { ObjectId } from "mongodb";
import dbConfig from "../config/database";
import type { Category } from "../models";
import DatabaseService from "../services/database";
const db = DatabaseService.getInstance();
// Middleware to check if user is admin
function requireAdmin(ctx: Context, next: Function) {
const user = (ctx.state as any).user;
if (user.role !== "admin") {
ctx.status = 403;
ctx.body = {
success: false,
message: "Admin access required",
};
return;
}
return next();
}
export default {
// Get categories for a user
async getUserCategories(ctx: Context) {
const user = (ctx.state as any).user;
const userId = user.id;
// Get categories for a user
async getUserCategories(ctx: Context) {
const user = (ctx.state as any).user;
const userId = user.id;
try {
const categoriesCollection = db.getCollection<Category>(dbConfig.collections.categories);
try {
const categoriesCollection = db.getCollection<Category>(
dbConfig.collections.categories,
);
const categories = await categoriesCollection.find({
userId: new ObjectId(userId)
}).toArray();
const categories = await categoriesCollection
.find({
userId: new ObjectId(String(userId)),
})
.toArray();
ctx.body = {
success: true,
data: categories
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: 'Error retrieving categories',
error: (error as Error).message
};
}
},
// Add new category
async addCategory(ctx: Context) {
const user = (ctx.state as any).user;
const userId = user.id;
const { name, icon, color } = ctx.request.body as {
name: string;
icon?: string;
color?: string;
};
if (!name) {
ctx.status = 400;
ctx.body = {
success: false,
message: 'Category name is required'
};
return;
}
try {
const categoriesCollection = db.getCollection<Category>(dbConfig.collections.categories);
// Check if category already exists
const existingCategory = await categoriesCollection.findOne({
userId: new ObjectId(userId),
name
});
if (existingCategory) {
ctx.status = 409;
ctx.body = {
success: false,
message: 'Category already exists'
};
return;
}
const now = new Date();
const newCategory: Category = {
userId: new ObjectId(userId),
name,
icon,
color,
createdAt: now,
updatedAt: now
};
const result = await categoriesCollection.insertOne(newCategory);
ctx.status = 201;
ctx.body = {
success: true,
message: 'Category added successfully',
categoryId: result.insertedId
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: 'Error adding category',
error: (error as Error).message
};
}
},
// Update category
async updateCategory(ctx: Context) {
const user = (ctx.state as any).user;
const userId = user.id;
const categoryId = ctx.params.id;
const { name, icon, color } = ctx.request.body as {
name?: string;
icon?: string;
color?: string;
};
if (!categoryId || !ObjectId.isValid(categoryId)) {
ctx.status = 400;
ctx.body = {
success: false,
message: 'Valid category ID is required'
};
return;
}
try {
const categoriesCollection = db.getCollection<Category>(dbConfig.collections.categories);
// Make sure category belongs to user
const category = await categoriesCollection.findOne({
_id: new ObjectId(categoryId),
userId: new ObjectId(userId)
});
if (!category) {
ctx.status = 404;
ctx.body = {
success: false,
message: 'Category not found'
};
return;
}
const updateData: Partial<Category> = {
updatedAt: new Date()
};
if (name) updateData.name = name;
if (icon) updateData.icon = icon;
if (color) updateData.color = color;
await categoriesCollection.updateOne(
{ _id: new ObjectId(categoryId) },
{ $set: updateData }
);
ctx.body = {
success: true,
message: 'Category updated successfully'
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: 'Error updating category',
error: (error as Error).message
};
}
ctx.body = {
success: true,
data: categories,
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: "Error retrieving categories",
error: (error as Error).message,
};
}
},
// Add new category
async addCategory(ctx: Context) {
const user = (ctx.state as any).user;
const userId = user.id;
const { name, icon, color } = ctx.request.body as {
name: string;
icon?: string;
color?: string;
};
if (!name) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Category name is required",
};
return;
}
try {
const categoriesCollection = db.getCollection<Category>(
dbConfig.collections.categories,
);
// Check if category already exists
const existingCategory = await categoriesCollection.findOne({
userId: new ObjectId(userId),
name,
});
if (existingCategory) {
ctx.status = 409;
ctx.body = {
success: false,
message: "Category already exists",
};
return;
}
const now = new Date();
const newCategory: Category = {
userId: new ObjectId(userId),
name,
icon,
color,
createdAt: now,
updatedAt: now,
};
const result = await categoriesCollection.insertOne(newCategory);
ctx.status = 201;
ctx.body = {
success: true,
message: "Category added successfully",
categoryId: result.insertedId,
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: "Error adding category",
error: (error as Error).message,
};
}
},
// Update category
async updateCategory(ctx: Context) {
const user = (ctx.state as any).user;
const userId = user.id;
const categoryId = ctx.params.id;
const { name, icon, color } = ctx.request.body as {
name?: string;
icon?: string;
color?: string;
};
if (!categoryId || !ObjectId.isValid(categoryId)) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Valid category ID is required",
};
return;
}
try {
const categoriesCollection = db.getCollection<Category>(
dbConfig.collections.categories,
);
// Make sure category belongs to user
const category = await categoriesCollection.findOne({
_id: new ObjectId(categoryId),
userId: new ObjectId(userId),
});
if (!category) {
ctx.status = 404;
ctx.body = {
success: false,
message: "Category not found",
};
return;
}
const updateData: Partial<Category> = {
updatedAt: new Date(),
};
if (name) updateData.name = name;
if (icon) updateData.icon = icon;
if (color) updateData.color = color;
await categoriesCollection.updateOne(
{ _id: new ObjectId(categoryId) },
{ $set: updateData },
);
ctx.body = {
success: true,
message: "Category updated successfully",
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: "Error updating category",
error: (error as Error).message,
};
}
},
// Delete category (Admin only)
async deleteCategory(ctx: Context) {
const user = (ctx.state as any).user;
const categoryId = ctx.params.id;
if (!categoryId || !ObjectId.isValid(categoryId)) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Valid category ID is required",
};
return;
}
try {
const categoriesCollection = db.getCollection<Category>(
dbConfig.collections.categories,
);
// Check if category exists
const category = await categoriesCollection.findOne({
_id: new ObjectId(categoryId),
});
if (!category) {
ctx.status = 404;
ctx.body = {
success: false,
message: "Category not found",
};
return;
}
// Check if category is being used in transactions
const transactionsCollection = db.getCollection(
dbConfig.collections.transactions,
);
const transactionCount = await transactionsCollection.countDocuments({
categoryId: new ObjectId(categoryId),
});
if (transactionCount > 0) {
ctx.status = 409;
ctx.body = {
success: false,
message: `Cannot delete category. It is used in ${transactionCount} transaction(s).`,
};
return;
}
await categoriesCollection.deleteOne({
_id: new ObjectId(categoryId),
});
ctx.body = {
success: true,
message: "Category deleted successfully",
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: "Error deleting category",
error: (error as Error).message,
};
}
},
// Get all categories (Admin only)
async getAllCategories(ctx: Context) {
try {
const categoriesCollection = db.getCollection<Category>(
dbConfig.collections.categories,
);
const categories = await categoriesCollection
.find({})
.sort({ createdAt: -1 })
.toArray();
ctx.body = {
success: true,
data: categories,
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: "Error retrieving categories",
error: (error as Error).message,
};
}
},
// Create global category (Admin only)
async createGlobalCategory(ctx: Context) {
const { name, icon, color } = ctx.request.body as {
name: string;
icon?: string;
color?: string;
};
if (!name) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Category name is required",
};
return;
}
try {
const categoriesCollection = db.getCollection<Category>(
dbConfig.collections.categories,
);
// Check if global category already exists
const existingCategory = await categoriesCollection.findOne({
name,
userId: null, // Global categories have no userId
});
if (existingCategory) {
ctx.status = 409;
ctx.body = {
success: false,
message: "Global category already exists",
};
return;
}
const now = new Date();
const newCategory: any = {
userId: null, // Global category
name,
icon,
color,
createdAt: now,
updatedAt: now,
};
const result = await categoriesCollection.insertOne(newCategory);
ctx.status = 201;
ctx.body = {
success: true,
message: "Global category created successfully",
categoryId: result.insertedId,
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: "Error creating global category",
error: (error as Error).message,
};
}
},
// Update any category (Admin only)
async updateAnyCategory(ctx: Context) {
const categoryId = ctx.params.id;
const { name, icon, color } = ctx.request.body as {
name?: string;
icon?: string;
color?: string;
};
if (!categoryId || !ObjectId.isValid(categoryId)) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Valid category ID is required",
};
return;
}
try {
const categoriesCollection = db.getCollection<Category>(
dbConfig.collections.categories,
);
const category = await categoriesCollection.findOne({
_id: new ObjectId(categoryId),
});
if (!category) {
ctx.status = 404;
ctx.body = {
success: false,
message: "Category not found",
};
return;
}
const updateData: Partial<Category> = {
updatedAt: new Date(),
};
if (name) updateData.name = name;
if (icon) updateData.icon = icon;
if (color) updateData.color = color;
await categoriesCollection.updateOne(
{ _id: new ObjectId(categoryId) },
{ $set: updateData },
);
ctx.body = {
success: true,
message: "Category updated successfully",
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: "Error updating category",
error: (error as Error).message,
};
}
},
// Middleware function for admin routes
requireAdmin,
};
+43 -21
View File
@@ -31,7 +31,7 @@ export default {
try {
const transactionsCollection = db.getCollection<Transaction>(
dbConfig.collections.transactions
dbConfig.collections.transactions,
);
// Get the latest 10 transactions for the user
@@ -61,40 +61,61 @@ export default {
async addTransaction(ctx: Context) {
const user = (ctx.state as any).user;
const userId = user.id;
const { amount, type, categoryId, description } = ctx.request.body as {
amount: number;
type: "income" | "expense";
categoryId: string;
description: string;
};
// Validate input
if (!amount || !type || !categoryId) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Amount, type and category are required",
};
return;
}
try {
const { amount, type, categoryId, description, date } = ctx.request
.body as {
amount: number;
type: "income" | "expense";
categoryId: string;
description: string;
date?: string;
};
console.log("Received transaction data:", ctx.request.body);
// Validate input
if (!amount || !type || !categoryId) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Amount, type and category are required",
};
return;
}
const transactionsCollection = db.getCollection<Transaction>(
dbConfig.collections.transactions
dbConfig.collections.transactions,
);
const now = new Date();
const transactionDate = date ? new Date(date) : now;
// Check if categoryId is a valid ObjectId
let categoryObjectId;
try {
categoryObjectId = new ObjectId(categoryId);
} catch (err) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Invalid category ID format",
};
return;
}
const newTransaction: Transaction = {
userId: new ObjectId(userId),
amount,
amount: Number(amount),
type,
categoryId: new ObjectId(categoryId),
categoryId: categoryObjectId,
description: description || "",
date: now,
date: transactionDate,
createdAt: now,
updatedAt: now,
};
console.log("Inserting transaction:", newTransaction);
const result = await transactionsCollection.insertOne(newTransaction);
ctx.status = 201;
@@ -104,6 +125,7 @@ export default {
transactionId: result.insertedId,
};
} catch (error) {
console.error("Transaction error:", error);
ctx.status = 500;
ctx.body = {
success: false,
+102
View File
@@ -0,0 +1,102 @@
import type { Context } from "koa";
import { ObjectId } from "mongodb";
import dbConfig from "../config/database";
import DatabaseService from "../services/database";
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'
};
webpush.setVapidDetails(
'mailto:support@budgetmanager.com',
vapidKeys.publicKey,
vapidKeys.privateKey
);
const db = DatabaseService.getInstance();
export default {
// Get VAPID public key
async getVapidPublicKey(ctx: Context) {
ctx.body = vapidKeys.publicKey;
},
// Register a new push subscription
async registerPushSubscription(ctx: Context) {
const user = (ctx.state as any).user;
const userId = user.id;
const { subscription } = ctx.request.body;
if (!subscription) {
ctx.status = 400;
ctx.body = {
success: false,
message: "Subscription data is required"
};
return;
}
try {
const subscriptionsCollection = db.getCollection(
dbConfig.collections.pushSubscriptions
);
// Store subscription with user ID
await subscriptionsCollection.updateOne(
{ userId: new ObjectId(String(userId)) },
{ $set: { subscription, updatedAt: new Date() } },
{ upsert: true }
);
ctx.body = {
success: true,
message: "Push subscription registered successfully"
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: "Error registering push subscription",
error: (error as Error).message
};
}
},
// Send notification for a new transaction
async sendTransactionNotification(userId: string, transaction: any) {
try {
const subscriptionsCollection = db.getCollection(
dbConfig.collections.pushSubscriptions
);
// Find user's subscription
const record = await subscriptionsCollection.findOne({
userId: new ObjectId(String(userId))
});
if (!record || !record.subscription) {
return { success: false, message: "No subscription found" };
}
// Prepare notification payload
const payload = JSON.stringify({
message: `New ${transaction.type}: ${transaction.description || 'Transaction'} - ${transaction.amount}`,
additionalData: {
transactionId: transaction._id.toString(),
type: transaction.type
}
});
// Send push notification
await webpush.sendNotification(record.subscription, payload);
return { success: true };
} catch (error) {
console.error("Error sending push notification:", error);
return { success: false, error: (error as Error).message };
}
}
};
+19 -23
View File
@@ -1,33 +1,29 @@
import type { Middleware } from 'koa';
import jwt from 'koa-jwt';
import jwtConfig from '../config/jwt';
import type { Middleware } from "koa";
import jwt from "koa-jwt";
import jwtConfig from "../config/jwt";
// JWT authentication middleware
export const authenticate: Middleware = jwt({
secret: jwtConfig.secret
secret: jwtConfig.secret,
}).unless({
// Paths that don't require authentication
path: [
'/api/auth/login',
'/api/auth/register',
'/api/auth/register-admin'
]
// Paths that don't require authentication
path: ["/api/auth/login", "/api/auth/register", "/api/auth/register-admin"],
});
// Error handling middleware for JWT authentication
export const handleJwtError: Middleware = async (ctx, next) => {
try {
// Next middleware have to be jwt call.
await next();
} catch (err: any) {
if (err.status === 401) {
ctx.status = 401;
ctx.body = {
success: false,
message: 'Unauthorized - Invalid or expired token'
};
} else {
throw err;
}
try {
// Next middleware have to be jwt call.
await next();
} catch (err: any) {
if (err.status === 401) {
ctx.status = 401;
ctx.body = {
success: false,
message: "Unauthorized - Invalid or expired token",
};
} else {
throw err;
}
}
};
+3 -3
View File
@@ -1,11 +1,11 @@
import { ObjectId } from 'mongodb';
import { ObjectId } from "mongodb";
export interface User {
_id?: ObjectId;
username: string;
password: string;
email?: string;
role?: 'user' | 'admin';
role?: "user" | "admin";
createdAt: Date;
updatedAt: Date;
}
@@ -14,7 +14,7 @@ export interface Transaction {
_id?: ObjectId;
userId: ObjectId;
amount: number;
type: 'income' | 'expense';
type: "income" | "expense";
categoryId: ObjectId;
description: string;
date: Date;
+37 -14
View File
@@ -1,9 +1,10 @@
import Router from 'koa-router';
import authController from '../controllers/auth';
import protectedController from '../controllers/protected';
import categoriesController from '../controllers/categories';
import Router from "koa-router";
import authController from "../controllers/auth";
import protectedController from "../controllers/protected";
import categoriesController from "../controllers/categories";
import pushController from "../controllers/push";
const router = new Router({ prefix: '/api' });
const router = new Router({ prefix: "/api" });
/*
Middleware requires authentication for all routes.
@@ -13,21 +14,43 @@ const router = new Router({ prefix: '/api' });
// --- Public routes ---
// Auth routes
router.post('/auth/register', authController.register);
router.post('/auth/register-admin', authController.registerAdmin);
router.post('/auth/login', authController.login);
router.post("/auth/register", authController.register);
router.post("/auth/register-admin", authController.registerAdmin);
router.post("/auth/login", authController.login);
// --- All below routes require authentication ----
// Protected routes
router.get('/protected', protectedController.getProtectedData);
router.get("/protected", protectedController.getProtectedData);
// Transaction routes
router.get('/transactions', protectedController.getUserTransactions);
router.post('/transactions', protectedController.addTransaction);
router.get("/transactions", protectedController.getUserTransactions);
router.post("/transactions", protectedController.addTransaction);
// Category routes
router.get('/categories', categoriesController.getUserCategories);
router.post('/categories', categoriesController.addCategory);
router.put('/categories/:id', categoriesController.updateCategory);
router.get("/categories", categoriesController.getUserCategories);
router.post("/categories", categoriesController.addCategory);
router.put("/categories/:id", categoriesController.updateCategory);
// Admin-only category routes
router.get(
"/admin/categories",
categoriesController.requireAdmin,
categoriesController.getAllCategories,
);
router.post(
"/admin/categories",
categoriesController.requireAdmin,
categoriesController.createGlobalCategory,
);
router.put(
"/admin/categories/:id",
categoriesController.requireAdmin,
categoriesController.updateAnyCategory,
);
router.delete(
"/admin/categories/:id",
categoriesController.requireAdmin,
categoriesController.deleteCategory,
);
export default router;
+34 -32
View File
@@ -1,46 +1,48 @@
import { Collection, Db, MongoClient, type Document } from 'mongodb';
import dbConfig from '../config/database';
import { Collection, Db, MongoClient, type Document } from "mongodb";
import dbConfig from "../config/database";
class DatabaseService {
private client: MongoClient;
private db: Db | null = null;
private static instance: DatabaseService;
private client: MongoClient;
private db: Db | null = null;
private static instance: DatabaseService;
private constructor() {
this.client = new MongoClient(dbConfig.uri);
}
private constructor() {
this.client = new MongoClient(dbConfig.uri);
}
static getInstance(): DatabaseService {
if (!DatabaseService.instance) {
DatabaseService.instance = new DatabaseService();
}
return DatabaseService.instance;
static getInstance(): DatabaseService {
if (!DatabaseService.instance) {
DatabaseService.instance = new DatabaseService();
}
return DatabaseService.instance;
}
async connect(): Promise<void> {
try {
await this.client.connect();
this.db = this.client.db(dbConfig.dbName);
console.log('Connected to MongoDB successfully');
} catch (error) {
console.error('Failed to connect to MongoDB', error);
throw error;
}
async connect(): Promise<void> {
try {
await this.client.connect();
this.db = this.client.db(dbConfig.dbName);
console.log("Connected to MongoDB successfully");
} catch (error) {
console.error("Failed to connect to MongoDB", error);
throw error;
}
}
getCollection<T extends Document>(collectionName: string): Collection<T> {
if (!this.db) {
throw new Error('Database connection not established. Call connect() first.');
}
return this.db.collection<T>(collectionName);
getCollection<T extends Document>(collectionName: string): Collection<T> {
if (!this.db) {
throw new Error(
"Database connection not established. Call connect() first.",
);
}
return this.db.collection<T>(collectionName);
}
async disconnect(): Promise<void> {
if (this.client) {
await this.client.close();
console.log('Disconnected from MongoDB');
}
async disconnect(): Promise<void> {
if (this.client) {
await this.client.close();
console.log("Disconnected from MongoDB");
}
}
}
export default DatabaseService;
+1 -1
View File
@@ -6,4 +6,4 @@ services:
- 27017:27017
# environment:
# MONGO_INITDB_ROOT_USERNAME: root
# MONGO_INITDB_ROOT_PASSWORD: example
# MONGO_INITDB_ROOT_PASSWORD: example
+7
View File
@@ -0,0 +1,7 @@
{
"tabWidth": 2,
"useTabs": false,
"printWidth": 80,
"vueIndentScriptAndStyle": true,
"bracketSameLine": true
}
-3
View File
@@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar"]
}
+8 -3
View File
@@ -1,5 +1,10 @@
# Vue 3 + TypeScript + Vite
# Frontend
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Main package managers
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
1. [bun](https://bun.sh/) - A fast all-in-one JavaScript runtime, package manager, and bundler.
2. [pnpm](https://pnpm.io/) - A fast, disk space efficient package manager.
## Main libraries
1. [shadcn-vue](https://www.shadcn-vue.com/) - A UI component library based on Vue 3 and Tailwind CSS.
+459 -19
View File
@@ -2,24 +2,46 @@
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "frontend",
"name": "frontend-vue",
"dependencies": {
"vue": "^3.5.13",
"@tailwindcss/cli": "^4.1.7",
"@tailwindcss/vite": "^4.1.7",
"@vee-validate/zod": "^4.15.0",
"@vueuse/core": "^13.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.511.0",
"pinia": "^3.0.2",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"reka-ui": "^2.2.1",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.7",
"tw-animate-css": "^1.3.0",
"vee-validate": "^4.15.0",
"vue": "^3.5.14",
"vue-router": "^4.5.1",
"zod": "^3.25.7",
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"@types/node": "^22.15.19",
"@types/vue-router": "^2.0.0",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/tsconfig": "^0.7.0",
"prettier": "^3.5.3",
"typescript": "~5.8.3",
"unplugin-vue-router": "^0.12.0",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0",
"vue-tsc": "^2.2.8",
"vite-plugin-vue-devtools": "^7.7.6",
"vue-tsc": "^2.2.10",
},
},
},
"packages": {
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@antfu/utils": ["@antfu/utils@0.7.10", "", {}, "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww=="],
"@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.6", "", { "dependencies": { "json-schema": "^0.4.0", "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
@@ -78,12 +100,22 @@
"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": ["@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw=="],
"@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-decorators": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DTxe4LBPrtFdsWzgpmbBKevg3e9PBy+dXRt19kSbucbZvL2uqtdqwwpluL1jfxYE0wIDTFp1nTy/q6gNLsxXrg=="],
"@babel/plugin-proposal-private-property-in-object": ["@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w=="],
"@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A=="],
"@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg=="],
"@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww=="],
"@babel/plugin-syntax-import-meta": ["@babel/plugin-syntax-import-meta@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w=="],
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ=="],
"@babel/plugin-syntax-unicode-sets-regex": ["@babel/plugin-syntax-unicode-sets-regex@7.18.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg=="],
"@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA=="],
@@ -178,6 +210,8 @@
"@babel/plugin-transform-typeof-symbol": ["@babel/plugin-transform-typeof-symbol@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw=="],
"@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg=="],
"@babel/plugin-transform-unicode-escapes": ["@babel/plugin-transform-unicode-escapes@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg=="],
"@babel/plugin-transform-unicode-property-regex": ["@babel/plugin-transform-unicode-property-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q=="],
@@ -248,6 +282,20 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
"@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
"@floating-ui/vue": ["@floating-ui/vue@1.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.0.0", "@floating-ui/utils": "^0.2.9", "vue-demi": ">=0.13.0" } }, "sha512-XFlUzGHGv12zbgHNk5FN2mUB7ROul3oG2ENdTpWdE+qMFxyNxWSRmsoyhiEnpmabNm6WnUvR1OvJfUfN4ojC1A=="],
"@internationalized/date": ["@internationalized/date@3.8.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PgVE6B6eIZtzf9Gu5HvJxRK3ufUFz9DhspELuhW/N0GuMGMTLvPQNRkHP2hTuP9lblOk+f+1xi96sPiPXANXAA=="],
"@internationalized/number": ["@internationalized/number@3.6.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-E5QTOlMg9wo5OrKdHD6edo1JJlIoOsylh0+mbf0evi1tHJwMZfJSaBpGtnJV9N7w3jeiioox9EG/EWRWPh82vg=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
@@ -260,6 +308,42 @@
"@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=="],
"@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=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="],
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="],
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="],
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="],
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="],
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="],
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="],
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="],
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="],
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="],
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="],
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/plugin-babel": ["@rollup/plugin-babel@5.3.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.10.4", "@rollup/pluginutils": "^3.1.0" }, "peerDependencies": { "@babel/core": "^7.0.0", "@types/babel__core": "^7.1.9", "rollup": "^1.20.0||^2.0.0" }, "optionalPeers": ["@types/babel__core"] }, "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q=="],
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@15.3.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA=="],
@@ -268,7 +352,7 @@
"@rollup/plugin-terser": ["@rollup/plugin-terser@0.4.4", "", { "dependencies": { "serialize-javascript": "^6.0.1", "smob": "^1.0.0", "terser": "^5.17.4" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A=="],
"@rollup/pluginutils": ["@rollup/pluginutils@3.1.0", "", { "dependencies": { "@types/estree": "0.0.39", "estree-walker": "^1.0.1", "picomatch": "^2.2.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0" } }, "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.1.4", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A=="],
@@ -310,14 +394,64 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.41.0", "", { "os": "win32", "cpu": "x64" }, "sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@surma/rollup-plugin-off-main-thread": ["@surma/rollup-plugin-off-main-thread@2.2.3", "", { "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", "magic-string": "^0.25.0", "string.prototype.matchall": "^4.0.6" } }, "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ=="],
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
"@tailwindcss/cli": ["@tailwindcss/cli@4.1.7", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.1.7", "@tailwindcss/oxide": "4.1.7", "enhanced-resolve": "^5.18.1", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.1.7" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-hJNjpov/UiJc9ZWH4j/eEQxqklADrD/71s+t8Y0wbyQVAwtLkSp+MeC/sHTb03X+28rfbe0fRXkiBsf73/IwPg=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.7", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.7" } }, "sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.7", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.7", "@tailwindcss/oxide-darwin-arm64": "4.1.7", "@tailwindcss/oxide-darwin-x64": "4.1.7", "@tailwindcss/oxide-freebsd-x64": "4.1.7", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.7", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.7", "@tailwindcss/oxide-linux-arm64-musl": "4.1.7", "@tailwindcss/oxide-linux-x64-gnu": "4.1.7", "@tailwindcss/oxide-linux-x64-musl": "4.1.7", "@tailwindcss/oxide-wasm32-wasi": "4.1.7", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.7", "@tailwindcss/oxide-win32-x64-msvc": "4.1.7" } }, "sha512-5SF95Ctm9DFiUyjUPnDGkoKItPX/k+xifcQhcqX5RA85m50jw1pT/KzjdvlqxRja45Y52nR4MR9fD1JYd7f8NQ=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.7", "", { "os": "android", "cpu": "arm64" }, "sha512-IWA410JZ8fF7kACus6BrUwY2Z1t1hm0+ZWNEzykKmMNM09wQooOcN/VXr0p/WJdtHZ90PvJf2AIBS/Ceqx1emg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-81jUw9To7fimGGkuJ2W5h3/oGonTOZKZ8C2ghm/TTxbwvfSiFSDPd6/A/KE2N7Jp4mv3Ps9OFqg2fEKgZFfsvg=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-q77rWjEyGHV4PdDBtrzO0tgBBPlQWKY7wZK0cUok/HaGgbNKecegNxCGikuPJn5wFAlIywC3v+WMBt0PEBtwGw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-RfmdbbK6G6ptgF4qqbzoxmH+PKfP4KSVs7SRlTwcbRgBwezJkAO3Qta/7gDy10Q2DcUVkKxFLXUQO6J3CRvBGw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.7", "", { "os": "linux", "cpu": "arm" }, "sha512-OZqsGvpwOa13lVd1z6JVwQXadEobmesxQ4AxhrwRiPuE04quvZHWn/LnihMg7/XkN+dTioXp/VMu/p6A5eZP3g=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-voMvBTnJSfKecJxGkoeAyW/2XRToLZ227LxswLAwKY7YslG/Xkw9/tJNH+3IVh5bdYzYE7DfiaPbRkSHFxY1xA=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-PjGuNNmJeKHnP58M7XyjJyla8LPo+RmwHQpBI+W/OxqrwojyuCQ+GUtygu7jUqTEexejZHr/z3nBc/gTiXBj4A=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-HMs+Va+ZR3gC3mLZE00gXxtBo3JoSQxtu9lobbZd+DmfkIxR54NO7Z+UQNPsa0P/ITn1TevtFxXTpsRU7qEvWg=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-MHZ6jyNlutdHH8rd+YTdr3QbXrHXqwIhHw9e7yXEBcQdluGwhpQY2Eku8UZK6ReLaWtQ4gijIv5QoM5eE+qlsA=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.7", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.9", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-ANaSKt74ZRzE2TvJmUcbFQ8zS201cIPxUDm5qez5rLEwWkie2SkGtA4P+GPTj+u8N6JbPrC8MtY8RmJA35Oo+A=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-HUiSiXQ9gLJBAPCMVRk2RT1ZrBjto7WvqsPBwUrNK2BcdSxMnk19h4pjZjI7zgPhDxlAbJSumTC4ljeA9y0tEw=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-rYHGmvoHiLJ8hWucSfSOEmdCBIGZIq7SpkPRSqLsH2Ab2YUNgKeAPT1Fi2cx3+hnYOrAb0jp9cRyode3bBW4mQ=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.7", "", { "dependencies": { "@tailwindcss/node": "4.1.7", "@tailwindcss/oxide": "4.1.7", "tailwindcss": "4.1.7" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-tYa2fO3zDe41I7WqijyVbRd8oWT0aEID1Eokz5hMT6wShLIHj3yvwj9XbfuloHP9glZ6H+aG2AN/+ZrxJ1Y5RQ=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.9", "", {}, "sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ=="],
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.9", "", { "dependencies": { "@tanstack/virtual-core": "3.13.9" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-HsvHaOo+o52cVcPhomKDZ3CMpTF/B2qg+BhPHIQJwzn4VIqDyt/rRVqtIomG6jE83IFsE2vlr6cmx7h3dHA0SA=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/node": ["@types/node@22.15.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/vue-router": ["@types/vue-router@2.0.0", "", { "dependencies": { "vue-router": "*" } }, "sha512-E454lQ6tp9ftVWdZ8VGZpRcIV4YeqVAcx/uifl3P1GGwscYsxOFdYfgIuKasKO0Fm6Np2JM/L378D3bcRQE9hg=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@vee-validate/zod": ["@vee-validate/zod@4.15.0", "", { "dependencies": { "type-fest": "^4.8.3", "vee-validate": "4.15.0" }, "peerDependencies": { "zod": "^3.24.0" } }, "sha512-MpvIKiyg9X5yD8bJW0no2AU7wtR2T5mrvD9tuPRiie951sU2n6QKgMV38qKKOiqFBCxsMSjIuLLLV3V5kVE4nQ=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
"@volar/language-core": ["@volar/language-core@2.4.14", "", { "dependencies": { "@volar/source-map": "2.4.14" } }, "sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w=="],
@@ -326,6 +460,14 @@
"@volar/typescript": ["@volar/typescript@2.4.14", "", { "dependencies": { "@volar/language-core": "2.4.14", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw=="],
"@vue-macros/common": ["@vue-macros/common@1.16.1", "", { "dependencies": { "@vue/compiler-sfc": "^3.5.13", "ast-kit": "^1.4.0", "local-pkg": "^1.0.0", "magic-string-ast": "^0.7.0", "pathe": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-Pn/AWMTjoMYuquepLZP813BIcq8DTZiNCoaceuNlvaYuOTd8DqBZWc5u0uOMQZMInwME1mdSmmBAcTluiV9Jtg=="],
"@vue/babel-helper-vue-transform-on": ["@vue/babel-helper-vue-transform-on@1.4.0", "", {}, "sha512-mCokbouEQ/ocRce/FpKCRItGo+013tHg7tixg3DUNS+6bmIchPt66012kBMm476vyEIJPafrvOf4E5OYj3shSw=="],
"@vue/babel-plugin-jsx": ["@vue/babel-plugin-jsx@1.4.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-plugin-utils": "^7.26.5", "@babel/plugin-syntax-jsx": "^7.25.9", "@babel/template": "^7.26.9", "@babel/traverse": "^7.26.9", "@babel/types": "^7.26.9", "@vue/babel-helper-vue-transform-on": "1.4.0", "@vue/babel-plugin-resolve-type": "1.4.0", "@vue/shared": "^3.5.13" }, "peerDependencies": { "@babel/core": "^7.0.0-0" }, "optionalPeers": ["@babel/core"] }, "sha512-9zAHmwgMWlaN6qRKdrg1uKsBKHvnUU+Py+MOCTuYZBoZsopa90Di10QRjB+YPnVss0BZbG/H5XFwJY1fTxJWhA=="],
"@vue/babel-plugin-resolve-type": ["@vue/babel-plugin-resolve-type@1.4.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/helper-module-imports": "^7.25.9", "@babel/helper-plugin-utils": "^7.26.5", "@babel/parser": "^7.26.9", "@vue/compiler-sfc": "^3.5.13" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-4xqDRRbQQEWHQyjlYSgZsWj44KfiF6D+ktCuXyZ8EnVDYV3pztmXJDf1HveAjUAXxAnR8daCQT51RneWWxtTyQ=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.14", "", { "dependencies": { "@babel/parser": "^7.27.2", "@vue/shared": "3.5.14", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-k7qMHMbKvoCXIxPhquKQVw3Twid3Kg4s7+oYURxLGRd56LiuHJVrvFKI4fm2AM3c8apqODPfVJGoh8nePbXMRA=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.14", "", { "dependencies": { "@vue/compiler-core": "3.5.14", "@vue/shared": "3.5.14" } }, "sha512-1aOCSqxGOea5I80U2hQJvXYpPm/aXo95xL/m/mMhgyPUsKe9jhjwWpziNAw7tYRnbz1I61rd9Mld4W9KmmRoug=="],
@@ -336,6 +478,14 @@
"@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="],
"@vue/devtools-api": ["@vue/devtools-api@7.7.6", "", { "dependencies": { "@vue/devtools-kit": "^7.7.6" } }, "sha512-b2Xx0KvXZObePpXPYHvBRRJLDQn5nhKjXh7vUhMEtWxz1AYNFOVIsh5+HLP8xDGL7sy+Q7hXeUxPHB/KgbtsPw=="],
"@vue/devtools-core": ["@vue/devtools-core@7.7.6", "", { "dependencies": { "@vue/devtools-kit": "^7.7.6", "@vue/devtools-shared": "^7.7.6", "mitt": "^3.0.1", "nanoid": "^5.1.0", "pathe": "^2.0.3", "vite-hot-client": "^2.0.4" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-ghVX3zjKPtSHu94Xs03giRIeIWlb9M+gvDRVpIZ/cRIxKHdW6HE/sm1PT3rUYS3aV92CazirT93ne+7IOvGUWg=="],
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.6", "", { "dependencies": { "@vue/devtools-shared": "^7.7.6", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-geu7ds7tem2Y7Wz+WgbnbZ6T5eadOvozHZ23Atk/8tksHMFOFylKi1xgGlQlVn0wlkEf4hu+vd5ctj1G4kFtwA=="],
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-yFEgJZ/WblEsojQQceuyK6FzpFDx4kqrz2ohInxNj5/DnhoX023upTv4OD6lNPLAA5LLkbwPVb10o/7b+Y4FVA=="],
"@vue/language-core": ["@vue/language-core@2.2.10", "", { "dependencies": { "@volar/language-core": "~2.4.11", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^1.0.3", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-+yNoYx6XIKuAO8Mqh1vGytu8jkFEOH5C8iOv3i8Z/65A7x9iAOXA97Q+PqZ3nlm2lxf5rOJuIGI/wDtx/riNYw=="],
"@vue/reactivity": ["@vue/reactivity@3.5.14", "", { "dependencies": { "@vue/shared": "3.5.14" } }, "sha512-7cK1Hp343Fu/SUCCO52vCabjvsYu7ZkOqyYu7bXV9P2yyfjUMUXHZafEbq244sP7gf+EZEz+77QixBTuEqkQQw=="],
@@ -350,6 +500,12 @@
"@vue/tsconfig": ["@vue/tsconfig@0.7.0", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg=="],
"@vueuse/core": ["@vueuse/core@13.2.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.2.0", "@vueuse/shared": "13.2.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-n5TZoIAxbWAQ3PqdVPDzLgIRQOujFfMlatdI+f7ditSmoEeNpPBvp7h2zamzikCmrhFIePAwdEQB6ENccHr7Rg=="],
"@vueuse/metadata": ["@vueuse/metadata@13.2.0", "", {}, "sha512-kPpzuQCU0+D8DZCzK0iPpIcXI+6ufWSgwnjJ6//GNpEn+SHViaCtR+XurzORChSgvpHO9YC8gGM97Y1kB+UabA=="],
"@vueuse/shared": ["@vueuse/shared@13.2.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-vx9ZPDF5HcU9up3Jgt3G62dMUfZEdk6tLyBAHYAG4F4n73vpaA7J5hdncDI/lS9Vm7GA/FPlbOmh9TrDZROTpg=="],
"acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
@@ -358,10 +514,16 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"ast-kit": ["ast-kit@1.4.3", "", { "dependencies": { "@babel/parser": "^7.27.0", "pathe": "^2.0.3" } }, "sha512-MdJqjpodkS5J149zN0Po+HPshkTdUyrvF7CKTafUgv69vBSPtncrj+3IiUgqdd7ElIEkbeXCsEouBUwLrw9Ilg=="],
"ast-walker-scope": ["ast-walker-scope@0.6.2", "", { "dependencies": { "@babel/parser": "^7.25.3", "ast-kit": "^1.0.1" } }, "sha512-1UWOyC50xI3QZkRuDj6PqDtpm1oHWtYs+NQGwqL/2R11eN3Q81PHAHPM0SWW3BNQm53UDwS//Jv8L4CCVLM1bQ=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
@@ -378,12 +540,18 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"birpc": ["birpc@2.3.0", "", {}, "sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g=="],
"brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.24.5", "", { "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -394,6 +562,14 @@
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -404,10 +580,16 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"copy-anything": ["copy-anything@3.0.5", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w=="],
"core-js-compat": ["core-js-compat@3.42.0", "", { "dependencies": { "browserslist": "^4.24.4" } }, "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -424,18 +606,32 @@
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"default-browser": ["default-browser@5.2.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg=="],
"default-browser-id": ["default-browser-id@5.0.0", "", {}, "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.157", "", {}, "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w=="],
"electron-to-chromium": ["electron-to-chromium@1.5.155", "", {}, "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng=="],
"enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"error-stack-parser-es": ["error-stack-parser-es@0.1.5", "", {}, "sha512-xHku1X40RO+fO8yJ8Wh2f2rZWVjqyhb1zgq1yZ8aZRQkv6OOKhKWRUaht3eSCUbAOBaKIgM+ykwFLE+QUxgGeg=="],
"es-abstract": ["es-abstract@1.23.10", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-regex": "^1.2.1", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-MtUbM072wlJNyeYAe0mhzrD+M6DIJa96CZAOBBrhDbgKnB4MApIKefcyAB1eOdYn8cUNZgvwBvEzdoAYsxgEIw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@@ -456,16 +652,28 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"execa": ["execa@9.5.3", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.3", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.0", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.0.0" } }, "sha512-QFNnTvU3UjgWFy8Ef9iDHvIdcgZ344ebkwYx4/KLbR+CKQA4xBaHzv+iRpp86QfMHP8faFQLh8iOc57215y4Rg=="],
"exsolve": ["exsolve@1.0.5", "", {}, "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fdir": ["fdir@6.4.4", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
@@ -488,10 +696,14 @@
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
@@ -516,6 +728,10 @@
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
@@ -540,18 +756,30 @@
"is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
"is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
"is-obj": ["is-obj@1.0.1", "", {}, "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
"is-regexp": ["is-regexp@1.0.0", "", {}, "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="],
@@ -560,7 +788,7 @@
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
@@ -568,16 +796,26 @@
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
"is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
"is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jake": ["jake@10.9.2", "", { "dependencies": { "async": "^3.2.3", "chalk": "^4.0.2", "filelist": "^1.0.4", "minimatch": "^3.1.2" }, "bin": { "jake": "bin/cli.js" } }, "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA=="],
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -592,8 +830,34 @@
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
"local-pkg": ["local-pkg@1.1.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.0.1", "quansync": "^0.2.8" } }, "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
@@ -602,52 +866,104 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-vue-next": ["lucide-vue-next@0.511.0", "", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-VSv0F3pHniGN7JMMzDcLFNMQbl8381+shNnHwV8hi+El7xl2ZL8qdNuzPoiBViKk8mTKK5K3ZDfmE/wEcTZVIQ=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
"magic-string-ast": ["magic-string-ast@0.7.1", "", { "dependencies": { "magic-string": "^0.30.17" } }, "sha512-ub9iytsEbT7Yw/Pd29mSo/cNQpaEu67zR1VVcXDiYjSFwzeBxNdTd0FMnSslLQXiRj8uGPzwsaoefrMD5XAmdw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
"mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"pinia": ["pinia@3.0.2", "", { "dependencies": { "@vue/devtools-api": "^7.7.2" }, "peerDependencies": { "typescript": ">=4.4.4", "vue": "^2.7.0 || ^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g=="],
"pkg-types": ["pkg-types@2.1.0", "", { "dependencies": { "confbox": "^0.2.1", "exsolve": "^1.0.1", "pathe": "^2.0.3" } }, "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.6.11", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-import-sort", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-style-order", "prettier-plugin-svelte"] }, "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA=="],
"pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="],
"pretty-ms": ["pretty-ms@9.2.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
@@ -662,12 +978,22 @@
"regjsparser": ["regjsparser@0.12.0", "", { "dependencies": { "jsesc": "~3.0.2" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ=="],
"reka-ui": ["reka-ui@2.2.1", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^12.5.0", "@vueuse/shared": "^12.5.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-oLHiyBn6gTIQGnTnv8G5LQuFp9j8HuUNl0qdnW3XPhFb/07hrxzFpjo2kt/jxOZive+n/XWDbOjSj2h9Hih3qA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rollup": ["rollup@4.41.0", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.41.0", "@rollup/rollup-android-arm64": "4.41.0", "@rollup/rollup-darwin-arm64": "4.41.0", "@rollup/rollup-darwin-x64": "4.41.0", "@rollup/rollup-freebsd-arm64": "4.41.0", "@rollup/rollup-freebsd-x64": "4.41.0", "@rollup/rollup-linux-arm-gnueabihf": "4.41.0", "@rollup/rollup-linux-arm-musleabihf": "4.41.0", "@rollup/rollup-linux-arm64-gnu": "4.41.0", "@rollup/rollup-linux-arm64-musl": "4.41.0", "@rollup/rollup-linux-loongarch64-gnu": "4.41.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.41.0", "@rollup/rollup-linux-riscv64-gnu": "4.41.0", "@rollup/rollup-linux-riscv64-musl": "4.41.0", "@rollup/rollup-linux-s390x-gnu": "4.41.0", "@rollup/rollup-linux-x64-gnu": "4.41.0", "@rollup/rollup-linux-x64-musl": "4.41.0", "@rollup/rollup-win32-arm64-msvc": "4.41.0", "@rollup/rollup-win32-ia32-msvc": "4.41.0", "@rollup/rollup-win32-x64-msvc": "4.41.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg=="],
"run-applescript": ["run-applescript@7.0.0", "", {}, "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -676,6 +1002,8 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
@@ -686,6 +1014,10 @@
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
@@ -694,6 +1026,10 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"sirv": ["sirv@3.0.1", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A=="],
"smob": ["smob@1.5.0", "", {}, "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig=="],
"source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
@@ -704,6 +1040,8 @@
"sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="],
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
"string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="],
@@ -716,10 +1054,22 @@
"strip-comments": ["strip-comments@2.0.1", "", {}, "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw=="],
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"superjson": ["superjson@2.2.2", "", { "dependencies": { "copy-anything": "^3.0.2" } }, "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@3.3.0", "", {}, "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ=="],
"tailwindcss": ["tailwindcss@4.1.7", "", {}, "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg=="],
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
"temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="],
"tempy": ["tempy@0.6.0", "", { "dependencies": { "is-stream": "^2.0.0", "temp-dir": "^2.0.0", "type-fest": "^0.16.0", "unique-string": "^2.0.0" } }, "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw=="],
@@ -728,9 +1078,17 @@
"tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
"type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.3.0", "", {}, "sha512-jrJ0XenzS9KVuDThJDvnhalbl4IYiMQ/XvpA0a2FL8KmlK+6CSMviO7ROY/I7z1NnUs5NnDhlM6fXmF40xPxzw=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
@@ -742,8 +1100,12 @@
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
"unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="],
@@ -752,28 +1114,54 @@
"unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.1.0", "", {}, "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"unique-string": ["unique-string@2.0.0", "", { "dependencies": { "crypto-random-string": "^2.0.0" } }, "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unplugin": ["unplugin@2.3.4", "", { "dependencies": { "acorn": "^8.14.1", "picomatch": "^4.0.2", "webpack-virtual-modules": "^0.6.2" } }, "sha512-m4PjxTurwpWfpMomp8AptjD5yj8qEZN5uQjjGM3TAs9MWWD2tXSSNNj6jGR2FoVGod4293ytyV6SwBbertfyJg=="],
"unplugin-utils": ["unplugin-utils@0.2.4", "", { "dependencies": { "pathe": "^2.0.2", "picomatch": "^4.0.2" } }, "sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA=="],
"unplugin-vue-router": ["unplugin-vue-router@0.12.0", "", { "dependencies": { "@babel/types": "^7.26.8", "@vue-macros/common": "^1.16.1", "ast-walker-scope": "^0.6.2", "chokidar": "^4.0.3", "fast-glob": "^3.3.3", "json5": "^2.2.3", "local-pkg": "^1.0.0", "magic-string": "^0.30.17", "micromatch": "^4.0.8", "mlly": "^1.7.4", "pathe": "^2.0.2", "scule": "^1.3.0", "unplugin": "^2.2.0", "unplugin-utils": "^0.2.3", "yaml": "^2.7.0" }, "peerDependencies": { "vue-router": "^4.4.0" }, "optionalPeers": ["vue-router"] }, "sha512-xjgheKU0MegvXQcy62GVea0LjyOdMxN0/QH+ijN29W62ZlMhG7o7K+0AYqfpprvPwpWtuRjiyC5jnV2SxWye2w=="],
"upath": ["upath@1.2.0", "", {}, "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"vee-validate": ["vee-validate@4.15.0", "", { "dependencies": { "@vue/devtools-api": "^7.5.2", "type-fest": "^4.8.3" }, "peerDependencies": { "vue": "^3.4.26" } }, "sha512-PGJh1QCFwCBjbHu5aN6vB8macYVWrajbDvgo1Y/8fz9n/RVIkLmZCJDpUgu7+mUmCOPMxeyq7vXUOhbwAqdXcA=="],
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
"vite-hot-client": ["vite-hot-client@2.0.4", "", { "peerDependencies": { "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0" } }, "sha512-W9LOGAyGMrbGArYJN4LBCdOC5+Zwh7dHvOHC0KmGKkJhsOzaKbpo/jEjpPKVHIW0/jBWj8RZG0NUxfgA8BxgAg=="],
"vite-plugin-inspect": ["vite-plugin-inspect@0.8.9", "", { "dependencies": { "@antfu/utils": "^0.7.10", "@rollup/pluginutils": "^5.1.3", "debug": "^4.3.7", "error-stack-parser-es": "^0.1.5", "fs-extra": "^11.2.0", "open": "^10.1.0", "perfect-debounce": "^1.0.0", "picocolors": "^1.1.1", "sirv": "^3.0.0" }, "peerDependencies": { "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.1" } }, "sha512-22/8qn+LYonzibb1VeFZmISdVao5kC22jmEKm24vfFE8siEn47EpVcCLYMv6iKOYMJfjSvSJfueOwcFCkUnV3A=="],
"vite-plugin-pwa": ["vite-plugin-pwa@1.0.0", "", { "dependencies": { "debug": "^4.3.6", "pretty-bytes": "^6.1.1", "tinyglobby": "^0.2.10", "workbox-build": "^7.3.0", "workbox-window": "^7.3.0" }, "peerDependencies": { "@vite-pwa/assets-generator": "^1.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["@vite-pwa/assets-generator"] }, "sha512-X77jo0AOd5OcxmWj3WnVti8n7Kw2tBgV1c8MCXFclrSlDV23ePzv2eTDIALXI2Qo6nJ5pZJeZAuX0AawvRfoeA=="],
"vite-plugin-vue-devtools": ["vite-plugin-vue-devtools@7.7.6", "", { "dependencies": { "@vue/devtools-core": "^7.7.6", "@vue/devtools-kit": "^7.7.6", "@vue/devtools-shared": "^7.7.6", "execa": "^9.5.2", "sirv": "^3.0.1", "vite-plugin-inspect": "0.8.9", "vite-plugin-vue-inspector": "^5.3.1" }, "peerDependencies": { "vite": "^3.1.0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0" } }, "sha512-L7nPVM5a7lgit/Z+36iwoqHOaP3wxqVi1UvaDJwGCfblS9Y6vNqf32ILlzJVH9c47aHu90BhDXeZc+rgzHRHcw=="],
"vite-plugin-vue-inspector": ["vite-plugin-vue-inspector@5.3.1", "", { "dependencies": { "@babel/core": "^7.23.0", "@babel/plugin-proposal-decorators": "^7.23.0", "@babel/plugin-syntax-import-attributes": "^7.22.5", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-transform-typescript": "^7.22.15", "@vue/babel-plugin-jsx": "^1.1.5", "@vue/compiler-dom": "^3.3.4", "kolorist": "^1.8.0", "magic-string": "^0.30.4" }, "peerDependencies": { "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0" } }, "sha512-cBk172kZKTdvGpJuzCCLg8lJ909wopwsu3Ve9FsL1XsnLBiRT9U3MePcqrgGHgCX2ZgkqZmAGR8taxw+TV6s7A=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"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-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=="],
"vue-tsc": ["vue-tsc@2.2.10", "", { "dependencies": { "@volar/typescript": "~2.4.11", "@vue/language-core": "2.2.10" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-jWZ1xSaNbabEV3whpIDMbjVSVawjAyW+x1n3JeGQo7S0uv2n9F/JMgWW90tGWNFRKya4YwKMZgCtr0vRAM7DeQ=="],
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
"which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
@@ -816,42 +1204,94 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="],
"yoctocolors": ["yoctocolors@2.1.1", "", {}, "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ=="],
"zod": ["zod@3.25.7", "", {}, "sha512-YGdT1cVRmKkOg6Sq7vY7IkxdphySKnXhaUmFI4r4FcuFVNgpCb9tZfNwXbT6BPjD5oz0nubFsoo9pIqKrDcCvg=="],
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"@rollup/plugin-babel/@rollup/pluginutils": ["@rollup/pluginutils@3.1.0", "", { "dependencies": { "@types/estree": "0.0.39", "estree-walker": "^1.0.1", "picomatch": "^2.2.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0" } }, "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg=="],
"@rollup/plugin-babel/rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
"@rollup/plugin-node-resolve/@rollup/pluginutils": ["@rollup/pluginutils@5.1.4", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ=="],
"@rollup/plugin-replace/@rollup/pluginutils": ["@rollup/pluginutils@3.1.0", "", { "dependencies": { "@types/estree": "0.0.39", "estree-walker": "^1.0.1", "picomatch": "^2.2.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0" } }, "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg=="],
"@rollup/plugin-replace/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="],
"@rollup/plugin-replace/rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
"@rollup/pluginutils/@types/estree": ["@types/estree@0.0.39", "", {}, "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@1.0.1", "", {}, "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg=="],
"@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"@rollup/pluginutils/rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
"@surma/rollup-plugin-off-main-thread/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.10", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" }, "bundled": true }, "sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@vue/devtools-core/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
"filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"jake/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"regjsparser/jsesc": ["jsesc@3.0.2", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="],
"reka-ui/@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
"reka-ui/@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"tempy/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
"vite-plugin-inspect/fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="],
"vue-router/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
"workbox-build/pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
"workbox-build/rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
"@rollup/plugin-babel/@rollup/pluginutils/@types/estree": ["@types/estree@0.0.39", "", {}, "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="],
"@rollup/plugin-babel/@rollup/pluginutils/estree-walker": ["estree-walker@1.0.1", "", {}, "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg=="],
"@rollup/plugin-babel/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"@rollup/plugin-replace/@rollup/pluginutils/@types/estree": ["@types/estree@0.0.39", "", {}, "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="],
"@rollup/plugin-replace/@rollup/pluginutils/estree-walker": ["estree-walker@1.0.1", "", {}, "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg=="],
"@rollup/plugin-replace/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"jake/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"reka-ui/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"typescript": true,
"tailwind": {
"config": "",
"css": "styles/tailwind.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}
+1
View File
@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
+92
View File
@@ -0,0 +1,92 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.i9edo79oi9g"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));
File diff suppressed because it is too large Load Diff
+24 -3
View File
@@ -2,12 +2,33 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<link rel="icon" type="image/svg+xml" href="/icons/icon-192px.jpg" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0" />
<meta
name="description"
content="Budget Manager PWA for tracking your finances" />
<meta name="theme-color" content="#3b82f6" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192px.jpg" />
<!-- iOS PWA support -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Budget Manager" />
<title>Budget Manager</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<!-- PWA no-JS fallback -->
<noscript>
<div style="padding: 20px; text-align: center">
<h1>Budget Manager</h1>
<p>This application requires JavaScript to be enabled.</p>
</div>
</noscript>
</body>
</html>
+290
View File
@@ -0,0 +1,290 @@
# Vue.js for React Developers: A Guided Tour
Welcome to the LuPa Plant Manager Cockpit's Vue.js frontend! This guide is designed for developers familiar with React who are new to Vue.js. We'll explore the key concepts, syntax differences, and mental models needed to understand and work with this codebase.
## Table of Contents
1. [Project Structure](#project-structure)
2. [Component System](#component-system)
3. [Reactivity & State Management](#reactivity--state-management)
4. [Template Syntax vs JSX](#template-syntax-vs-jsx)
5. [Routing](#routing)
6. [Lifecycle Hooks](#lifecycle-hooks)
7. [Styling & UI Components](#styling--ui-components)
8. [Additional Resources](#additional-resources)
## Project Structure
The Vue project structure is similar to React with some key differences:
```
frontend-vue/
├── src/ # Source code directory (same as React)
│ ├── App.vue # Root component (like App.jsx in React)
│ ├── main.ts # Application entry point (like index.js)
│ ├── components/ # Reusable components (similar to React)
│ │ └── ui/ # UI component library (similar to shadcn/ui in React)
│ ├── pages/ # Page components (similar to pages/ in Next.js)
│ └── stores/ # State management (similar to Redux or Context)
├── styles/ # Global styles
└── public/ # Static assets (same as React)
```
The main difference: Vue uses Single File Components (SFC) with `.vue` extension that contain template, script, and style in one file, versus separate `.jsx/.tsx` and `.css` files in React.
## Component System
### Single File Components
Vue's Single File Components (SFCs) combine template, script, and style in one `.vue` file:
```vue
<template>
<!-- HTML template (like JSX in React) -->
</template>
<script setup>
// Component logic (like function body in React)
</script>
<style>
/* Component styles (like CSS modules or styled-components) */
</style>
```
The closest React equivalent would be combining JSX with CSS modules or styled-components.
### Example Components
Explore our commented examples:
- [App.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/App.vue) - Root component with router setup
- [PageLayout.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/components/PageLayout.vue) - Layout component with responsive behavior
- [Navigation.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/components/Navigation.vue) - Navigation component with router links
### Component Composition
Vue components can be composed similarly to React:
```vue
<template>
<PageLayout>
<Card>
<CardContent> Hello world! </CardContent>
</Card>
</PageLayout>
</template>
```
This is conceptually similar to React's component composition:
```jsx
function MyComponent() {
return (
<PageLayout>
<Card>
<CardContent>Hello world!</CardContent>
</Card>
</PageLayout>
);
}
```
## Reactivity & State Management
### Reactivity Fundamentals
Vue's reactivity system is more granular than React's:
```vue
<script setup>
import { ref, reactive, computed } from "vue";
// Similar to: const [count, setCount] = useState(0)
const count = ref(0);
// Update state - different from React's setCount(count + 1)
function increment() {
count.value++; // Note: Use .value to get/set ref values
}
// Similar to a more complex useState object
const user = reactive({
name: "John",
age: 30,
});
// Similar to useMemo, but cleaner syntax
const doubleCount = computed(() => count.value * 2);
</script>
```
See our [Login.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/pages/Login.vue) for practical examples of state management.
### Key Differences from React
1. **Direct mutations**: In Vue, you directly mutate state with `count.value++`, whereas React requires `setCount(count + 1)`
2. **Automatic tracking**: Vue automatically tracks dependencies, whereas React requires explicit dependency arrays
3. **Computed properties**: Vue provides `computed()` for derived state, which is cleaner than React's `useMemo()`
## Template Syntax vs JSX
### Vue Templates vs React JSX
Vue uses an HTML-based template syntax, while React uses JSX:
| Concept | Vue | React |
| ----------- | ------------------------------- | -------------------------------- |
| Attributes | `<div :class="myClass">` | `<div className={myClass}>` |
| Events | `<button @click="handleClick">` | `<button onClick={handleClick}>` |
| Conditional | `<div v-if="showDiv">` | `{showDiv && <div>}` |
| Loops | `<div v-for="item in items">` | `{items.map(item => <div>)}` |
| Text | `<div>{{ message }}</div>` | `<div>{message}</div>` |
### Examples in Our Codebase
Check out these components for real-world examples:
- [Dashboard.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/pages/Dashboard.vue) - Lists and conditionals
- [Navigation.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/components/Navigation.vue) - Router links and template basics
## Routing
Vue Router is similar to React Router but with some syntax differences:
### Route Configuration
```js
// Vue Router (in main.ts)
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
component: () => import("./pages/Dashboard.vue"),
},
// More routes...
],
});
// React Router equivalent
const router = createBrowserRouter([
{
path: "/",
element: <Dashboard />,
},
// More routes...
]);
```
See our fully commented [main.ts](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/main.ts) for the complete router setup.
### Router Links
```vue
<!-- Vue -->
<router-link to="/about" active-class="active">About</router-link>
<!-- React -->
<NavLink to="/about" activeClassName="active">About</NavLink>
```
Examples can be found in [Navigation.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/components/Navigation.vue).
## Lifecycle Hooks
Vue's Composition API lifecycle hooks compared to React's:
| Vue | React | Description |
| ----------------------- | ------------------------------------------ | ------------------------------------------ |
| `onMounted(() => {})` | `useEffect(() => {}, [])` | Runs once after component mounts |
| `onUpdated(() => {})` | `useEffect(() => {})` | Runs after each update |
| `onUnmounted(() => {})` | `useEffect(() => { return () => {} }, [])` | Cleanup when component unmounts |
| `watch(dep, callback)` | `useEffect(() => {}, [dep])` | Watch for changes in specific dependencies |
### Example in Our Codebase
```vue
<script setup>
import { onMounted, onUnmounted } from "vue";
// Similar to React's:
// useEffect(() => {
// window.addEventListener('resize', handleResize);
// return () => window.removeEventListener('resize', handleResize);
// }, []);
onMounted(() => {
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
</script>
```
See [PageLayout.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/components/PageLayout.vue) for a practical lifecycle example.
## Styling & UI Components
### Global vs Component Styles
In our project, we use:
- Tailwind CSS for utility classes (similar to React)
- Global styles in [transitions.css](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/styles/transitions.css)
- Component-specific styles can be added in a `<style>` block in `.vue` files
### UI Component Library
We use shadcn-vue, which is similar to shadcn/ui in React:
```vue
<script setup>
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
</script>
<template>
<Card>
<CardHeader>
<h2>Card Title</h2>
</CardHeader>
<CardContent>
<p>Card content</p>
<Button>Click me</Button>
</CardContent>
</Card>
</template>
```
See [Login.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/pages/Login.vue) and [Dashboard.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/pages/Dashboard.vue) for examples of UI component usage.
## Additional Resources
### Official Vue Documentation
- [Vue.js Official Guide](https://vuejs.org/guide/introduction.html)
- [Vue Router](https://router.vuejs.org/)
- [Composition API](https://vuejs.org/guide/extras/composition-api-faq.html)
### Vue for React Developers
- [Vue for React Developers](https://www.vuemastery.com/courses/vue-for-react-devs/introduction)
- [Vue vs React: Key Differences](https://v3.vuejs.org/guide/comparison.html)
### Our Well-Commented Files
- [App.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/App.vue) - Root component
- [main.ts](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/main.ts) - App initialization & routing
- [PageLayout.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/components/PageLayout.vue) - Layout with lifecycle methods
- [Navigation.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/components/Navigation.vue) - Router navigation
- [Login.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/pages/Login.vue) - Forms and event handling
## Conclusion
Vue and React share many concepts but differ in implementation details. Vue's template syntax might feel more HTML-like, while its reactivity system is more fine-grained than React's. With the mental models provided in this guide, you should be able to navigate the Vue codebase more confidently.
Remember that Vue's Composition API (using `<script setup>`) is the modern approach that's quite similar to React hooks, making the transition between frameworks smoother.
Happy coding!
+25 -5
View File
@@ -1,5 +1,5 @@
{
"name": "frontend",
"name": "frontend-vue",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -9,15 +9,35 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
"@tailwindcss/cli": "^4.1.7",
"@tailwindcss/vite": "^4.1.7",
"@vee-validate/zod": "^4.15.0",
"@vueuse/core": "^13.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.511.0",
"pinia": "^3.0.2",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"reka-ui": "^2.2.1",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.7",
"tw-animate-css": "^1.3.0",
"vee-validate": "^4.15.0",
"vue": "^3.5.14",
"vue-router": "^4.5.1",
"zod": "^3.25.7"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"@types/node": "^22.15.19",
"@types/vue-router": "^2.0.0",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/tsconfig": "^0.7.0",
"prettier": "^3.5.3",
"typescript": "~5.8.3",
"unplugin-vue-router": "^0.12.0",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0",
"vue-tsc": "^2.2.8"
"vite-plugin-vue-devtools": "^7.7.6",
"vue-tsc": "^2.2.10"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+59
View File
@@ -0,0 +1,59 @@
{
"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"
}
],
"orientation": "portrait",
"categories": ["finance", "productivity", "utilities"],
"screenshots": [
{
"src": "/screenshots/dashboard.png",
"sizes": "1280x720",
"type": "image/png",
"platform": "wide",
"label": "Dashboard of Budget Manager PWA"
},
{
"src": "/screenshots/transactions.png",
"sizes": "1280x720",
"type": "image/png",
"platform": "wide",
"label": "Transactions page of Budget Manager PWA"
}
],
"related_applications": [],
"prefer_related_applications": false,
"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" }]
}
]
}
+215
View File
@@ -0,0 +1,215 @@
// Service Worker for Budget Manager PWA
const CACHE_NAME = "budget-manager-v1";
// Assets to precache
const precacheResources = [
"/",
"/index.html",
"/src/main.ts",
"/src/App.vue",
"/styles/tailwind.css",
"/styles/transitions.css",
"/styles/style.css",
"/icons/icon-192px.jpg",
"/icons/icon-512px.jpg",
];
// Install event - precache assets
self.addEventListener("install", (event) => {
console.log("Service worker install event");
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => {
return cache.addAll(precacheResources);
})
.then(() => {
// Skip waiting to activate immediately
return self.skipWaiting();
}),
);
});
// Activate event - clean up old caches
self.addEventListener("activate", (event) => {
console.log("Service worker activate event");
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log("Deleting old cache:", cacheName);
return caches.delete(cacheName);
}
}),
);
})
.then(() => {
// Take control of all clients
return self.clients.claim();
}),
);
});
// Fetch event - serve from cache first, then network
self.addEventListener("fetch", (event) => {
// Skip cross-origin requests
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 successful responses
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(() => {
// If fetch fails (offline), return a fallback
if (event.request.mode === "navigate") {
return caches.match("/");
}
return null;
});
}),
);
});
// Background sync for offline transactions
self.addEventListener("sync", (event) => {
if (event.tag === "sync-transactions") {
event.waitUntil(syncTransactions());
} else if (event.tag === "sync-categories") {
event.waitUntil(syncCategories());
}
});
// Push notification handler
self.addEventListener("push", (event) => {
const data = event.data.json();
const options = {
body: data.body,
icon: "/icons/icon-192px.jpg",
badge: "/icons/badge-96x96.png",
data: data.data,
actions: data.actions || [],
};
event.waitUntil(self.registration.showNotification(data.title, options));
});
// Notification click handler
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(clients.openWindow("/"));
});
// Sync transactions with the server
async function syncTransactions() {
try {
const pendingTransactions = await getPendingItems("pendingTransactions");
const token = await getAuthToken();
if (!token || pendingTransactions.length === 0) return;
for (const transaction of pendingTransactions) {
const response = await fetch("http://localhost:3000/api/transactions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(transaction),
});
if (response.ok) {
await removePendingItem("pendingTransactions", transaction.id);
}
}
} catch (error) {
console.error("Error syncing transactions:", error);
}
}
// Sync categories with the server
async function syncCategories() {
try {
const pendingCategories = await getPendingItems("pendingCategories");
const token = await getAuthToken();
if (!token || pendingCategories.length === 0) return;
for (const category of pendingCategories) {
const response = await fetch("http://localhost:3000/api/categories", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(category),
});
if (response.ok) {
await removePendingItem("pendingCategories", category.id);
}
}
} catch (error) {
console.error("Error syncing categories:", error);
}
}
// Helper function to get pending items from IndexedDB
async function getPendingItems(storeName) {
return []; // Placeholder - in a real app, this would access IndexedDB
}
// Helper function to remove a pending item from IndexedDB
async function removePendingItem(storeName, id) {
// Placeholder - in a real app, this would access IndexedDB
}
// Helper function to get auth token from client
async function getAuthToken() {
const clients = await self.clients.matchAll();
if (clients.length === 0) {
return localStorage.getItem("token");
}
// Ask client for token
const client = clients[0];
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
resolve(event.data.token);
};
client.postMessage({ type: "GET_AUTH_TOKEN" }, [channel.port2]);
});
}
+66 -23
View File
@@ -1,30 +1,73 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
/**
* Vue 3 Script Setup Format
* -------------------------------
* This is Vue's Composition API with the <script setup> syntax - Vue 3's modern approach.
* It's similar to React's functional components with hooks.
*
* Key differences from React:
* - No need to return JSX - template is separate (like having JSX in a different section)
* - No explicit useState - just use 'ref' or 'reactive' directly
* - No useEffect - use 'onMounted', 'onUpdated', 'watch', etc.
* - Variables/functions declared here are automatically available in the template
*/
import { ref, onMounted } from "vue";
// 'ref' is like React's useState - creates a reactive variable
// In React: const [isPageLoaded, setIsPageLoaded] = useState(false)
const isPageLoaded = ref(false);
// 'onMounted' is like React's useEffect with empty dependency array
// In React: useEffect(() => { ... }, [])
onMounted(() => {
// Small delay to ensure DOM is fully loaded
setTimeout(() => {
// To update a ref value, use .value (this is different from React)
// In React: setIsPageLoaded(true)
isPageLoaded.value = true;
}, 50);
});
</script>
<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
<!--
Template Section
---------------
This is where Vue's template syntax (similar to JSX) goes
Key differences from React:
- Uses HTML-like syntax instead of JSX
- v-bind: (or :) is like React's {}
- v-on: (or @) is like React's onClick={} etc.
- v-if, v-else, v-for instead of {condition && <element>} or {items.map()}
- No fragments needed - templates can have multiple root elements
-->
<!-- Dynamic class binding similar to React's className={isPageLoaded ? 'opacity-100' : 'opacity-0'} -->
<div :class="{ 'opacity-0': !isPageLoaded, 'opacity-100': isPageLoaded }">
<!--
Router View Component
--------------------
Similar to React Router's <Routes>/<Route> system, but more concise
The v-slot exposes the component that should be rendered for the current route
This is like React Router's element prop but with more capabilities
-->
<router-view v-slot="{ Component }">
<!--
Dynamic component rendering
In React, this would be similar to a switch statement with routes
The :key ensures the component is completely re-rendered when the route changes
-->
<component :is="Component" :key="$route.fullPath" />
</router-view>
</div>
<HelloWorld msg="Vite + Vue" />
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>
<!--
Notes:
1. Vue files combine script, template, and style in one file (Single File Component)
2. No need for explicit exports like in React
3. There's no direct equivalent to React's Context API - Vue uses 'provide/inject' or Pinia store
4. Vue's reactivity system is more granular than React's
5. No need for useCallback or useMemo - Vue optimizes renders differently
-->
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useTransactionsStore } from "@/stores/transactions";
import { formatCurrency } from "@/lib/utils";
import { useRouter } from "vue-router";
// Access the transactions store
const transactionsStore = useTransactionsStore();
const { totalIncome, totalExpenses, balance, loading, transactions } = transactionsStore;
// Calculate balance history from transactions
const balanceHistory = computed(() => {
const now = new Date();
const months = [];
// Get last 6 months
for (let i = 5; i >= 0; i--) {
const month = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthName = month.toLocaleDateString('en-US', { month: 'short' });
months.push({
month: monthName,
year: month.getFullYear(),
monthIndex: month.getMonth(),
balance: 0,
income: 0,
expenses: 0
});
}
// Calculate balance for each month
transactionsStore.transactions.forEach(transaction => {
const txDate = new Date(transaction.date);
const monthIndex = months.findIndex(m =>
m.monthIndex === txDate.getMonth() &&
m.year === txDate.getFullYear()
);
if (monthIndex !== -1) {
if (transaction.type === 'income') {
months[monthIndex].income += transaction.amount;
} else {
months[monthIndex].expenses += transaction.amount;
}
months[monthIndex].balance = months[monthIndex].income - months[monthIndex].expenses;
}
});
// Ensure there's some data for visualization
const maxBalance = Math.max(...months.map(m => Math.abs(m.balance)), 1000);
return {
months,
maxBalance
};
});
// Format balance with colors - neutral by default, red if negative
const balanceClass = computed(() =>
balance.value < 0 ? "text-red-500" : "text-gray-700",
);
onMounted(() => {
transactionsStore.fetchTransactions();
});
</script>
<template>
<Card class="w-full shadow-sm hover:shadow transition-shadow duration-300">
<CardHeader class="pb-2">
<CardTitle class="text-xl font-bold flex items-center">
<span class="mr-2">Balance Summary</span>
<span v-if="loading" class="inline-block w-3 h-3 rounded-full bg-primary animate-pulse"></span>
</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
<!-- Summary Stats -->
<div class="grid grid-cols-2 gap-4 pb-2">
<!-- Income -->
<div class="rounded-lg bg-green-50 p-3 transition-all">
<div class="text-sm font-medium text-gray-500">Income</div>
<div class="text-green-600 font-semibold text-lg mt-1">
{{ formatCurrency(totalIncome) }}
</div>
</div>
<!-- Expenses -->
<div class="rounded-lg bg-red-50 p-3 transition-all">
<div class="text-sm font-medium text-gray-500">Expenses</div>
<div class="text-red-600 font-semibold text-lg mt-1">
{{ formatCurrency(totalExpenses) }}
</div>
</div>
</div>
<div class="pt-4 border-t">
<div class="flex justify-between items-center">
<div class="text-sm font-bold">Current Balance</div>
<div :class="balanceClass" class="text-xl font-bold flex items-center">
{{ balance < 0 ? '-' : '' }}{{ formatCurrency(Math.abs(balance)) }}
<span class="ml-1 text-xs" :class="balance < 0 ? 'text-red-400' : 'text-gray-500'">
({{ balance >= 0 ? '+' : '' }}{{ Math.round(balance / Math.max(totalIncome, 1) * 100) }}%)
</span>
</div>
</div>
</div>
<!-- Balance History Chart -->
<div class="mt-6">
<h3 class="text-sm font-medium mb-2">Balance History</h3>
<!-- No data message -->
<div v-if="transactions.length === 0" class="text-center py-6 bg-gray-50 rounded-md border border-dashed border-gray-200">
<p class="text-sm text-gray-500">No transaction data available</p>
<router-link to="/add" class="text-xs text-primary hover:underline mt-1 inline-block">
Add your first transaction
</router-link>
</div>
<!-- Chart -->
<div v-else class="h-32 flex items-end justify-between">
<div
v-for="(item, index) in balanceHistory.months"
:key="index"
class="flex flex-col items-center w-full group relative cursor-pointer">
<div class="relative w-full h-24 flex flex-col justify-end items-center">
<!-- Bar -->
<div
class="w-8 rounded-t transition-all duration-500 ease-in-out hover:opacity-80"
:class="item.balance >= 0 ? 'bg-green-500/80' : 'bg-red-500/80'"
:style="{
height: `${Math.abs(item.balance) / (balanceHistory.maxBalance / 100)}%`,
minHeight: '4px'
}">
</div>
<!-- Tooltip with month data -->
<div class="opacity-0 group-hover:opacity-100 absolute bottom-full mb-2 bg-gray-800 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10 shadow-md transition-opacity">
<div class="font-medium mb-1">{{ item.month }} {{ item.year }}</div>
<div>Income: {{ formatCurrency(item.income) }}</div>
<div>Expenses: {{ formatCurrency(item.expenses) }}</div>
<div class="mt-1 pt-1 border-t border-gray-700">
<span :class="item.balance >= 0 ? 'text-green-400' : 'text-red-400'">
Balance: {{ formatCurrency(item.balance) }}
</span>
</div>
</div>
</div>
<span class="text-xs mt-1 text-gray-600">{{ item.month }}</span>
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter>
<router-link to="/reports" class="w-full">
<Button variant="outline" size="sm" class="w-full hover:bg-primary/10 transition-colors">
View Detailed Report
</Button>
</router-link>
</CardFooter>
</Card>
</template>
-41
View File
@@ -1,41 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { LogOut } from "lucide-vue-next";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { useAuthStore } from "@/stores/auth";
import { computed } from "vue";
// Auth store
const authStore = useAuthStore();
// Get user data from auth store
const userData = computed(() => {
if (!authStore.user) {
return {
name: "Guest User",
role: "Guest",
avatar: "",
initials: "GU",
};
}
return {
name: authStore.user.username || "User",
role: authStore.user.role || "User",
avatar: "",
initials: authStore.user.username
? authStore.user.username.substring(0, 2).toUpperCase()
: "U",
};
});
// Initialize router
const router = useRouter();
const logout = () => {
authStore.logout();
};
</script>
<template>
<div class="flex items-center justify-between space-x-3 p-4">
<div class="flex items-center space-x-3">
<Avatar>
<AvatarImage :src="userData.avatar" alt="User avatar" />
<AvatarFallback>{{ userData.initials }}</AvatarFallback>
</Avatar>
<div>
<p class="text-sm font-medium text-gray-900">
{{ userData.name }}
</p>
<p class="text-xs text-gray-500">
{{ userData.role }}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
@click="logout"
class="h-8 w-8 p-0"
aria-label="Logout">
<LogOut class="h-4 w-4" />
</Button>
</div>
</template>
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
/**
* Navigation Component
* -------------------
* This component renders the app's navigation menu with links.
* It's similar to a Navigation component in React that might use React Router.
*/
import { defineComponent } from "vue";
import {
Home,
LogIn,
Info,
PieChart,
Plus,
CreditCard,
Settings,
Shield,
Tag,
} from "lucide-vue-next"; // Icon library (equivalent to something like react-icons)
import { useAuthStore } from "@/stores/auth";
// defineComponent is optional in <script setup> but provides better TypeScript support and naming
// In React, this would be like: function Navigation() { ... }
defineComponent({
name: "Navigation", // Component name for debugging
});
const authStore = useAuthStore();
/**
* Note: With <script setup>, we don't need to explicitly export the component
* All top-level variables and functions are automatically available in the template
* There's no explicit "return" of JSX like in React functional components
*/
</script>
<template>
<!--
Vue Template Section
-------------------
This is similar to the JSX returned by a React component
Key differences:
- Vue uses <router-link> instead of React Router's <Link>
- Vue directives use v- prefix (v-if, v-for, etc.) instead of React's { } expressions
- Vue uses 'class' attribute directly instead of React's 'className'
- Vue's binding syntax is :prop="value" instead of React's prop={value}
-->
<div class="py-2">
<ul class="space-y-1">
<li>
<router-link
to="/"
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
active-class="bg-secondary">
<Home class="mr-3 h-5 w-5" />
Dashboard
</router-link>
</li>
<!-- Auth-required links -->
<template v-if="authStore.isAuthenticated">
<li>
<router-link
to="/transactions"
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
active-class="bg-secondary">
<CreditCard class="mr-3 h-5 w-5" />
Transactions
</router-link>
</li>
<li>
<router-link
to="/add"
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
active-class="bg-secondary">
<Plus class="mr-3 h-5 w-5" />
Add Transaction
</router-link>
</li>
<li>
<router-link
to="/reports"
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
active-class="bg-secondary">
<PieChart class="mr-3 h-5 w-5" />
Reports
</router-link>
</li>
<li>
<router-link
to="/categories"
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
active-class="bg-secondary">
<Tag class="mr-3 h-5 w-5" />
Categories
</router-link>
</li>
<li>
<router-link
to="/settings"
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
active-class="bg-secondary">
<Settings class="mr-3 h-5 w-5" />
Settings
</router-link>
</li>
<!-- Admin only link -->
<li v-if="authStore.user?.role === 'admin'">
<router-link
to="/admin/categories"
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
active-class="bg-secondary">
<Shield class="mr-3 h-5 w-5" />
Manage Categories
</router-link>
</li>
</template>
<!-- Guest links -->
<li v-if="!authStore.isAuthenticated">
<router-link
to="/login"
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors hover:bg-secondary"
active-class="bg-secondary">
<LogIn class="mr-3 h-5 w-5" />
Login
</router-link>
</li>
<li>
<router-link
to="/about"
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
active-class="bg-secondary">
<Info class="mr-3 h-5 w-5" />
About
</router-link>
</li>
</ul>
</div>
</template>
<!--
Notes for React developers:
1. In Vue, you don't need to import React or use React.Fragment
2. Vue templates can have multiple root elements without needing a wrapper
3. Vue's router-link handles active states out of the box (no need for useLocation hook)
4. Vue components have built-in optimization - no need for memo or useMemo in most cases
-->
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { defineProps, ref, onMounted, onUnmounted, computed } from "vue";
import Navigation from "./Navigation.vue";
import LoggedInUser from "./LoggedInUser.vue";
import { X, Menu } from "lucide-vue-next";
import ThemeToggle from "./ThemeToggle.vue";
interface Props {
header: string;
}
defineProps<Props>();
const isSidebarOpen = ref(false);
const isDesktopView = ref(window.innerWidth >= 768);
const handleResize = () => {
isDesktopView.value = window.innerWidth >= 768;
};
onMounted(() => {
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
const sidebarVisibilityClass = computed(() => {
if (isDesktopView.value) {
return "translate-x-0";
}
return isSidebarOpen.value ? "translate-x-0" : "-translate-x-full";
});
const toggleSidebar = () => {
isSidebarOpen.value = !isSidebarOpen.value;
};
const closeSidebar = () => {
isSidebarOpen.value = false;
};
const currentYear = new Date().getFullYear();
</script>
<template>
<div class="min-h-screen md:flex">
<aside
:class="[
'fixed inset-y-0 left-0 z-40 w-64 shadow-lg bg-primary-foreground',
sidebarVisibilityClass,
'md:static md:transform-none md:flex-shrink-0 md:shadow-none',
]"
aria-label="Sidebar navigation">
<div class="h-full flex flex-col md:border-r">
<div
class="px-4 py-6 sm:px-6 lg:px-8 min-h-[81px] flex items-center border-b gap-4">
<h2 class="text-xl font-semibold">Budget Manager</h2>
<button
@click="closeSidebar"
class="p-2 rounded-md md:hidden focus:outline-none focus:ring-2"
aria-label="Close sidebar">
<X class="h-6 w-6" aria-hidden="true" />
</button>
</div>
<nav class="flex-1 overflow-y-auto" aria-label="Main navigation">
<Navigation />
</nav>
<div class="border-t p-4">
<LoggedInUser />
</div>
</div>
</aside>
<div class="flex flex-col w-full h-screen md:flex-grow">
<header class="shadow-sm flex-shrink-0" role="banner">
<div class="px-4 py-6 sm:px-6 lg:px-8 flex items-center">
<button
@click="toggleSidebar"
class="md:hidden mr-4 p-2 rounded-md focus:outline-none focus:ring-2"
aria-label="Toggle sidebar"
:aria-expanded="isSidebarOpen">
<Menu class="h-6 w-6" aria-hidden="true" />
</button>
<h1 class="text-2xl font-semibold">{{ header }}</h1>
<div class="flex-grow"></div>
</div>
</header>
<main class="flex-1 p-4 sm:p-6 lg:p-8 overflow-y-auto" role="main">
<slot></slot>
</main>
<footer class="border-t" role="contentinfo">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<p class="text-center text-sm">
© {{ currentYear }} Budget Manager PWA. All rights reserved.
</p>
</div>
</footer>
</div>
</div>
</template>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useColorMode } from "@vueuse/core";
import { Moon, Sun, Monitor } from "lucide-vue-next";
// Pass { disableTransition: false } to enable transitions
const mode = useColorMode();
const isActive = ref(false);
// Animation effect when theme changes
const animate = () => {
isActive.value = true;
setTimeout(() => {
isActive.value = false;
}, 700);
};
// Watch for theme changes to trigger animation
watch(mode, () => {
animate();
// Save to localStorage for Settings page
localStorage.setItem("darkMode", mode.value === "dark" ? "true" : "false");
});
// Get icon based on current theme
const currentIcon = computed(() => {
if (mode.value === "dark") return Moon;
if (mode.value === "light") return Sun;
return Monitor;
});
// Get text label based on current theme
const currentModeText = computed(() => {
if (mode.value === "dark") return "Dark";
if (mode.value === "light") return "Light";
return "System";
});
// Initialize on mount - sync with Settings page
onMounted(() => {
// Check if a theme preference is stored
const storedDarkMode = localStorage.getItem("darkMode");
if (storedDarkMode === "true") {
mode.value = "dark";
} else if (storedDarkMode === "false") {
mode.value = "light";
}
});
</script>
<template>
<div class="relative">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-9 w-9 px-0 relative"
:class="{ 'animate-pulse': isActive }">
<Moon
v-if="mode === 'dark'"
class="h-[1.2rem] w-[1.2rem] transition-all" />
<Sun
v-else-if="mode === 'light'"
class="h-[1.2rem] w-[1.2rem] transition-all" />
<Monitor v-else class="h-[1.2rem] w-[1.2rem] transition-all" />
<span class="sr-only">Toggle theme</span>
<!-- Theme indicator dot -->
<span
class="absolute bottom-1 right-1 w-1.5 h-1.5 rounded-full bg-primary"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="mt-1 w-40">
<DropdownMenuItem @click="mode = 'light'" class="cursor-pointer">
<Sun class="mr-2 h-4 w-4" /> Light
<span v-if="mode === 'light'" class="ml-auto text-xs opacity-70"
>Active</span
>
</DropdownMenuItem>
<DropdownMenuItem @click="mode = 'dark'" class="cursor-pointer">
<Moon class="mr-2 h-4 w-4" /> Dark
<span v-if="mode === 'dark'" class="ml-auto text-xs opacity-70"
>Active</span
>
</DropdownMenuItem>
<DropdownMenuItem @click="mode = 'auto'" class="cursor-pointer">
<Monitor class="mr-2 h-4 w-4" /> System
<span v-if="mode === 'auto'" class="ml-auto text-xs opacity-70"
>Active</span
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- Visual feedback animation when theme changes -->
<div
v-if="isActive"
class="fixed inset-0 z-50 bg-background opacity-10 pointer-events-none"
:class="{ 'animate-flash': isActive }"></div>
</div>
</template>
<style scoped>
@keyframes flash {
0% {
opacity: 0.1;
}
10% {
opacity: 0.2;
}
100% {
opacity: 0;
}
}
.animate-flash {
animation: flash 700ms ease-out forwards;
}
</style>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { AvatarRoot } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<AvatarRoot
data-slot="avatar"
:class="
cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
props.class,
)
">
<slot />
</AvatarRoot>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { AvatarFallback, type AvatarFallbackProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<
AvatarFallbackProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="
cn(
'bg-muted flex size-full items-center justify-center rounded-full',
props.class,
)
">
<slot />
</AvatarFallback>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AvatarImageProps } from "reka-ui";
import { AvatarImage } from "reka-ui";
const props = defineProps<AvatarImageProps>();
</script>
<template>
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full">
<slot />
</AvatarImage>
</template>
@@ -0,0 +1,3 @@
export { default as Avatar } from "./Avatar.vue";
export { default as AvatarFallback } from "./AvatarFallback.vue";
export { default as AvatarImage } from "./AvatarImage.vue";
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { Primitive, type PrimitiveProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { type ButtonVariants, buttonVariants } from ".";
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"];
class?: HTMLAttributes["class"];
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
});
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)">
<slot />
</Primitive>
</template>
@@ -0,0 +1,36 @@
import { cva, type VariantProps } from "class-variance-authority";
export { default as Button } from "./Button.vue";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export type ButtonVariants = VariantProps<typeof buttonVariants>;
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
">
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
data-slot="card-action"
:class="
cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
props.class,
)
">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div data-slot="card-content" :class="cn('px-6', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)">
<slot />
</p>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
data-slot="card-header"
:class="
cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
props.class,
)
">
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)">
<slot />
</h3>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue";
export { default as CardAction } from "./CardAction.vue";
export { default as CardContent } from "./CardContent.vue";
export { default as CardDescription } from "./CardDescription.vue";
export { default as CardFooter } from "./CardFooter.vue";
export { default as CardHeader } from "./CardHeader.vue";
export { default as CardTitle } from "./CardTitle.vue";
@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
DropdownMenuRoot,
type DropdownMenuRootEmits,
type DropdownMenuRootProps,
useForwardPropsEmits,
} from "reka-ui";
const props = defineProps<DropdownMenuRootProps>();
const emits = defineEmits<DropdownMenuRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuRoot data-slot="dropdown-menu" v-bind="forwarded">
<slot />
</DropdownMenuRoot>
</template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import {
DropdownMenuCheckboxItem,
type DropdownMenuCheckboxItemEmits,
type DropdownMenuCheckboxItemProps,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class="
cn(
`focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)
">
<span
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Check class="size-4" />
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import {
DropdownMenuContent,
type DropdownMenuContentEmits,
type DropdownMenuContentProps,
DropdownMenuPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = withDefaults(
defineProps<
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
>(),
{
sideOffset: 4,
},
);
const emits = defineEmits<DropdownMenuContentEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
props.class,
)
">
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { DropdownMenuGroup, type DropdownMenuGroupProps } from "reka-ui";
const props = defineProps<DropdownMenuGroupProps>();
</script>
<template>
<DropdownMenuGroup data-slot="dropdown-menu-group" v-bind="props">
<slot />
</DropdownMenuGroup>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import {
DropdownMenuItem,
type DropdownMenuItemProps,
useForwardProps,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = withDefaults(
defineProps<
DropdownMenuItemProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
variant?: "default" | "destructive";
}
>(),
{
variant: "default",
},
);
const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="
cn(
`focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)
">
<slot />
</DropdownMenuItem>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import {
DropdownMenuLabel,
type DropdownMenuLabelProps,
useForwardProps,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuLabelProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
}
>();
const delegatedProps = reactiveOmit(props, "class", "inset");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuLabel
data-slot="dropdown-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="
cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)
">
<slot />
</DropdownMenuLabel>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import {
DropdownMenuRadioGroup,
type DropdownMenuRadioGroupEmits,
type DropdownMenuRadioGroupProps,
useForwardPropsEmits,
} from "reka-ui";
const props = defineProps<DropdownMenuRadioGroupProps>();
const emits = defineEmits<DropdownMenuRadioGroupEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuRadioGroup
data-slot="dropdown-menu-radio-group"
v-bind="forwarded">
<slot />
</DropdownMenuRadioGroup>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Circle } from "lucide-vue-next";
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
type DropdownMenuRadioItemEmits,
type DropdownMenuRadioItemProps,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuRadioItemEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="
cn(
`focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)
">
<span
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Circle class="size-2 fill-current" />
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import {
DropdownMenuSeparator,
type DropdownMenuSeparatorProps,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuSeparatorProps & {
class?: HTMLAttributes["class"];
}
>();
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DropdownMenuSeparator
data-slot="dropdown-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)" />
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<span
data-slot="dropdown-menu-shortcut"
:class="
cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)
">
<slot />
</span>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
DropdownMenuSub,
type DropdownMenuSubEmits,
type DropdownMenuSubProps,
useForwardPropsEmits,
} from "reka-ui";
const props = defineProps<DropdownMenuSubProps>();
const emits = defineEmits<DropdownMenuSubEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuSub data-slot="dropdown-menu-sub" v-bind="forwarded">
<slot />
</DropdownMenuSub>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import {
DropdownMenuSubContent,
type DropdownMenuSubContentEmits,
type DropdownMenuSubContentProps,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuSubContentEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
props.class,
)
">
<slot />
</DropdownMenuSubContent>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { ChevronRight } from "lucide-vue-next";
import {
DropdownMenuSubTrigger,
type DropdownMenuSubTriggerProps,
useForwardProps,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuSubTriggerProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
}
>();
const delegatedProps = reactiveOmit(props, "class", "inset");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
props.class,
)
">
<slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
DropdownMenuTrigger,
type DropdownMenuTriggerProps,
useForwardProps,
} from "reka-ui";
const props = defineProps<DropdownMenuTriggerProps>();
const forwardedProps = useForwardProps(props);
</script>
<template>
<DropdownMenuTrigger
data-slot="dropdown-menu-trigger"
v-bind="forwardedProps">
<slot />
</DropdownMenuTrigger>
</template>
@@ -0,0 +1,16 @@
export { default as DropdownMenu } from "./DropdownMenu.vue";
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue";
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue";
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue";
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue";
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue";
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue";
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue";
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue";
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue";
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue";
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue";
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue";
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue";
export { DropdownMenuPortal } from "reka-ui";
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils";
const props = defineProps<{
defaultValue?: string | number;
modelValue?: string | number;
class?: HTMLAttributes["class"];
}>();
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void;
}>();
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
});
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)
" />
</template>
@@ -0,0 +1 @@
export { default as Input } from "./Input.vue";
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Label, type LabelProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
">
<slot />
</Label>
</template>
@@ -0,0 +1 @@
export { default as Label } from "./Label.vue";
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { Loader2 } from "lucide-vue-next";
interface Props {
/**
* Size of the loader
* @default "md"
*/
size?: "sm" | "md" | "lg";
/**
* Message to display below the loader
*/
message?: string;
/**
* Whether the loader is centered
* @default true
*/
centered?: boolean;
/**
* Whether the loader is fullscreen
* @default false
*/
fullScreen?: boolean;
}
// Default props
const props = withDefaults(defineProps<Props>(), {
size: "md",
centered: true,
message: "",
fullScreen: false,
});
// Calculate size class based on props
const sizeClass = computed(() => {
switch (props.size) {
case "sm":
return "h-4 w-4";
case "lg":
return "h-10 w-10";
case "md":
default:
return "h-6 w-6";
}
});
// Calculate container class based on props
const containerClass = computed(() => {
const classes = [];
if (props.centered) {
classes.push("flex flex-col items-center justify-center");
}
if (props.fullScreen) {
classes.push("fixed inset-0 z-50 bg-background/80 backdrop-blur-sm");
} else {
classes.push("py-6");
}
return classes.join(" ");
});
</script>
<template>
<div :class="containerClass">
<Loader2 :class="[sizeClass, 'animate-spin text-primary']" />
<p v-if="message" class="mt-2 text-sm text-muted-foreground">
{{ message }}
</p>
</div>
</template>
@@ -0,0 +1,4 @@
import Loading from "./Loading.vue";
export { Loading };
export default Loading;
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Separator, type SeparatorProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = withDefaults(
defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>(),
{
orientation: "horizontal",
decorative: true,
},
);
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Separator
data-slot="separator-root"
v-bind="delegatedProps"
:class="
cn(
`bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px`,
props.class,
)
" />
</template>
@@ -0,0 +1 @@
export { default as Separator } from "./Separator.vue";
+100
View File
@@ -0,0 +1,100 @@
// Service worker registration and PWA functionality
/**
* Register a service worker for the PWA
* This enables offline functionality, caching, and push notifications
*/
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");
}
return registration;
} catch (error) {
console.error("Registration failed with error:", error);
return null;
}
}
return null;
}
/**
* Request permission for push notifications
* @returns {Promise<string>} The permission status: 'granted', 'denied', or 'default'
*/
export async function requestNotificationPermission() {
if ("Notification" in window) {
const permission = await Notification.requestPermission();
return permission;
}
return "denied";
}
/**
* Show a push notification
* @param {string} title The notification title
* @param {object} options The notification options (body, icon, etc.)
*/
export function showNotification(title, options) {
if ("Notification" in window && Notification.permission === "granted") {
try {
const notification = new Notification(title, options);
return notification;
} catch (error) {
console.error("Error showing notification:", error);
}
}
return null;
}
/**
* Check if the app is running in standalone mode (installed as PWA)
* @returns {boolean} True if the app is installed as a PWA
*/
export function isPWAInstalled() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
window.navigator.standalone || // iOS Safari
document.referrer.includes("android-app://")
);
}
/**
* Show a budget alert notification
* @param {number} percentage The percentage of budget used
* @param {string} category The category name
*/
export function showBudgetAlert(percentage, category) {
const threshold = parseInt(
localStorage.getItem("budgetAlertThreshold") || "80",
);
const notificationsEnabled =
localStorage.getItem("notificationsEnabled") === "true";
if (percentage >= threshold && notificationsEnabled) {
showNotification("Budget Alert", {
body: `You've used ${percentage}% of your budget for ${category || "this month"}`,
icon: "/icons/icon-192px.jpg",
tag: "budget-alert",
renotify: true,
});
}
}
/**
* Check if the app is online
* @returns {boolean} True if the app is online
*/
export function isOnline() {
return navigator.onLine;
}
+86
View File
@@ -0,0 +1,86 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Format a number as currency
* @param value The number to format
* @param currency The currency code, defaults to PLN
* @returns Formatted currency string
*/
export function formatCurrency(
value: number,
currency: string = "PLN",
): string {
return new Intl.NumberFormat("pl-PL", {
style: "currency",
currency,
minimumFractionDigits: 2,
}).format(value);
}
/**
* Format a date string to a readable format
* @param dateString Date string to format
* @param format Format option: 'short', 'medium', 'long'
* @returns Formatted date string
*/
export function formatDate(
dateString: string,
format: "short" | "medium" | "long" = "medium",
): string {
const date = new Date(dateString);
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: format === "short" ? "numeric" : "long",
year: "numeric",
};
if (format === "long") {
options.weekday = "long";
options.hour = "2-digit";
options.minute = "2-digit";
}
return new Intl.DateTimeFormat("pl-PL", options).format(date);
}
/**
* Check if the app is currently online
* @returns Boolean indicating online status
*/
export function isOnline(): boolean {
return navigator.onLine;
}
/**
* Get random color for charts and visualizations
* @returns Hex color code
*/
export function getRandomColor(): string {
const colors = [
"#FF6384",
"#36A2EB",
"#FFCE56",
"#4BC0C0",
"#9966FF",
"#FF9F40",
"#8AC926",
"#1982C4",
"#6A4C93",
"#F94144",
];
return colors[Math.floor(Math.random() * colors.length)];
}
/**
* Generate a unique ID for local storage entries
* @returns Unique ID string
*/
export function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
}
+100 -4
View File
@@ -1,5 +1,101 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createApp } from "vue";
import "../styles/tailwind.css";
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";
createApp(App).mount('#app')
// Register service worker for PWA functionality
if (import.meta.env.PROD) {
registerServiceWorker();
}
const router = createRouter({
// createWebHistory is like BrowserRouter in React Router
history: createWebHistory(),
routes: [
{
path: "/",
// Dynamic imports for code splitting - similar to React.lazy
// In React: const Dashboard = React.lazy(() => import('./pages/Dashboard'))
component: () => import("./pages/Dashboard.vue"),
},
{
path: "/about",
component: () => import("./pages/About.vue"),
},
{
path: "/login",
component: () => import("./pages/Login.vue"),
},
// Budget manager routes
{
path: "/transactions",
component: () => import("./pages/Transactions.vue"),
meta: { requiresAuth: true },
},
{
path: "/add",
component: () => import("./pages/AddTransaction.vue"),
meta: { requiresAuth: true },
},
{
path: "/edit/:id",
component: () => import("./pages/EditTransaction.vue"),
meta: { requiresAuth: true },
},
{
path: "/reports",
component: () => import("./pages/Reports.vue"),
meta: { requiresAuth: true },
},
{
path: "/settings",
component: () => import("./pages/Settings.vue"),
meta: { requiresAuth: true },
},
{
path: "/categories",
component: () => import("./pages/Categories.vue"),
meta: { requiresAuth: true },
},
{
path: "/admin/categories",
component: () => import("./pages/AdminCategories.vue"),
meta: { requiresAuth: true, requiresAdmin: true },
},
],
});
// Import Pinia store
import { createPinia } from "pinia";
// Create Pinia store instance
const pinia = createPinia();
// Router guards
router.beforeEach(async (to, _from, next) => {
// Import auth store dynamically to avoid circular dependency issues
const { useAuthStore } = await import("./stores/auth");
const authStore = useAuthStore();
// Check if route requires authentication
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next("/login");
return;
}
// Check if route requires admin access
if (to.meta.requiresAdmin && authStore.user?.role !== "admin") {
next("/");
return;
}
next();
});
createApp(App) // Create Vue app instance with root component
.use(router) // Add the router to the app (like wrapping with <RouterProvider>)
.use(pinia) // Add Pinia store (like wrapping with Redux Provider)
.mount("#app"); // Mount to DOM element (like root.render())
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import PageLayout from "../components/PageLayout.vue";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
// You can add any component logic here using the script setup syntax
</script>
<template>
<PageLayout header="About">
<div class="max-w-4xl mx-auto space-y-8">
<Card>
<CardHeader class="bg-gray-50">
<h2 class="text-lg leading-6 font-medium text-gray-900">
Budget Manager PWA
</h2>
<p class="mt-1 max-w-2xl text-sm text-gray-500">
A Progressive Web App for tracking your expenses and income with
push notifications
</p>
</CardHeader>
<CardContent>
<h3 class="text-lg font-medium text-gray-900 mb-4">
Our Application
</h3>
<p class="text-gray-700">
Budget Manager PWA is designed to help you manage your finances
effectively by tracking expenses and income. This application works
offline and online, storing your data in both MongoDB and local
storage for seamless experience.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 class="text-lg font-medium text-gray-900">Features</h3>
</CardHeader>
<CardContent>
<ul class="space-y-2 list-disc pl-5">
<li>Track expenses and income with categorization</li>
<li>Budgeting tools to plan your monthly expenses</li>
<li>Generate reports and financial analysis</li>
<li>Works offline with local storage sync</li>
<li>Push notifications for budget alerts and reminders</li>
<li>Secure authentication system</li>
<li>MongoDB backend for data persistence</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 class="text-lg font-medium text-gray-900">Technology Stack</h3>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="font-medium mb-2">Frontend</h4>
<ul class="space-y-1 list-disc pl-5">
<li>Vue.js 3 with Composition API</li>
<li>Tailwind CSS for styling</li>
<li>PWA capabilities for offline use</li>
<li>Service workers for push notifications</li>
</ul>
</div>
<div>
<h4 class="font-medium mb-2">Backend</h4>
<ul class="space-y-1 list-disc pl-5">
<li>Koa.js for REST API endpoints</li>
<li>JWT for secure authentication</li>
<li>MongoDB for data storage</li>
<li>Bun for package management and runtime</li>
</ul>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 class="text-lg font-medium text-gray-900">
Privacy & Data Security
</h3>
</CardHeader>
<CardContent>
<p class="text-gray-700">
Your financial data is important, which is why we've implemented
secure authentication and local storage for offline access. The app
doesn't share your data with third parties, and all sensitive
information is encrypted.
</p>
</CardContent>
</Card>
</div>
</PageLayout>
</template>
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import PageLayout from "@/components/PageLayout.vue";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTransactionsStore } from "@/stores/transactions";
import { useCategoriesStore } from "@/stores/categories";
// Initialize stores and router
const router = useRouter();
const transactionsStore = useTransactionsStore();
const categoriesStore = useCategoriesStore();
// Form data
const transactionType = ref<"income" | "expense">("expense");
const amount = ref<number>(0);
const description = ref<string>("");
const categoryId = ref<string>("");
const date = ref<string>(new Date().toISOString().split("T")[0]);
// Form state
const isSubmitting = ref<boolean>(false);
const errorMessage = ref<string>("");
const successMessage = ref<string>("");
// Load categories on mount
onMounted(async () => {
// Force refresh categories from server to ensure we have latest
await categoriesStore.refreshCategories();
// Set default category if available
if (categoriesStore.categories.length > 0) {
categoryId.value = categoriesStore.categories[0]._id || "";
}
});
// Submit form
const submitTransaction = async () => {
// Validate form
if (amount.value <= 0) {
errorMessage.value = "Amount must be greater than zero";
return;
}
if (!categoryId.value) {
errorMessage.value = "Please select a category";
return;
}
try {
isSubmitting.value = true;
errorMessage.value = "";
successMessage.value = "";
// Create transaction object
const transaction = {
amount: amount.value,
type: transactionType.value,
categoryId: categoryId.value,
description: description.value,
date: new Date(date.value).toISOString(),
};
// Add transaction
await transactionsStore.addTransaction(transaction);
// Check if there was an error during the transaction
if (transactionsStore.error) {
errorMessage.value = `Failed to sync transaction: ${transactionsStore.error}`;
successMessage.value =
"Transaction saved locally and will sync when online.";
} else {
// Show success message
successMessage.value = "Transaction added successfully!";
// Reset form after success
setTimeout(() => {
router.push("/transactions");
}, 1500);
}
} catch (error: any) {
errorMessage.value = `Failed to add transaction: ${error.message}`;
console.error("Error adding transaction:", error);
} finally {
isSubmitting.value = false;
}
};
</script>
<template>
<PageLayout header="Add Transaction">
<div class="max-w-2xl mx-auto">
<Card>
<CardHeader>
<CardTitle>New Transaction</CardTitle>
</CardHeader>
<CardContent>
<!-- Success/Error Messages -->
<div
v-if="successMessage"
class="mb-4 p-3 bg-green-50 border border-green-200 text-green-700 rounded">
{{ successMessage }}
</div>
<div
v-if="errorMessage"
class="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded">
{{ errorMessage }}
</div>
<form @submit.prevent="submitTransaction" class="space-y-4">
<!-- Transaction Type -->
<div class="grid grid-cols-2 gap-4">
<Button
type="button"
:variant="transactionType === 'expense' ? 'default' : 'outline'"
@click="transactionType = 'expense'"
class="w-full"
:disabled="isSubmitting">
Expense
</Button>
<Button
type="button"
:variant="transactionType === 'income' ? 'default' : 'outline'"
@click="transactionType = 'income'"
class="w-full"
:disabled="isSubmitting">
Income
</Button>
</div>
<!-- Amount -->
<div class="space-y-2">
<Label for="amount">Amount</Label>
<Input
id="amount"
v-model.number="amount"
type="number"
min="0"
step="0.01"
placeholder="0.00"
required
:disabled="isSubmitting" />
</div>
<!-- Date -->
<div class="space-y-2">
<Label for="date">Date</Label>
<Input
id="date"
v-model="date"
type="date"
required
:disabled="isSubmitting" />
</div>
<!-- Category -->
<div class="space-y-2">
<Label for="category">Category</Label>
<select
id="category"
v-model="categoryId"
class="w-full rounded-md border border-gray-300 px-3 py-2"
required
:disabled="isSubmitting || categoriesStore.loading">
<option
v-if="categoriesStore.loading"
disabled
value="">
Loading categories...
</option>
<option
v-else-if="categoriesStore.categories.length === 0"
disabled
value="">
No categories available
</option>
<option
v-for="category in categoriesStore.categories"
:key="category._id"
:value="category._id">
{{ category.name }} {{ category.icon ? category.icon : '' }}
</option>
</select>
<div v-if="categoriesStore.categories.length === 0 && !categoriesStore.loading"
class="mt-2 text-sm text-red-500">
Please add categories in the Categories section first
</div>
</div>
<!-- Description -->
<div class="space-y-2">
<Label for="description">Description (optional)</Label>
<Input
id="description"
v-model="description"
placeholder="Enter description"
:disabled="isSubmitting" />
</div>
</form>
</CardContent>
<CardFooter class="flex justify-between">
<Button
variant="outline"
@click="router.push('/transactions')"
:disabled="isSubmitting">
Cancel
</Button>
<Button @click="submitTransaction" :disabled="isSubmitting">
{{ isSubmitting ? "Saving..." : "Save Transaction" }}
</Button>
</CardFooter>
</Card>
</div>
</PageLayout>
</template>
+444
View File
@@ -0,0 +1,444 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useRouter } from "vue-router";
import PageLayout from "@/components/PageLayout.vue";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCategoriesStore } from "@/stores/categories";
import { useAuthStore } from "@/stores/auth";
// Initialize stores and router
const router = useRouter();
const categoriesStore = useCategoriesStore();
const authStore = useAuthStore();
// Check if user is admin
const isAdmin = computed(() => authStore.user?.role === "admin");
// State
const allCategories = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const success = ref("");
// Form state for creating/editing categories
const showForm = ref(false);
const editingCategory = ref<any>(null);
const formData = ref({
name: "",
icon: "",
color: "#3B82F6",
});
// Available icons
const availableIcons = [
"🍔",
"🚗",
"🏠",
"🎬",
"💰",
"📦",
"🛒",
"💡",
"🎯",
"📱",
"✈️",
"🏥",
"📚",
"🎵",
"👕",
"⚽",
"🍕",
"☕",
"🎮",
"💊",
];
// Available colors
const availableColors = [
"#3B82F6",
"#EF4444",
"#10B981",
"#F59E0B",
"#8B5CF6",
"#EC4899",
"#06B6D4",
"#84CC16",
"#F97316",
"#6366F1",
];
// Load all categories
async function loadCategories() {
if (!isAdmin.value) {
error.value = "Admin access required";
return;
}
loading.value = true;
error.value = "";
try {
const categories = await categoriesStore.fetchAllCategories();
allCategories.value = categories || [];
} catch (err: any) {
error.value = err.message;
} finally {
loading.value = false;
}
}
// Create or update category
async function saveCategory() {
if (!formData.value.name.trim()) {
error.value = "Category name is required";
return;
}
loading.value = true;
error.value = "";
success.value = "";
try {
if (editingCategory.value) {
// Update existing category
await categoriesStore.updateCategory(editingCategory.value._id, {
name: formData.value.name,
icon: formData.value.icon,
color: formData.value.color,
});
success.value = "Category updated successfully";
} else {
// Create new category
await categoriesStore.createGlobalCategory({
name: formData.value.name,
icon: formData.value.icon,
color: formData.value.color,
});
success.value = "Category created successfully";
}
resetForm();
await loadCategories();
} catch (err: any) {
error.value = err.message;
} finally {
loading.value = false;
}
}
// Delete category
async function deleteCategory(categoryId: string) {
if (
!confirm(
"Are you sure you want to delete this category? This action cannot be undone.",
)
) {
return;
}
loading.value = true;
error.value = "";
success.value = "";
try {
await categoriesStore.deleteCategory(categoryId);
success.value = "Category deleted successfully";
await loadCategories();
} catch (err: any) {
error.value = err.message;
} finally {
loading.value = false;
}
}
// Edit category
function editCategory(category: any) {
editingCategory.value = category;
formData.value = {
name: category.name,
icon: category.icon || "",
color: category.color || "#3B82F6",
};
showForm.value = true;
}
// Reset form
function resetForm() {
editingCategory.value = null;
formData.value = {
name: "",
icon: "",
color: "#3B82F6",
};
showForm.value = false;
}
// Clear messages
function clearMessages() {
error.value = "";
success.value = "";
}
// Load categories on mount
onMounted(() => {
if (!isAdmin.value) {
router.push("/dashboard");
return;
}
loadCategories();
});
</script>
<template>
<PageLayout header="Category Management (Admin)">
<div class="max-w-6xl mx-auto space-y-6">
<!-- Access Control -->
<div v-if="!isAdmin" class="text-center py-8">
<Card>
<CardContent class="pt-6">
<h2 class="text-xl font-bold text-red-600 mb-2">Access Denied</h2>
<p class="text-gray-600">
You need admin privileges to access this page.
</p>
<Button @click="router.push('/dashboard')" class="mt-4">
Back to Dashboard
</Button>
</CardContent>
</Card>
</div>
<!-- Admin Interface -->
<div v-else>
<!-- Messages -->
<div
v-if="error"
class="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded">
{{ error }}
<button
@click="clearMessages"
class="ml-2 text-red-500 hover:text-red-700">
×
</button>
</div>
<div
v-if="success"
class="mb-4 p-3 bg-green-50 border border-green-200 text-green-700 rounded">
{{ success }}
<button
@click="clearMessages"
class="ml-2 text-green-500 hover:text-green-700">
×
</button>
</div>
<!-- Action Buttons -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">Category Management</h1>
<div class="space-x-2">
<Button
@click="loadCategories"
variant="outline"
:disabled="loading">
{{ loading ? "Loading..." : "Refresh" }}
</Button>
<Button @click="showForm = true" :disabled="loading">
Add New Category
</Button>
</div>
</div>
<!-- Create/Edit Form -->
<Card v-if="showForm" class="mb-6">
<CardHeader>
<CardTitle>
{{ editingCategory ? "Edit Category" : "Create New Category" }}
</CardTitle>
</CardHeader>
<CardContent>
<form @submit.prevent="saveCategory" class="space-y-4">
<!-- Category Name -->
<div class="space-y-2">
<Label for="name">Category Name</Label>
<Input
id="name"
v-model="formData.name"
placeholder="Enter category name"
required
:disabled="loading" />
</div>
<!-- Icon Selection -->
<div class="space-y-2">
<Label>Icon</Label>
<div class="grid grid-cols-10 gap-2">
<button
v-for="icon in availableIcons"
:key="icon"
type="button"
@click="formData.icon = icon"
:class="[
'p-2 text-xl border rounded hover:bg-gray-100',
formData.icon === icon
? 'border-blue-500 bg-blue-50'
: 'border-gray-300',
]"
:disabled="loading">
{{ icon }}
</button>
</div>
<Input
v-model="formData.icon"
placeholder="Or enter custom emoji"
class="mt-2" />
</div>
<!-- Color Selection -->
<div class="space-y-2">
<Label>Color</Label>
<div class="grid grid-cols-10 gap-2">
<button
v-for="color in availableColors"
:key="color"
type="button"
@click="formData.color = color"
:class="[
'w-8 h-8 rounded border-2',
formData.color === color
? 'border-gray-800'
: 'border-gray-300',
]"
:style="{ backgroundColor: color }"
:disabled="loading"></button>
</div>
<Input
v-model="formData.color"
type="color"
class="mt-2 w-full h-10" />
</div>
<!-- Form Actions -->
<div class="flex justify-end space-x-2 pt-4">
<Button
type="button"
variant="outline"
@click="resetForm"
:disabled="loading">
Cancel
</Button>
<Button type="submit" :disabled="loading">
{{
loading
? "Saving..."
: editingCategory
? "Update"
: "Create"
}}
</Button>
</div>
</form>
</CardContent>
</Card>
<!-- Categories List -->
<Card>
<CardHeader>
<CardTitle>All Categories</CardTitle>
</CardHeader>
<CardContent>
<div
v-if="loading && allCategories.length === 0"
class="text-center py-8">
<p class="text-gray-500">Loading categories...</p>
</div>
<div
v-else-if="allCategories.length === 0"
class="text-center py-8">
<p class="text-gray-500">No categories found.</p>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b">
<th class="text-left py-2">Icon</th>
<th class="text-left py-2">Name</th>
<th class="text-left py-2">Color</th>
<th class="text-left py-2">Owner</th>
<th class="text-left py-2">Created</th>
<th class="text-left py-2">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="category in allCategories"
:key="category._id"
class="border-b hover:bg-gray-50">
<td class="py-3">
<span class="text-2xl">{{ category.icon || "📦" }}</span>
</td>
<td class="py-3 font-medium">{{ category.name }}</td>
<td class="py-3">
<div class="flex items-center space-x-2">
<div
class="w-6 h-6 rounded border"
:style="{
backgroundColor: category.color || '#3B82F6',
}"></div>
<span class="text-sm text-gray-600">{{
category.color || "#3B82F6"
}}</span>
</div>
</td>
<td class="py-3">
<span
v-if="category.userId"
class="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
User
</span>
<span
v-else
class="text-sm bg-green-100 text-green-800 px-2 py-1 rounded">
Global
</span>
</td>
<td class="py-3 text-sm text-gray-600">
{{ new Date(category.createdAt).toLocaleDateString() }}
</td>
<td class="py-3">
<div class="flex space-x-2">
<Button
size="sm"
variant="outline"
@click="editCategory(category)"
:disabled="loading">
Edit
</Button>
<Button
size="sm"
variant="destructive"
@click="deleteCategory(category._id)"
:disabled="loading">
Delete
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</div>
</PageLayout>
</template>
+567
View File
@@ -0,0 +1,567 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import PageLayout from "@/components/PageLayout.vue";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCategoriesStore } from "@/stores/categories";
import { Plus, Save, Trash2, Edit, X, Loader2 } from "lucide-vue-next";
// Initialize store
const categoriesStore = useCategoriesStore();
// Local state
const categories = ref<any[]>([]);
const newCategory = ref({ name: "", icon: "", color: "#3b82f6" });
const isAddingNew = ref(false);
const isEditing = ref<string | null>(null);
const editedCategory = ref({ name: "", icon: "", color: "" });
const isLoading = ref(true);
const errorMessage = ref("");
// Standard colors for categories
const standardColors = [
"#ef4444", // red
"#f97316", // orange
"#f59e0b", // amber
"#eab308", // yellow
"#84cc16", // lime
"#10b981", // emerald
"#06b6d4", // cyan
"#3b82f6", // blue
"#6366f1", // indigo
"#8b5cf6", // violet
"#a855f7", // purple
"#d946ef", // fuchsia
"#ec4899", // pink
"#6b7280", // gray
"#000000", // black
];
// Custom color input
const isUsingCustomColor = ref(false);
const customColorInput = ref("");
// Standard icons for categories (emoji)
const standardIcons = [
"🍔",
"🚗",
"🏠",
"🎬",
"💰",
"📱",
"👕",
"💊",
"📚",
"✈️",
"🎁",
"🔧",
"💻",
"🏋️",
"🎮",
];
// Load categories on component mount
onMounted(async () => {
isLoading.value = true;
errorMessage.value = "";
try {
// Force refresh categories from server to ensure we have the latest
await categoriesStore.refreshCategories();
categories.value = [...categoriesStore.categories];
// Initialize new category defaults
newCategory.value = {
name: "",
icon: standardIcons[0],
color: standardColors[0]
};
} catch (error) {
console.error("Error loading categories:", error);
errorMessage.value = "Failed to load categories";
} finally {
isLoading.value = false;
}
});
// Start adding a new category
const startAddNew = () => {
newCategory.value = { name: "", icon: "📦", color: "#3b82f6" };
isAddingNew.value = true;
isEditing.value = null;
};
// Cancel adding or editing
const cancelAction = () => {
isAddingNew.value = false;
isEditing.value = null;
};
// Add a new category
const addCategory = async () => {
// Reset error first
errorMessage.value = "";
// Validate name
if (!newCategory.value.name.trim()) {
errorMessage.value = "Category name cannot be empty";
return;
}
// Check if name already exists
const nameExists = categories.value.some(
c => c.name.toLowerCase() === newCategory.value.name.toLowerCase()
);
if (nameExists) {
errorMessage.value = "A category with this name already exists";
return;
}
try {
await categoriesStore.addCategory({
name: newCategory.value.name,
icon: newCategory.value.icon,
color: newCategory.value.color,
});
categories.value = [...categoriesStore.categories];
isAddingNew.value = false;
newCategory.value = { name: "", icon: "", color: "#3b82f6" };
errorMessage.value = "";
} catch (error) {
console.error("Error adding category:", error);
errorMessage.value = "Failed to add category";
}
};
// Start editing a category
const startEdit = (category: any) => {
isEditing.value = category._id;
editedCategory.value = {
name: category.name,
icon: category.icon || "",
color: category.color || "#3b82f6",
};
isAddingNew.value = false;
};
// Update a category
const updateCategory = async (categoryId: string) => {
if (!editedCategory.value.name.trim()) {
errorMessage.value = "Category name cannot be empty";
return;
}
try {
await categoriesStore.updateCategory({
_id: categoryId,
name: editedCategory.value.name,
icon: editedCategory.value.icon,
color: editedCategory.value.color,
});
categories.value = [...categoriesStore.categories];
isEditing.value = null;
errorMessage.value = "";
} catch (error) {
console.error("Error updating category:", error);
errorMessage.value = "Failed to update category";
}
};
// Delete a category
const deleteCategory = async (categoryId: string) => {
if (confirm("Are you sure you want to delete this category?")) {
try {
await categoriesStore.deleteCategory(categoryId);
categories.value = [...categoriesStore.categories];
isEditing.value = null;
errorMessage.value = "";
} catch (error) {
console.error("Error deleting category:", error);
errorMessage.value = "Failed to delete category";
}
}
};
</script>
<template>
<PageLayout header="Categories">
<div class="max-w-4xl mx-auto">
<Card>
<CardHeader class="flex flex-row items-center justify-between">
<div>
<CardTitle class="text-xl">Manage Categories</CardTitle>
<p class="text-sm text-gray-500 mt-1">
Create and organize categories to track your expenses and income
</p>
</div>
<Button
variant="default"
size="sm"
@click="startAddNew"
v-if="!isAddingNew"
class="flex items-center shadow-sm hover:shadow transition-all">
<Plus class="mr-1 h-4 w-4" />
Add Category
</Button>
</CardHeader>
<CardContent>
<!-- Error message -->
<div
v-if="errorMessage"
class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4 text-sm">
{{ errorMessage }}
</div>
<!-- Loading state -->
<div v-if="isLoading" class="text-center py-8">
<Loader2 class="h-8 w-8 mx-auto animate-spin text-primary" />
<p class="mt-2 text-gray-500">Loading categories...</p>
</div>
<!-- No categories message -->
<div
v-else-if="categories.length === 0 && !isAddingNew"
class="text-center py-12 px-4 border border-dashed rounded-lg bg-gray-50">
<div class="flex flex-col items-center max-w-md mx-auto">
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<Plus class="h-8 w-8 text-primary" />
</div>
<h3 class="text-lg font-medium text-gray-900">No Categories Yet</h3>
<p class="text-gray-500 mt-2 text-center">
Categories help you organize and track your transactions. Create your first category to get started.
</p>
<Button class="mt-6" size="lg" @click="startAddNew">
<Plus class="mr-2 h-5 w-5" />
Add Your First Category
</Button>
</div>
</div>
<!-- Quick add form (shown when there are existing categories but not currently adding) -->
<div
v-if="!isAddingNew && categories.length > 0 && !isEditing"
class="border border-dashed rounded-lg p-4 mb-6 hover:border-primary/30 hover:bg-primary/5 transition-colors">
<div class="flex items-center gap-3">
<Input
v-model="newCategory.name"
placeholder="Quick add a new category..."
class="flex-grow"
@keyup.enter="addCategory" />
<Button
variant="outline"
@click="startAddNew"
title="Advanced options">
<Edit class="h-4 w-4" />
</Button>
<Button
@click="addCategory"
:disabled="!newCategory.name.trim()"
title="Add category">
<Plus class="h-4 w-4" />
</Button>
</div>
</div>
<!-- Category editor form - Add new -->
<div v-if="isAddingNew" class="border rounded-lg p-6 mb-6 bg-gray-50 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium">Add New Category</h3>
<button @click="cancelAction" class="text-gray-500 hover:text-gray-700">
<X class="h-5 w-5" />
</button>
</div>
<div class="space-y-6">
<!-- Preview -->
<div class="bg-white rounded-lg p-4 border flex items-center justify-center">
<div class="flex flex-col items-center">
<div
class="w-16 h-16 rounded-full flex items-center justify-center text-2xl mb-2 text-white"
:style="{ backgroundColor: newCategory.color || '#3b82f6' }">
{{ newCategory.icon || "📦" }}
</div>
<span class="text-lg font-medium">{{ newCategory.name || "New Category" }}</span>
<span class="text-xs text-gray-500 mt-1">Preview</span>
</div>
</div>
<div>
<Label for="new-name" class="text-sm font-medium">Category Name</Label>
<Input
id="new-name"
v-model="newCategory.name"
placeholder="e.g., Groceries, Transportation, Entertainment"
class="mt-1" />
<p class="text-xs text-gray-500 mt-1">Choose a descriptive name for your category</p>
</div>
<div>
<Label class="text-sm font-medium">Icon</Label>
<div class="mt-2 grid grid-cols-5 sm:grid-cols-8 gap-2">
<button
v-for="icon in standardIcons"
:key="icon"
@click="newCategory.icon = icon"
class="w-10 h-10 flex items-center justify-center rounded-md border text-lg transition-all hover:bg-gray-100"
:class="{
'bg-primary/10 ring-2 ring-primary border-primary':
newCategory.icon === icon,
}">
{{ icon }}
</button>
</div>
<p class="text-xs text-gray-500 mt-1">Select an icon that represents this category</p>
</div>
<div>
<Label class="text-sm font-medium">Color</Label>
<div class="mt-2 flex flex-wrap gap-3">
<button
v-for="color in standardColors"
:key="color"
@click="() => {
newCategory.color = color;
isUsingCustomColor = false;
}"
class="w-10 h-10 rounded-full border hover:ring-2 hover:ring-offset-2 transition-all"
:style="{ backgroundColor: color }"
:class="{
'ring-2 ring-offset-2 scale-110': newCategory.color === color && !isUsingCustomColor,
}"></button>
<!-- Custom color option -->
<button
@click="isUsingCustomColor = true"
class="w-10 h-10 rounded-full border flex items-center justify-center hover:ring-2 hover:ring-offset-2 transition-all bg-white"
:class="{
'ring-2 ring-offset-2': isUsingCustomColor,
}">
<span class="text-xs font-medium">+</span>
</button>
</div>
<!-- Custom color input -->
<div v-if="isUsingCustomColor" class="mt-2">
<div class="flex gap-2 items-center">
<Input
v-model="customColorInput"
placeholder="#RRGGBB"
class="w-32"
maxlength="7"
@input="() => {
if(/^#[0-9A-Fa-f]{6}$/.test(customColorInput)) {
newCategory.color = customColorInput;
}
}" />
<div
class="w-8 h-8 rounded-full border"
:style="{ backgroundColor: /^#[0-9A-Fa-f]{6}$/.test(customColorInput) ? customColorInput : '#CCCCCC' }">
</div>
<button
@click="() => {
if(/^#[0-9A-Fa-f]{6}$/.test(customColorInput)) {
newCategory.color = customColorInput;
} else {
customColorInput = newCategory.color;
}
}"
class="text-xs text-blue-600 hover:text-blue-800">
Apply
</button>
</div>
<p class="text-xs text-gray-500 mt-1">Enter a color in hex format (e.g., #FF5500)</p>
</div>
<p class="text-xs text-gray-500 mt-1">Choose a color to easily identify this category</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<Button variant="outline" @click="cancelAction">
Cancel
</Button>
<Button
@click="addCategory"
:disabled="!newCategory.name.trim()"
class="min-w-28">
<Save class="mr-2 h-4 w-4" />
Save Category
</Button>
</div>
</div>
</div>
<!-- Categories list -->
<div
v-if="!isLoading && (categories.length > 0 || isAddingNew)"
class="space-y-3">
<h3 class="text-sm font-medium text-gray-500 mt-2 mb-1">Your Categories</h3>
<div
v-for="category in categories"
:key="category._id"
class="border rounded-lg p-4 transition-all hover:bg-gray-50 group">
<!-- View mode -->
<div
v-if="isEditing !== category._id"
class="flex items-center justify-between">
<div class="flex items-center">
<div
class="w-10 h-10 rounded-full flex items-center justify-center mr-3 text-white text-lg shadow-sm"
:style="{ backgroundColor: category.color || '#3b82f6' }">
{{ category.icon || "📦" }}
</div>
<div>
<span class="font-medium text-gray-900">{{ category.name }}</span>
<p class="text-xs text-gray-500">{{ category._id?.startsWith('default-') ? 'Default' : 'Custom' }} category</p>
</div>
</div>
<div class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
@click="startEdit(category)"
class="p-2 text-gray-500 hover:text-blue-500 hover:bg-blue-50 rounded-full transition-colors"
title="Edit category">
<Edit class="h-4 w-4" />
</button>
<button
@click="deleteCategory(category._id)"
class="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
title="Delete category">
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
<!-- Edit mode -->
<div v-else class="space-y-5 bg-gray-50 p-3 rounded-md -m-2">
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium">Edit Category</h4>
<button @click="cancelAction" class="text-gray-500 hover:text-gray-700">
<X class="h-4 w-4" />
</button>
</div>
<!-- Preview -->
<div class="bg-white rounded-lg p-3 border flex items-center justify-center">
<div class="flex flex-col items-center">
<div
class="w-12 h-12 rounded-full flex items-center justify-center text-xl mb-1 text-white"
:style="{ backgroundColor: editedCategory.color || '#3b82f6' }">
{{ editedCategory.icon || "📦" }}
</div>
<span class="text-sm font-medium">{{ editedCategory.name || "Category Name" }}</span>
</div>
</div>
<div>
<Label :for="`edit-name-${category._id}`" class="text-sm font-medium">
Category Name
</Label>
<Input
:id="`edit-name-${category._id}`"
v-model="editedCategory.name"
class="mt-1" />
</div>
<div>
<Label class="text-sm font-medium">Icon</Label>
<div class="mt-1 grid grid-cols-5 sm:grid-cols-7 gap-1">
<button
v-for="icon in standardIcons"
:key="icon"
@click="editedCategory.icon = icon"
class="w-9 h-9 flex items-center justify-center rounded border text-lg hover:bg-gray-100 transition-all"
:class="{
'bg-primary/10 ring-1 ring-primary border-primary':
editedCategory.icon === icon,
}">
{{ icon }}
</button>
</div>
</div>
<div>
<Label class="text-sm font-medium">Color</Label>
<div class="mt-1 flex flex-wrap gap-2">
<button
v-for="color in standardColors"
:key="color"
@click="() => {
editedCategory.color = color;
isUsingCustomColor = false;
}"
class="w-9 h-9 rounded-full border hover:ring-1 hover:ring-offset-1 transition-all"
:style="{ backgroundColor: color }"
:class="{
'ring-2 ring-offset-1 scale-110': editedCategory.color === color && !isUsingCustomColor,
}">
</button>
<!-- Custom color option -->
<button
@click="isUsingCustomColor = true"
class="w-9 h-9 rounded-full border flex items-center justify-center hover:ring-1 hover:ring-offset-1 transition-all bg-white"
:class="{
'ring-2 ring-offset-1': isUsingCustomColor,
}">
<span class="text-xs font-medium">+</span>
</button>
</div>
<!-- Custom color input -->
<div v-if="isUsingCustomColor" class="mt-2">
<div class="flex gap-2 items-center">
<Input
v-model="customColorInput"
placeholder="#RRGGBB"
class="w-32"
maxlength="7"
@input="() => {
if(/^#[0-9A-Fa-f]{6}$/.test(customColorInput)) {
editedCategory.color = customColorInput;
}
}" />
<div
class="w-6 h-6 rounded-full border"
:style="{ backgroundColor: /^#[0-9A-Fa-f]{6}$/.test(customColorInput) ? customColorInput : '#CCCCCC' }">
</div>
<button
@click="() => {
if(/^#[0-9A-Fa-f]{6}$/.test(customColorInput)) {
editedCategory.color = customColorInput;
} else {
customColorInput = editedCategory.color;
}
}"
class="text-xs text-blue-600 hover:text-blue-800">
Apply
</button>
</div>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" @click="cancelAction">
Cancel
</Button>
<Button size="sm" @click="updateCategory(category._id)" :disabled="!editedCategory.name.trim()">
<Save class="mr-1 h-4 w-4" />
Save Changes
</Button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
</template>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
import PageLayout from "../components/PageLayout.vue";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useTransactionsStore } from "@/stores/transactions";
import { useCategoriesStore } from "@/stores/categories";
import { useAuthStore } from "@/stores/auth";
import { formatCurrency } from "@/lib/utils";
import BalanceSummary from "@/components/BalanceSummary.vue";
// Initialize stores
const transactionsStore = useTransactionsStore();
const categoriesStore = useCategoriesStore();
const authStore = useAuthStore();
// Recent transactions
const recentTransactions = computed(() => {
return transactionsStore.transactions
.slice()
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 5);
});
// Get category name by id
const getCategoryName = (categoryId) => {
const category = categoriesStore.categories.find(
(c) => c._id === categoryId,
);
return category ? category.name : "Unknown";
};
onMounted(async () => {
// Fetch transactions and categories
await Promise.all([
transactionsStore.fetchTransactions(),
categoriesStore.fetchCategories(),
]);
});
</script>
<template>
<PageLayout header="Dashboard">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Balance Summary -->
<div class="lg:col-span-2">
<BalanceSummary />
</div>
<!-- Quick Actions -->
<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>
<router-link to="/categories" class="w-full block">
<Button class="w-full" variant="outline">Manage Categories</Button>
</router-link>
<router-link to="/settings" class="w-full block">
<Button class="w-full" variant="outline">Budget Settings</Button>
</router-link>
</CardContent>
</Card>
<!-- Recent Transactions -->
<Card class="lg:col-span-3">
<CardHeader>
<CardTitle>Recent Transactions</CardTitle>
</CardHeader>
<CardContent>
<div v-if="!authStore.isAuthenticated" class="text-center py-8">
<p class="text-gray-500">Please log in to view your transactions</p>
<router-link to="/login">
<Button class="mt-4">Log In</Button>
</router-link>
</div>
<div
v-else-if="recentTransactions.length === 0"
class="text-center py-8">
<p class="text-gray-500">No transactions yet</p>
<router-link to="/add">
<Button class="mt-4">Add Your First Transaction</Button>
</router-link>
</div>
<div v-else>
<div class="divide-y">
<div
v-for="transaction in recentTransactions"
:key="transaction._id"
class="py-3">
<div class="flex justify-between items-center">
<div>
<p class="font-medium">
{{
transaction.description ||
getCategoryName(transaction.categoryId)
}}
</p>
<p class="text-sm text-gray-500">
{{ new Date(transaction.date).toLocaleDateString() }}
</p>
</div>
<div
:class="
transaction.type === 'expense'
? 'text-red-500'
: 'text-green-500'
"
class="font-bold">
{{ transaction.type === "expense" ? "-" : "+"
}}{{ formatCurrency(transaction.amount) }}
</div>
</div>
</div>
</div>
<div class="mt-4 text-center">
<router-link to="/transactions">
<Button variant="ghost" size="sm">View All Transactions</Button>
</router-link>
</div>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
</template>
+284
View File
@@ -0,0 +1,284 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import PageLayout from "@/components/PageLayout.vue";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTransactionsStore } from "@/stores/transactions";
import { useCategoriesStore } from "@/stores/categories";
import { Save, Loader2, ArrowLeft } from "lucide-vue-next";
// Initialize stores and router
const router = useRouter();
const route = useRoute();
const transactionsStore = useTransactionsStore();
const categoriesStore = useCategoriesStore();
// Get transaction ID from route params
const transactionId = route.params.id as string;
// Form data
const transactionType = ref<"income" | "expense">("expense");
const amount = ref<number>(0);
const description = ref<string>("");
const categoryId = ref<string>("");
const date = ref<string>(new Date().toISOString().split("T")[0]);
const originalTransaction = ref<any>(null);
// Form state
const isSubmitting = ref<boolean>(false);
const errorMessage = ref<string>("");
const successMessage = ref<string>("");
const isLoading = ref<boolean>(true);
// Filter categories based on transaction type
const filteredCategories = computed(() => {
return categoriesStore.categories;
});
// Load transaction data and categories on mount
onMounted(async () => {
isLoading.value = true;
try {
// Fetch categories if not already loaded
if (categoriesStore.categories.length === 0) {
await categoriesStore.fetchCategories();
}
// Fetch transactions if not already loaded
if (transactionsStore.transactions.length === 0) {
await transactionsStore.fetchTransactions();
}
// Find the transaction by ID
const transaction = transactionsStore.transactions.find(
(t) => t._id === transactionId,
);
if (!transaction) {
errorMessage.value = "Transaction not found";
return;
}
// Populate form with transaction data
originalTransaction.value = { ...transaction };
transactionType.value = transaction.type;
amount.value = transaction.amount;
description.value = transaction.description || "";
categoryId.value = transaction.categoryId;
date.value = new Date(transaction.date).toISOString().split("T")[0];
} catch (error) {
console.error("Error loading transaction:", error);
errorMessage.value = "Failed to load transaction data";
} finally {
isLoading.value = false;
}
});
// Update transaction
const updateTransaction = async () => {
if (!amount.value) {
errorMessage.value = "Please enter an amount";
return;
}
if (!categoryId.value) {
errorMessage.value = "Please select a category";
return;
}
if (!date.value) {
errorMessage.value = "Please enter a date";
return;
}
isSubmitting.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
const updatedTransaction = {
_id: transactionId,
amount: amount.value,
type: transactionType.value,
categoryId: categoryId.value,
description: description.value,
date: date.value,
};
const success =
await transactionsStore.updateTransaction(updatedTransaction);
if (success) {
successMessage.value = "Transaction updated successfully";
setTimeout(() => {
router.push("/transactions");
}, 1500);
} else {
errorMessage.value = "Failed to update transaction";
}
} catch (error) {
console.error("Error updating transaction:", error);
errorMessage.value = "An unexpected error occurred";
} finally {
isSubmitting.value = false;
}
};
// Cancel and go back
const cancelEdit = () => {
router.go(-1);
};
</script>
<template>
<PageLayout header="Edit Transaction">
<div class="max-w-md mx-auto">
<Card>
<CardHeader>
<CardTitle class="text-lg">Edit Transaction</CardTitle>
</CardHeader>
<CardContent>
<div v-if="isLoading" class="py-8 text-center">
<Loader2 class="h-8 w-8 mx-auto animate-spin text-primary" />
<p class="mt-2 text-gray-500">Loading transaction data...</p>
</div>
<div
v-else-if="errorMessage && !originalTransaction"
class="py-8 text-center">
<p class="text-red-500">{{ errorMessage }}</p>
<Button
variant="outline"
class="mt-4"
@click="router.push('/transactions')">
<ArrowLeft class="mr-2 h-4 w-4" />
Back to Transactions
</Button>
</div>
<form v-else @submit.prevent="updateTransaction" class="space-y-4">
<!-- Success message -->
<div
v-if="successMessage"
class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded text-sm">
{{ successMessage }}
</div>
<!-- Error message -->
<div
v-if="errorMessage"
class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded text-sm">
{{ errorMessage }}
</div>
<!-- Transaction type -->
<div class="space-y-2">
<Label>Transaction Type</Label>
<div class="flex gap-4">
<div class="flex items-center">
<input
type="radio"
id="expense"
value="expense"
v-model="transactionType"
name="transactionType"
class="h-4 w-4 text-primary border-gray-300 mr-2" />
<Label for="expense" class="text-sm cursor-pointer"
>Expense</Label
>
</div>
<div class="flex items-center">
<input
type="radio"
id="income"
value="income"
v-model="transactionType"
name="transactionType"
class="h-4 w-4 text-primary border-gray-300 mr-2" />
<Label for="income" class="text-sm cursor-pointer"
>Income</Label
>
</div>
</div>
</div>
<!-- Amount -->
<div class="space-y-2">
<Label for="amount">Amount</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0.01"
v-model="amount"
required />
</div>
<!-- Category -->
<div class="space-y-2">
<Label for="category">Category</Label>
<select
id="category"
v-model="categoryId"
required
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-input">
<option value="" disabled>Select a category</option>
<option
v-for="category in filteredCategories"
:key="category._id"
:value="category._id">
{{ category.name }}
</option>
</select>
</div>
<!-- Description -->
<div class="space-y-2">
<Label for="description">Description (optional)</Label>
<Input
id="description"
type="text"
v-model="description"
placeholder="Enter a description" />
</div>
<!-- Date -->
<div class="space-y-2">
<Label for="date">Date</Label>
<Input id="date" type="date" v-model="date" required />
</div>
</form>
</CardContent>
<CardFooter class="flex justify-between">
<Button
variant="outline"
@click="cancelEdit"
:disabled="isSubmitting">
Cancel
</Button>
<Button
variant="default"
@click="updateTransaction"
:disabled="isSubmitting"
class="ml-2">
<Loader2 v-if="isSubmitting" class="mr-2 h-4 w-4 animate-spin" />
<Save v-else class="mr-2 h-4 w-4" />
Save Changes
</Button>
</CardFooter>
</Card>
</div>
</PageLayout>
</template>
+196
View File
@@ -0,0 +1,196 @@
<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";
import { Label } from "@/components/ui/label";
import { Info } from "lucide-vue-next"; // Icon library (like react-icons)
import PageLayout from "../components/PageLayout.vue"; // Layout component (like a layout in Next.js)
import { ref } from "vue"; // ref is like React's useState
import { useAuthStore } from "@/stores/auth";
import { useRouter } from "vue-router";
const router = useRouter();
const authStore = useAuthStore();
// Form state
const username = ref("");
const password = ref("");
const isLoading = ref(false);
const errorMessage = ref("");
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";
return;
}
isLoading.value = true;
errorMessage.value = "";
try {
const success = await authStore.login(username.value, password.value);
if (success) {
router.push("/");
} else {
errorMessage.value = authStore.error || "Login failed";
}
} catch (err) {
errorMessage.value = "An unexpected error occurred";
console.error(err);
} finally {
isLoading.value = false;
}
};
/**
* Handle registration form submission
*/
const register = async () => {
if (!username.value || !password.value) {
errorMessage.value = "Please enter both username and password";
return;
}
isLoading.value = true;
errorMessage.value = "";
try {
const success = await authStore.register(
username.value,
password.value,
email.value,
);
if (success) {
isRegistering.value = false;
errorMessage.value = "Registration successful! You can now log in.";
} else {
errorMessage.value = authStore.error || "Registration failed";
}
} catch (err) {
errorMessage.value = "An unexpected error occurred";
console.error(err);
} finally {
isLoading.value = false;
}
};
/**
* 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">
<div class="w-full max-w-md">
<!--
Component composition works similarly to React
These are shadcn components, equivalent to React's shadcn/ui
-->
<Card>
<CardHeader>
<h2 class="text-lg font-semibold">
{{ isRegistering ? "Create an Account" : "Account Login" }}
</h2>
</CardHeader>
<CardContent>
<!-- Show error message if any -->
<div
v-if="errorMessage"
class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4 text-sm">
{{ errorMessage }}
</div>
<form
@submit.prevent="isRegistering ? register() : login()"
method="post"
class="space-y-4">
<div class="space-y-2">
<Label for="username" class="text-sm font-medium"
>Username</Label
>
<Input
type="text"
id="username"
v-model="username"
name="username"
required
:disabled="isLoading" />
</div>
<!-- Email field (only for registration) -->
<div v-if="isRegistering" class="space-y-2">
<Label for="email" class="text-sm font-medium">Email</Label>
<Input
type="email"
id="email"
v-model="email"
name="email"
:disabled="isLoading" />
</div>
<div class="space-y-2">
<Label for="password" class="text-sm font-medium"
>Password</Label
>
<Input
type="password"
id="password"
v-model="password"
name="password"
required
:disabled="isLoading" />
</div>
<div class="pt-2">
<Button type="submit" class="w-full" :disabled="isLoading">
{{ isRegistering ? "Register" : "Login" }}
</Button>
</div>
<div class="text-center mt-4 text-sm">
<a @click="toggleRegistration" href="#" class="font-medium">
{{
isRegistering
? "Already have an account? Login"
: "Need an account? Register"
}}
</a>
</div>
</form>
</CardContent>
</Card>
</div>
</div>
</PageLayout>
</template>
+397
View File
@@ -0,0 +1,397 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import PageLayout from "@/components/PageLayout.vue";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useTransactionsStore } from "@/stores/transactions";
import { useCategoriesStore } from "@/stores/categories";
import { formatCurrency } from "@/lib/utils";
import { PieChart, BarChart, Calendar } from "lucide-vue-next";
// Initialize stores
const transactionsStore = useTransactionsStore();
const categoriesStore = useCategoriesStore();
// Active report type
const activeReport = ref("overview"); // 'overview', 'byCategory', 'byMonth'
// Date filters
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth();
const selectedYear = ref(currentYear);
const selectedMonth = ref(currentMonth);
// Load data on mount
onMounted(async () => {
await Promise.all([
transactionsStore.fetchTransactions(),
categoriesStore.fetchCategories(),
]);
});
// Helper function to get transactions for selected period
const getFilteredTransactions = () => {
return transactionsStore.transactions.filter((transaction) => {
const transactionDate = new Date(transaction.date);
const year = transactionDate.getFullYear();
const month = transactionDate.getMonth();
return (
year === selectedYear.value &&
(activeReport.value === "byMonth"
? true
: month === selectedMonth.value)
);
});
};
// Monthly summary data
const monthlySummary = computed(() => {
const filteredTransactions = getFilteredTransactions();
const income = filteredTransactions
.filter((t) => t.type === "income")
.reduce((sum, t) => sum + t.amount, 0);
const expenses = filteredTransactions
.filter((t) => t.type === "expense")
.reduce((sum, t) => sum + t.amount, 0);
const savings = income - expenses;
const savingsRate = income > 0 ? (savings / income) * 100 : 0;
return {
income,
expenses,
savings,
savingsRate: savingsRate.toFixed(1),
transactionCount: filteredTransactions.length,
};
});
// Category breakdown data
const categoryBreakdown = computed(() => {
const filteredTransactions = getFilteredTransactions();
const breakdown = {};
// Group expenses by category
filteredTransactions
.filter((t) => t.type === "expense")
.forEach((transaction) => {
const categoryId = transaction.categoryId;
if (!breakdown[categoryId]) {
const category = categoriesStore.categories.find(
(c) => c._id === categoryId,
);
breakdown[categoryId] = {
categoryId,
name: category ? category.name : "Unknown",
amount: 0,
percentage: 0,
color: category?.color || "#cccccc",
};
}
breakdown[categoryId].amount += transaction.amount;
});
// Calculate percentages
const totalExpenses = Object.values(breakdown).reduce(
(sum: number, item: any) => sum + item.amount,
0,
);
Object.values(breakdown).forEach((item: any) => {
item.percentage =
totalExpenses > 0
? ((item.amount / totalExpenses) * 100).toFixed(1)
: 0;
});
// Sort by amount (highest first)
return Object.values(breakdown).sort(
(a: any, b: any) => b.amount - a.amount,
);
});
// Monthly trend data (simplified for the template)
const monthlyTrend = computed(() => {
const result = [];
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
// Generate data for each month
for (let i = 0; i < 12; i++) {
const monthTransactions = transactionsStore.transactions.filter(
(transaction) => {
const date = new Date(transaction.date);
return (
date.getFullYear() === selectedYear.value && date.getMonth() === i
);
},
);
const income = monthTransactions
.filter((t) => t.type === "income")
.reduce((sum, t) => sum + t.amount, 0);
const expenses = monthTransactions
.filter((t) => t.type === "expense")
.reduce((sum, t) => sum + t.amount, 0);
result.push({
month: months[i],
income,
expenses,
savings: income - expenses,
});
}
return result;
});
// Function to switch between report types
const setReportType = (type) => {
activeReport.value = type;
};
const monthName = computed(() => {
return new Date(selectedYear.value, selectedMonth.value).toLocaleString(
"default",
{ month: "long" },
);
});
</script>
<template>
<PageLayout header="Financial Reports">
<!-- Report type selector -->
<div class="mb-6 flex flex-wrap gap-2">
<Button
:variant="activeReport === 'overview' ? 'default' : 'outline'"
@click="setReportType('overview')">
<PieChart 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" />
Spending by Category
</Button>
<Button
:variant="activeReport === 'byMonth' ? 'default' : 'outline'"
@click="setReportType('byMonth')">
<Calendar class="mr-2 h-4 w-4" />
Monthly Trend
</Button>
</div>
<!-- Period selector -->
<div class="mb-6 flex gap-3 items-center">
<div>
<select
v-model="selectedMonth"
class="border rounded p-1"
v-if="activeReport !== 'byMonth'">
<option value="0">January</option>
<option value="1">February</option>
<option value="2">March</option>
<option value="3">April</option>
<option value="4">May</option>
<option value="5">June</option>
<option value="6">July</option>
<option value="7">August</option>
<option value="8">September</option>
<option value="9">October</option>
<option value="10">November</option>
<option value="11">December</option>
</select>
</div>
<div>
<select v-model="selectedYear" class="border rounded p-1">
<option :value="currentYear - 2">{{ currentYear - 2 }}</option>
<option :value="currentYear - 1">{{ currentYear - 1 }}</option>
<option :value="currentYear">{{ currentYear }}</option>
</select>
</div>
</div>
<!-- Monthly Overview Report -->
<div v-if="activeReport === 'overview'" class="grid md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>{{ monthName }} Summary</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm text-gray-500">Income</div>
<div class="text-xl font-bold text-green-500">
{{ formatCurrency(monthlySummary.income) }}
</div>
</div>
<div>
<div class="text-sm text-gray-500">Expenses</div>
<div class="text-xl font-bold text-red-500">
{{ formatCurrency(monthlySummary.expenses) }}
</div>
</div>
</div>
<div class="pt-3 border-t">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm text-gray-500">Savings</div>
<div
class="text-xl font-bold"
:class="
monthlySummary.savings >= 0
? 'text-green-500'
: 'text-red-500'
">
{{ formatCurrency(monthlySummary.savings) }}
</div>
</div>
<div>
<div class="text-sm text-gray-500">Savings Rate</div>
<div class="text-xl font-bold">
{{ monthlySummary.savingsRate }}%
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Placeholder for 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>
</CardContent>
</Card>
</div>
<!-- Category Breakdown Report -->
<div v-if="activeReport === 'byCategory'">
<Card>
<CardHeader>
<CardTitle>Spending by Category</CardTitle>
</CardHeader>
<CardContent>
<div v-if="categoryBreakdown.length === 0" class="text-center py-6">
<p class="text-gray-500">
No expense data available for this period
</p>
</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>
</div>
<!-- Category breakdown table -->
<div class="space-y-3">
<div
v-for="category in categoryBreakdown"
:key="category.categoryId"
class="flex items-center py-2">
<div
class="w-3 h-3 rounded-full mr-3"
:style="{ backgroundColor: category.color }"></div>
<div class="flex-grow">{{ category.name }}</div>
<div class="font-medium mr-4">
{{ formatCurrency(category.amount) }}
</div>
<div class="text-gray-500 w-16 text-right">
{{ category.percentage }}%
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Monthly Trend Report -->
<div v-if="activeReport === 'byMonth'">
<Card>
<CardHeader>
<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>
</div>
<!-- Monthly data table -->
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="border-b">
<th class="py-2 text-left">Month</th>
<th class="py-2 text-right">Income</th>
<th class="py-2 text-right">Expenses</th>
<th class="py-2 text-right">Savings</th>
</tr>
</thead>
<tbody>
<tr
v-for="month in monthlyTrend"
:key="month.month"
class="border-b">
<td class="py-2">{{ month.month }}</td>
<td class="py-2 text-right text-green-600">
{{ formatCurrency(month.income) }}
</td>
<td class="py-2 text-right text-red-600">
{{ formatCurrency(month.expenses) }}
</td>
<td
class="py-2 text-right"
:class="
month.savings >= 0 ? 'text-green-600' : 'text-red-600'
">
{{ formatCurrency(month.savings) }}
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
</template>
+323
View File
@@ -0,0 +1,323 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import PageLayout from "@/components/PageLayout.vue";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Bell,
Database,
Wifi,
WifiOff,
Moon,
Sun,
Smartphone,
} from "lucide-vue-next";
import { useAuthStore } from "@/stores/auth";
// Initialize store
const authStore = useAuthStore();
// Settings state
const notificationsEnabled = ref(
localStorage.getItem("notificationsEnabled") === "true",
);
const darkMode = ref(localStorage.getItem("darkMode") === "true");
const offlineMode = ref(localStorage.getItem("offlineMode") === "true");
const budgetAlertThreshold = ref(
parseInt(localStorage.getItem("budgetAlertThreshold") || "80"),
);
// PWA status
const isPWAInstalled = ref(false);
const isOnline = ref(navigator.onLine);
const serviceWorkerRegistered = ref(false);
const pushPermission = ref("default");
// Check if PWA is installed
onMounted(async () => {
// Check network status
isOnline.value = navigator.onLine;
window.addEventListener("online", () => {
isOnline.value = true;
});
window.addEventListener("offline", () => {
isOnline.value = false;
});
// Check if the app is installed as PWA
if (window.matchMedia("(display-mode: standalone)").matches) {
isPWAInstalled.value = true;
}
// Check if push notifications are supported and permission status
if ("Notification" in window) {
pushPermission.value = Notification.permission;
}
// Check if service worker is registered
if ("serviceWorker" in navigator) {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
serviceWorkerRegistered.value = registrations.length > 0;
} catch (error) {
console.error("Error checking service worker:", error);
}
}
});
// Toggle notifications
const toggleNotifications = async () => {
if ("Notification" in window) {
if (Notification.permission !== "granted") {
const permission = await Notification.requestPermission();
if (permission === "granted") {
notificationsEnabled.value = true;
pushPermission.value = "granted";
} else {
notificationsEnabled.value = false;
pushPermission.value = permission;
return;
}
} else {
notificationsEnabled.value = !notificationsEnabled.value;
}
localStorage.setItem(
"notificationsEnabled",
notificationsEnabled.value.toString(),
);
}
};
// Toggle dark mode
const toggleDarkMode = () => {
darkMode.value = !darkMode.value;
localStorage.setItem("darkMode", darkMode.value.toString());
// Apply dark mode
if (darkMode.value) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
// Toggle offline mode
const toggleOfflineMode = () => {
offlineMode.value = !offlineMode.value;
localStorage.setItem("offlineMode", offlineMode.value.toString());
};
// Update budget threshold
const updateBudgetThreshold = () => {
localStorage.setItem(
"budgetAlertThreshold",
budgetAlertThreshold.value.toString(),
);
};
// Clear all stored data
const clearStoredData = () => {
if (
confirm(
"Are you sure you want to clear all locally stored data? This action cannot be undone.",
)
) {
localStorage.removeItem("transactions");
localStorage.removeItem("categories");
// Keep settings and auth data
alert("Local transaction and category data cleared.");
}
};
</script>
<template>
<PageLayout header="Settings">
<div class="max-w-4xl mx-auto space-y-6">
<!-- App Settings -->
<Card>
<CardHeader>
<CardTitle>Application Settings</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<!-- Theme Toggle -->
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Theme</Label>
<div class="text-sm text-gray-500 dark:text-gray-400">
Choose between light and dark mode
</div>
</div>
<Button
@click="toggleDarkMode"
variant="outline"
size="sm"
class="ml-auto">
<Sun v-if="darkMode" class="h-4 w-4 mr-2" />
<Moon v-else class="h-4 w-4 mr-2" />
{{ darkMode ? "Light Mode" : "Dark Mode" }}
</Button>
</div>
<!-- Notification Settings -->
<div class="flex items-center justify-between">
<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
</div>
<div
v-if="pushPermission === 'denied'"
class="text-xs text-red-500 mt-1">
Permission denied. Please enable notifications in your browser
settings.
</div>
</div>
<Button
@click="toggleNotifications"
variant="outline"
size="sm"
class="ml-auto"
:disabled="pushPermission === 'denied'">
<Bell class="h-4 w-4 mr-2" />
{{ notificationsEnabled ? "Disable" : "Enable" }}
</Button>
</div>
<!-- Budget Alert Threshold -->
<div class="space-y-2">
<Label for="threshold">Budget Alert Threshold (%)</Label>
<div class="flex items-center gap-4">
<Input
id="threshold"
type="number"
min="1"
max="100"
v-model="budgetAlertThreshold"
class="w-24" />
<Button @click="updateBudgetThreshold" size="sm">Save</Button>
</div>
<p class="text-sm text-gray-500">
Receive alerts when you've spent this percentage of your budget
</p>
</div>
<!-- Offline Mode -->
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Offline Mode</Label>
<div class="text-sm text-gray-500 dark:text-gray-400">
Prioritize working offline when possible
</div>
<div
class="text-xs"
:class="isOnline ? 'text-green-600' : 'text-amber-600'">
Status: {{ isOnline ? "Online" : "Offline" }}
</div>
</div>
<Button
@click="toggleOfflineMode"
variant="outline"
size="sm"
class="ml-auto">
<Wifi v-if="offlineMode" class="h-4 w-4 mr-2" />
<WifiOff v-else class="h-4 w-4 mr-2" />
{{ offlineMode ? "Disable" : "Enable" }}
</Button>
</div>
</CardContent>
</Card>
<!-- PWA Information -->
<Card>
<CardHeader>
<CardTitle>Progressive Web App</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="font-medium">Installation Status</p>
<span
:class="isPWAInstalled ? 'text-green-600' : 'text-gray-600'">
{{ isPWAInstalled ? "Installed" : "Not Installed" }}
</span>
</div>
<div class="flex items-center justify-between">
<p class="font-medium">Service Worker</p>
<span
:class="
serviceWorkerRegistered ? 'text-green-600' : 'text-gray-600'
">
{{ serviceWorkerRegistered ? "Registered" : "Not Registered" }}
</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
offline access
</p>
<div class="flex items-center">
<Smartphone class="h-4 w-4 mr-2 text-gray-600" />
<span class="text-sm"
>Use your browser's "Add to Home Screen" option</span
>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Data Management -->
<Card>
<CardHeader>
<CardTitle>Data Management</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label>Local Data</Label>
<div class="flex items-center justify-between">
<div class="text-sm text-gray-500">
Clear locally stored transaction and category data
</div>
<Button @click="clearStoredData" variant="destructive" size="sm">
<Database class="h-4 w-4 mr-2" />
Clear Data
</Button>
</div>
</div>
<div class="pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-500">
Signed in as:
<span class="font-medium">{{
authStore.user?.username || "Guest"
}}</span>
</p>
<p class="text-sm text-gray-500">
Account type:
<span class="font-medium">{{ authStore.userRole }}</span>
</p>
<Button
v-if="authStore.isAuthenticated"
@click="authStore.logout"
variant="outline"
class="mt-2"
size="sm">
Sign Out
</Button>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
</template>
+275
View File
@@ -0,0 +1,275 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useRouter } from "vue-router";
import PageLayout from "@/components/PageLayout.vue";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/components/ui/card";
import { useTransactionsStore } from "@/stores/transactions";
import { useCategoriesStore } from "@/stores/categories";
import { formatCurrency, formatDate } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Search,
ArrowUpDown,
Filter,
Edit,
Trash2,
Loader2,
Plus,
} from "lucide-vue-next";
// Initialize stores and router
const transactionsStore = useTransactionsStore();
const categoriesStore = useCategoriesStore();
const router = useRouter();
// Local state
const searchQuery = ref("");
const sortBy = ref("date");
const sortOrder = ref("desc");
const filterType = ref("all"); // 'all', 'income', or 'expense'
const isDeleting = ref(false);
const deletingId = ref<string | null>(null);
// Load data on component mount
onMounted(async () => {
await Promise.all([
transactionsStore.fetchTransactions(),
categoriesStore.fetchCategories(),
]);
});
// Get category name by ID
const getCategoryName = (categoryId: string) => {
const category = categoriesStore.categories.find(
(c) => c._id === categoryId,
);
return category ? category.name : "Unknown";
};
// Filtered and sorted transactions
const filteredTransactions = computed(() => {
let result = [...transactionsStore.transactions];
// Apply type filter
if (filterType.value !== "all") {
result = result.filter((t) => t.type === filterType.value);
}
// Apply search filter
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase();
result = result.filter((t) => {
return (
t.description?.toLowerCase().includes(query) ||
getCategoryName(t.categoryId).toLowerCase().includes(query)
);
});
}
// Apply sorting
result.sort((a, b) => {
let compareResult = 0;
switch (sortBy.value) {
case "date":
compareResult =
new Date(a.date).getTime() - new Date(b.date).getTime();
break;
case "amount":
compareResult = a.amount - b.amount;
break;
case "category":
compareResult = getCategoryName(a.categoryId).localeCompare(
getCategoryName(b.categoryId),
);
break;
default:
compareResult = 0;
}
return sortOrder.value === "asc" ? compareResult : -compareResult;
});
return result;
});
// Toggle sort order
const toggleSort = (field: string) => {
if (sortBy.value === field) {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
} else {
sortBy.value = field;
sortOrder.value = "desc";
}
};
// Edit a transaction
const editTransaction = (id: string) => {
router.push(`/edit/${id}`);
};
// Delete a transaction
const confirmDelete = async (id: string) => {
if (confirm("Are you sure you want to delete this transaction?")) {
deletingId.value = id;
isDeleting.value = true;
try {
await transactionsStore.deleteTransaction(id);
} catch (error) {
console.error("Error deleting transaction:", error);
} finally {
isDeleting.value = false;
deletingId.value = null;
}
}
};
</script>
<template>
<PageLayout header="Transactions">
<div class="space-y-4">
<!-- Filters and search -->
<div class="flex flex-col sm:flex-row gap-4">
<div class="relative flex-grow">
<Input
type="text"
v-model="searchQuery"
placeholder="Search transactions..."
class="pl-10" />
<Search
class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
</div>
<div class="flex gap-2">
<Button
variant="outline"
:class="{ 'bg-primary/10': filterType === 'all' }"
@click="filterType = 'all'">
All
</Button>
<Button
variant="outline"
:class="{ 'bg-primary/10': filterType === 'income' }"
@click="filterType = 'income'">
Income
</Button>
<Button
variant="outline"
:class="{ 'bg-primary/10': filterType === 'expense' }"
@click="filterType = 'expense'">
Expenses
</Button>
</div>
</div>
<Card>
<CardHeader class="flex flex-row items-center justify-between">
<CardTitle class="text-xl">Transaction History</CardTitle>
<Button
variant="default"
size="sm"
to="/add"
class="flex items-center">
<Plus class="mr-1 h-4 w-4" />
Add New
</Button>
</CardHeader>
<CardContent>
<div v-if="transactionsStore.loading" class="text-center py-8">
<p>Loading transactions...</p>
</div>
<div
v-else-if="filteredTransactions.length === 0"
class="text-center py-8">
<p class="text-gray-500">No transactions found</p>
<Button class="mt-4" to="/add">Add Transaction</Button>
</div>
<div v-else>
<!-- Table header -->
<div
class="grid grid-cols-12 gap-4 py-3 px-4 font-medium text-sm border-b">
<div
class="col-span-3 sm:col-span-2 flex items-center cursor-pointer"
@click="toggleSort('date')">
Date
<ArrowUpDown class="ml-1 h-4 w-4" />
</div>
<div
class="col-span-4 sm:col-span-3 flex items-center cursor-pointer"
@click="toggleSort('category')">
Category
<ArrowUpDown class="ml-1 h-4 w-4" />
</div>
<div class="col-span-3 sm:col-span-4 hidden sm:block">
Description
</div>
<div
class="col-span-3 sm:col-span-2 flex items-center justify-end cursor-pointer"
@click="toggleSort('amount')">
Amount
<ArrowUpDown class="ml-1 h-4 w-4" />
</div>
<div class="col-span-2 sm:col-span-1 text-center">Actions</div>
</div>
<!-- Table body -->
<div class="divide-y">
<div
v-for="transaction in filteredTransactions"
:key="transaction._id"
class="grid grid-cols-12 gap-4 py-4 px-4 hover:bg-gray-50 transition-colors">
<div class="col-span-3 sm:col-span-2 text-sm text-gray-600">
{{ formatDate(transaction.date, "short") }}
</div>
<div class="col-span-4 sm:col-span-3">
{{ getCategoryName(transaction.categoryId) }}
</div>
<div class="col-span-3 sm:col-span-4 hidden sm:block text-sm">
{{ transaction.description || "-" }}
</div>
<div
class="col-span-3 sm:col-span-2 font-medium text-right"
:class="
transaction.type === 'income'
? 'text-green-500'
: 'text-red-500'
">
{{ transaction.type === "income" ? "+" : "-"
}}{{ formatCurrency(transaction.amount) }}
</div>
<div
class="col-span-2 sm:col-span-1 flex justify-end items-center gap-2">
<button
@click="editTransaction(transaction._id!)"
class="p-1 text-gray-500 hover:text-blue-500 transition-colors"
title="Edit transaction">
<Edit class="h-4 w-4" />
</button>
<button
@click="confirmDelete(transaction._id!)"
class="p-1 text-gray-500 hover:text-red-500 transition-colors"
:disabled="isDeleting && deletingId === transaction._id"
title="Delete transaction">
<Loader2
v-if="isDeleting && deletingId === transaction._id"
class="h-4 w-4 animate-spin" />
<Trash2 v-else class="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
</template>
+5
View File
@@ -0,0 +1,5 @@
import { reactive } from "vue";
export const userState = reactive({
isLoggedIn: false,
});
+123
View File
@@ -0,0 +1,123 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { useRouter } from "vue-router";
export const useAuthStore = defineStore("auth", () => {
// State
const token = ref(localStorage.getItem("token") || "");
const user = ref(JSON.parse(localStorage.getItem("user") || "null"));
const loading = ref(false);
const error = ref("");
// Getters
const isAuthenticated = computed(() => Boolean(token.value));
const userRole = computed(() => user.value?.role || "guest");
// API URL - update with your actual backend URL
const apiBaseUrl = "http://localhost:3000/api";
// Actions
async function login(username: string, password: string) {
loading.value = true;
error.value = "";
try {
const response = await fetch(`${apiBaseUrl}/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Login failed");
}
// Store auth data in localStorage
localStorage.setItem("token", data.token);
localStorage.setItem(
"user",
JSON.stringify({
id: data.userId,
username: data.username,
role: data.role || "user",
}),
);
// Update state
token.value = data.token;
user.value = {
id: data.userId,
username: data.username,
role: data.role || "user",
};
return true;
} catch (err: any) {
error.value = err.message || "Something went wrong";
return false;
} finally {
loading.value = false;
}
}
async function register(username: string, password: string, email: string) {
loading.value = true;
error.value = "";
try {
const response = await fetch(`${apiBaseUrl}/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password, email }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Registration failed");
}
return true;
} catch (err: any) {
error.value = err.message || "Something went wrong";
return false;
} finally {
loading.value = false;
}
}
function logout() {
// Clear state and localStorage
token.value = "";
user.value = null;
localStorage.removeItem("token");
localStorage.removeItem("user");
// Use router to redirect to login page
const router = useRouter();
router.push("/login");
}
return {
// State
token,
user,
loading,
error,
// Getters
isAuthenticated,
userRole,
// Actions
login,
register,
logout,
};
});
+422
View File
@@ -0,0 +1,422 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { useAuthStore } from "./auth";
import { useSyncStore } from "./sync";
// Types
export interface Category {
_id?: string;
name: string;
icon?: string;
color?: string;
synced?: boolean;
}
// Store for categories
export const useCategoriesStore = defineStore("categories", () => {
// State
const categories = ref<Category[]>([]);
const loading = ref(false);
const error = ref("");
const lastFetchTime = ref<number>(0);
const cacheTimeout = 5 * 60 * 1000; // 5 minutes cache timeout
// API URL
const apiBaseUrl = "http://localhost:3000/api";
// Auth store for token
const authStore = useAuthStore();
// Load and save from local storage
function loadFromLocalStorage() {
const savedData = localStorage.getItem("categories");
const savedTimestamp = localStorage.getItem("categories_timestamp");
if (savedData && savedTimestamp) {
categories.value = JSON.parse(savedData);
lastFetchTime.value = parseInt(savedTimestamp);
}
}
function saveToLocalStorage() {
localStorage.setItem("categories", JSON.stringify(categories.value));
localStorage.setItem("categories_timestamp", lastFetchTime.value.toString());
}
// Check if cache is valid
function isCacheValid() {
const now = Date.now();
return categories.value.length > 0 && (now - lastFetchTime.value) < cacheTimeout;
}
// Default categories if none exist (for offline users only)
function initializeDefaultCategories() {
if (categories.value.length === 0 && !authStore.token) {
categories.value = [
{ _id: "default-food", name: "Food", icon: "🍔", color: "#FF5733" },
{
_id: "default-transport",
name: "Transport",
icon: "🚗",
color: "#33A1FF",
},
{
_id: "default-housing",
name: "Housing",
icon: "🏠",
color: "#33FF57",
},
{
_id: "default-entertainment",
name: "Entertainment",
icon: "🎬",
color: "#D733FF",
},
{ _id: "default-salary", name: "Salary", icon: "💰", color: "#33FFD7" },
{ _id: "default-other", name: "Other", icon: "📦", color: "#FFD733" },
];
saveToLocalStorage();
}
}
// API Actions
async function fetchCategories(forceRefresh = false) {
// Load from localStorage first
if (!forceRefresh) {
loadFromLocalStorage();
// Check if cache is valid
if (isCacheValid()) {
return;
}
}
if (!authStore.token) {
initializeDefaultCategories();
return;
}
loading.value = true;
error.value = "";
try {
const response = await fetch(`${apiBaseUrl}/categories`, {
headers: {
Authorization: `Bearer ${authStore.token}`,
},
});
if (!response.ok) {
throw new Error("Failed to fetch categories");
}
const data = await response.json();
// Always use what the server returns for authenticated users
if (data.data) {
// Clear existing categories to avoid mixing with defaults
categories.value = [];
// Use server categories even if empty array
categories.value = data.data;
lastFetchTime.value = Date.now();
}
// Save to localStorage for offline access
saveToLocalStorage();
} catch (err: any) {
error.value = err.message;
// If API call fails, try to load from localStorage
loadFromLocalStorage();
// If still no categories, initialize defaults
if (categories.value.length === 0) {
initializeDefaultCategories();
}
} finally {
loading.value = false;
}
}
async function addCategory(category: Omit<Category, "_id">) {
loading.value = true;
error.value = "";
// Client-side validation
if (!category.name?.trim()) {
error.value = "Category name cannot be empty";
loading.value = false;
return null;
}
// Check for duplicates
const isDuplicate = categories.value.some(
c => c.name.toLowerCase() === category.name.toLowerCase()
);
if (isDuplicate) {
error.value = "A category with this name already exists";
loading.value = false;
return null;
}
// Get sync store
const syncStore = useSyncStore();
// Prepare the category with defaults if needed
const preparedCategory = {
...category,
icon: category.icon || "📦",
color: category.color || "#3b82f6",
};
// Add to local state first for immediate UI update
const newCategory = {
...preparedCategory,
_id: "temp_" + Date.now(),
synced: false,
};
categories.value.push(newCategory);
saveToLocalStorage();
// Then sync with server if online
if (syncStore.isOnline && authStore.token) {
try {
const response = await fetch(`${apiBaseUrl}/categories`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authStore.token}`,
},
body: JSON.stringify(category),
});
if (!response.ok) {
throw new Error("Failed to save category");
}
// Update with server response
const data = await response.json();
const index = categories.value.findIndex(
(c) => c._id === newCategory._id,
);
if (index !== -1) {
categories.value[index] = {
...category,
_id: data.categoryId,
synced: true,
};
saveToLocalStorage();
}
} catch (err: any) {
error.value = err.message;
// Add to pending actions for later sync
syncStore.addPendingAction("category", "create", newCategory);
}
} else {
// We're offline or not authenticated, add to pending actions
syncStore.addPendingAction("category", "create", newCategory);
}
loading.value = false;
return newCategory;
}
// Admin functions
async function fetchAllCategories() {
if (!authStore.token || authStore.user?.role !== "admin") {
error.value = "Admin access required";
return;
}
loading.value = true;
error.value = "";
try {
const response = await fetch(`${apiBaseUrl}/admin/categories`, {
headers: {
Authorization: `Bearer ${authStore.token}`,
},
});
if (!response.ok) {
throw new Error("Failed to fetch all categories");
}
const data = await response.json();
return data.data;
} catch (err: any) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
async function createGlobalCategory(category: Omit<Category, "_id">) {
if (!authStore.token || authStore.user?.role !== "admin") {
error.value = "Admin access required";
return;
}
loading.value = true;
error.value = "";
try {
const response = await fetch(`${apiBaseUrl}/admin/categories`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authStore.token}`,
},
body: JSON.stringify(category),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.message || "Failed to create global category",
);
}
const data = await response.json();
return data;
} catch (err: any) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
async function updateCategory(
categoryId: string,
updates: Partial<Category>,
) {
if (!authStore.token || authStore.user?.role !== "admin") {
error.value = "Admin access required";
return;
}
loading.value = true;
error.value = "";
try {
const response = await fetch(
`${apiBaseUrl}/admin/categories/${categoryId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authStore.token}`,
},
body: JSON.stringify(updates),
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "Failed to update category");
}
const data = await response.json();
return data;
} catch (err: any) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
async function deleteCategory(categoryId: string) {
if (!authStore.token || authStore.user?.role !== "admin") {
error.value = "Admin access required";
return;
}
loading.value = true;
error.value = "";
try {
const response = await fetch(
`${apiBaseUrl}/admin/categories/${categoryId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${authStore.token}`,
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "Failed to delete category");
}
const data = await response.json();
return data;
} catch (err: any) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
// Initialize store
function initializeStore() {
// When user is authenticated, always get data from server
if (authStore.token) {
// First check if we have a valid cache
loadFromLocalStorage();
// If cache is invalid or forced refresh, fetch from server
if (!isCacheValid()) {
fetchCategories();
}
} else {
// For non-authenticated users, load from local storage or use defaults
loadFromLocalStorage();
// If still no categories after load, use defaults for offline experience
if (categories.value.length === 0) {
initializeDefaultCategories();
}
}
}
// Initialize
initializeStore();
// Method to force refresh categories
async function refreshCategories() {
return fetchCategories(true);
}
// Method to clear cache
function clearCache() {
categories.value = [];
lastFetchTime.value = 0;
localStorage.removeItem("categories");
localStorage.removeItem("categories_timestamp");
}
return {
// State
categories,
loading,
error,
// Actions
fetchCategories,
refreshCategories,
addCategory,
clearCache,
// Admin actions
fetchAllCategories,
createGlobalCategory,
updateCategory,
deleteCategory,
};
});
+214
View File
@@ -0,0 +1,214 @@
// SyncStore - Handles data synchronization between local storage and server
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { useAuthStore } from "./auth";
interface PendingItem {
id: string;
type: "transaction" | "category";
action: "create" | "update" | "delete";
data: any;
timestamp: number;
}
export const useSyncStore = defineStore("sync", () => {
// State
const pendingActions = ref<PendingItem[]>([]);
const isSyncing = ref(false);
const lastSyncTime = ref<number>(
parseInt(localStorage.getItem("lastSyncTime") || "0"),
);
const syncErrors = ref<string[]>([]);
const isOnline = ref(navigator.onLine);
// API URL
const apiBaseUrl = "http://localhost:3000/api";
// Auth store for token
const authStore = useAuthStore();
// Initialize online/offline listeners
window.addEventListener("online", () => {
isOnline.value = true;
if (pendingActions.value.length > 0) {
syncWithServer();
}
});
window.addEventListener("offline", () => {
isOnline.value = false;
});
// Load pending actions from localStorage
function loadFromLocalStorage() {
const savedActions = localStorage.getItem("pendingActions");
if (savedActions) {
pendingActions.value = JSON.parse(savedActions);
}
}
// Save pending actions to localStorage
function saveToLocalStorage() {
localStorage.setItem(
"pendingActions",
JSON.stringify(pendingActions.value),
);
}
// Add pending action
function addPendingAction(
type: "transaction" | "category",
action: "create" | "update" | "delete",
data: any,
) {
const id = `${type}_${Date.now()}`;
pendingActions.value.push({
id,
type,
action,
data,
timestamp: Date.now(),
});
saveToLocalStorage();
if (isOnline.value && authStore.isAuthenticated) {
syncWithServer();
} else if ("serviceWorker" in navigator && "SyncManager" in window) {
// Register for background sync
navigator.serviceWorker.ready
.then((registration) => {
registration.sync.register(`sync-${type}s`);
})
.catch((err) => {
console.error("Background sync registration failed:", err);
});
}
return id;
}
// Remove pending action
function removePendingAction(id: string) {
const index = pendingActions.value.findIndex((item) => item.id === id);
if (index !== -1) {
pendingActions.value.splice(index, 1);
saveToLocalStorage();
}
}
// Sync with server
async function syncWithServer() {
if (
isSyncing.value ||
!authStore.token ||
pendingActions.value.length === 0
) {
return;
}
isSyncing.value = true;
syncErrors.value = [];
try {
// Clone the array to avoid mutation issues during iteration
const actions = [...pendingActions.value];
for (const action of actions) {
try {
if (action.type === "transaction") {
await syncTransaction(action);
} else if (action.type === "category") {
await syncCategory(action);
}
} catch (err: any) {
syncErrors.value.push(
`Failed to sync ${action.type}: ${err.message}`,
);
}
}
// Update last sync time
lastSyncTime.value = Date.now();
localStorage.setItem("lastSyncTime", lastSyncTime.value.toString());
} catch (err: any) {
syncErrors.value.push(`Sync error: ${err.message}`);
} finally {
isSyncing.value = false;
}
}
// Sync a transaction
async function syncTransaction(item: PendingItem) {
let method = "POST";
let url = `${apiBaseUrl}/transactions`;
if (item.action === "update") {
method = "PUT";
url = `${apiBaseUrl}/transactions/${item.data._id}`;
} else if (item.action === "delete") {
method = "DELETE";
url = `${apiBaseUrl}/transactions/${item.data._id}`;
}
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authStore.token}`,
},
body: method !== "DELETE" ? JSON.stringify(item.data) : undefined,
});
if (response.ok) {
removePendingAction(item.id);
} else {
throw new Error(`Server returned ${response.status}`);
}
}
// Sync a category
async function syncCategory(item: PendingItem) {
let method = "POST";
let url = `${apiBaseUrl}/categories`;
if (item.action === "update") {
method = "PUT";
url = `${apiBaseUrl}/categories/${item.data._id}`;
} else if (item.action === "delete") {
method = "DELETE";
url = `${apiBaseUrl}/categories/${item.data._id}`;
}
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authStore.token}`,
},
body: method !== "DELETE" ? JSON.stringify(item.data) : undefined,
});
if (response.ok) {
removePendingAction(item.id);
} else {
throw new Error(`Server returned ${response.status}`);
}
}
// Initialize
loadFromLocalStorage();
return {
// State
pendingActions,
isSyncing,
lastSyncTime,
syncErrors,
isOnline,
// Actions
addPendingAction,
removePendingAction,
syncWithServer,
};
});
+313
View File
@@ -0,0 +1,313 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { useAuthStore } from "./auth";
import { useSyncStore } from "./sync";
// Types
export interface Transaction {
_id?: string;
amount: number;
type: "income" | "expense";
categoryId: string;
description: string;
date: string;
createdAt?: string;
updatedAt?: string;
synced?: boolean;
}
export interface Category {
_id?: string;
name: string;
icon?: string;
color?: string;
}
// Store for transactions
export const useTransactionsStore = defineStore("transactions", () => {
// State
const transactions = ref<Transaction[]>([]);
const loading = ref(false);
const error = ref("");
// API URL
const apiBaseUrl = "http://localhost:3000/api";
// Auth store for token
const authStore = useAuthStore();
// Getters
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);
// Load and save from local storage
function loadFromLocalStorage() {
const savedData = localStorage.getItem("transactions");
if (savedData) {
transactions.value = JSON.parse(savedData);
}
}
function saveToLocalStorage() {
localStorage.setItem("transactions", JSON.stringify(transactions.value));
}
// API Actions
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;
// Save to localStorage for offline access
saveToLocalStorage();
} catch (err: any) {
error.value = err.message;
// If API call fails, try to load from localStorage
loadFromLocalStorage();
} finally {
loading.value = false;
}
}
async function addTransaction(
transaction: Omit<Transaction, "_id" | "createdAt" | "updatedAt">,
) {
loading.value = true;
error.value = "";
// Get sync store for handling offline/online sync
const syncStore = useSyncStore();
// Create transaction object with temp ID
const newTransaction = {
...transaction,
_id: "temp_" + Date.now(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
synced: false,
};
// Check if we're online and authenticated
if (syncStore.isOnline && authStore.token) {
try {
console.log("Sending transaction to server:", transaction);
const response = await fetch(`${apiBaseUrl}/transactions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authStore.token}`,
},
body: JSON.stringify(transaction),
});
const responseData = await response.json();
if (!response.ok) {
throw new Error(responseData.message || "Failed to save transaction");
}
// Add to local state with server ID
const serverTransaction = {
...transaction,
_id: responseData.transactionId,
createdAt: newTransaction.createdAt,
updatedAt: newTransaction.updatedAt,
synced: true,
};
transactions.value.push(serverTransaction);
saveToLocalStorage();
return serverTransaction;
} catch (err: any) {
console.error("Transaction error:", err);
error.value = err.message;
// Add to local state as unsynced
transactions.value.push(newTransaction);
saveToLocalStorage();
// Add to pending actions for later sync
syncStore.addPendingAction("transaction", "create", newTransaction);
}
} else {
// We're offline or not authenticated, add to local state
transactions.value.push(newTransaction);
saveToLocalStorage();
// Add to pending actions for later sync
syncStore.addPendingAction("transaction", "create", newTransaction);
}
loading.value = false;
return newTransaction;
}
// Update an existing transaction
async function updateTransaction(transaction: Transaction) {
if (!transaction._id) {
error.value = "Transaction ID is required for update";
return false;
}
loading.value = true;
error.value = "";
// Get sync store
const syncStore = useSyncStore();
// Update in local state first
const index = transactions.value.findIndex(
(t) => t._id === transaction._id,
);
if (index !== -1) {
transactions.value[index] = {
...transaction,
updatedAt: new Date().toISOString(),
synced: false,
};
saveToLocalStorage();
} else {
error.value = "Transaction not found";
loading.value = false;
return false;
}
// Try to sync with server if online
if (syncStore.isOnline && authStore.token) {
try {
const response = await fetch(
`${apiBaseUrl}/transactions/${transaction._id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authStore.token}`,
},
body: JSON.stringify(transaction),
},
);
if (!response.ok) {
throw new Error("Failed to update transaction");
}
// Mark as synced
transactions.value[index].synced = true;
saveToLocalStorage();
} catch (err: any) {
error.value = err.message;
// Add to pending actions for later sync
syncStore.addPendingAction("transaction", "update", transaction);
}
} else {
// We're offline, add to pending actions
syncStore.addPendingAction("transaction", "update", transaction);
}
loading.value = false;
return true;
}
// Delete a transaction
async function deleteTransaction(id: string) {
loading.value = true;
error.value = "";
// Get sync store
const syncStore = useSyncStore();
// Find the transaction to delete
const transaction = transactions.value.find((t) => t._id === id);
if (!transaction) {
error.value = "Transaction not found";
loading.value = false;
return false;
}
// Remove from local state first
transactions.value = transactions.value.filter((t) => t._id !== id);
saveToLocalStorage();
// Try to sync with server if online
if (syncStore.isOnline && authStore.token) {
try {
const response = await fetch(`${apiBaseUrl}/transactions/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${authStore.token}`,
},
});
if (!response.ok) {
throw new Error("Failed to delete transaction");
}
} catch (err: any) {
error.value = err.message;
// Add to pending actions for later sync
syncStore.addPendingAction("transaction", "delete", { _id: id });
}
} else {
// We're offline, add to pending actions
syncStore.addPendingAction("transaction", "delete", { _id: id });
}
loading.value = false;
return true;
}
// Initialize
loadFromLocalStorage();
return {
// State
transactions,
loading,
error,
// Getters
incomes,
expenses,
totalIncome,
totalExpenses,
balance,
// Actions
fetchTransactions,
addTransaction,
updateTransaction,
deleteTransaction,
loadFromLocalStorage,
saveToLocalStorage,
};
});
-79
View File
@@ -1,79 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 142.1 76.2% 36.3%;
--radius: 0.5rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 0 0% 95%;
--card: 24 9.8% 10%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 95%;
--primary: 142.1 70.6% 45.3%;
--primary-foreground: 144.9 80.4% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 15%;
--muted-foreground: 240 5% 64.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 142.4 71.8% 29.2%;
}
}
+224
View File
@@ -0,0 +1,224 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.141 0.005 285.823);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.141 0.005 285.823);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.274 0.006 286.033);
--input: oklch(0.274 0.006 286.033);
--ring: oklch(0.442 0.017 285.786);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.274 0.006 286.033);
--sidebar-ring: oklch(0.442 0.017 285.786);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@layer base {
}
+9
View File
@@ -0,0 +1,9 @@
/* Global transitions */
/* Disable all transitions for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}
+10 -2
View File
@@ -1,8 +1,11 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
@@ -11,5 +14,10 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"typed-router.d.ts"
]
}
+13 -3
View File
@@ -1,7 +1,17 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

Some files were not shown because too many files have changed in this diff Show More