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:
@@ -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
@@ -1,2 +1,2 @@
|
||||
// Import and run the Koa.js application
|
||||
import './src/app';
|
||||
import "./src/app";
|
||||
|
||||
@@ -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",
|
||||
|
||||
+25
-14
@@ -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;
|
||||
@@ -11,14 +12,24 @@ const db = DatabaseService.getInstance();
|
||||
// Connect to database
|
||||
db.connect()
|
||||
.then(() => {
|
||||
console.log('Database connection established');
|
||||
console.log("Database connection established");
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Database connection failed:', err);
|
||||
.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,12 +37,12 @@ app.use(router.routes());
|
||||
app.use(router.allowedMethods());
|
||||
|
||||
// Error handling
|
||||
app.on('error', (err, ctx) => {
|
||||
console.error('Server error:', err);
|
||||
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'
|
||||
message: err.message || "Internal server error",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -41,13 +52,13 @@ app.listen(PORT, () => {
|
||||
});
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down server...');
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("Shutting down server...");
|
||||
try {
|
||||
await db.disconnect();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error during shutdown:', error);
|
||||
console.error("Error during shutdown:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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();
|
||||
|
||||
@@ -13,26 +13,31 @@ export default {
|
||||
// User registration
|
||||
async register(ctx: Context) {
|
||||
const { username, password, email } = ctx.request.body as {
|
||||
username: string,
|
||||
password: string,
|
||||
email?: string
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
ctx.status = 400;
|
||||
ctx.body = { success: false, message: 'Username and password are required' };
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: "Username and password are required",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const usersCollection = db.getCollection<User>(dbConfig.collections.users);
|
||||
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' };
|
||||
ctx.body = { success: false, message: "Username already exists" };
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,7 +52,7 @@ export default {
|
||||
password: hashedPassword,
|
||||
email,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Save user to database
|
||||
@@ -56,15 +61,15 @@ export default {
|
||||
ctx.status = 201;
|
||||
ctx.body = {
|
||||
success: true,
|
||||
message: 'User registered successfully',
|
||||
userId: result.insertedId
|
||||
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
|
||||
message: "Error registering user",
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -72,16 +77,19 @@ export default {
|
||||
// Admin registration
|
||||
async registerAdmin(ctx: Context) {
|
||||
const { username, password, email, adminKey } = ctx.request.body as {
|
||||
username: string,
|
||||
password: string,
|
||||
email?: string,
|
||||
adminKey: string
|
||||
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' };
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: "Username and password are required",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,18 +97,20 @@ export default {
|
||||
const validAdminKey = appConfig.adminKey;
|
||||
if (adminKey !== validAdminKey) {
|
||||
ctx.status = 403;
|
||||
ctx.body = { success: false, message: 'Invalid admin key' };
|
||||
ctx.body = { success: false, message: "Invalid admin key" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const usersCollection = db.getCollection<User>(dbConfig.collections.users);
|
||||
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' };
|
||||
ctx.body = { success: false, message: "Username already exists" };
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,9 +124,9 @@ export default {
|
||||
username,
|
||||
password: hashedPassword,
|
||||
email,
|
||||
role: 'admin', // Add admin role
|
||||
role: "admin", // Add admin role
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Save user to database
|
||||
@@ -125,38 +135,46 @@ export default {
|
||||
ctx.status = 201;
|
||||
ctx.body = {
|
||||
success: true,
|
||||
message: 'Admin registered successfully',
|
||||
userId: result.insertedId
|
||||
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
|
||||
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 };
|
||||
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' };
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: "Username and password are required",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const usersCollection = db.getCollection<User>(dbConfig.collections.users);
|
||||
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' };
|
||||
ctx.body = { success: false, message: "Invalid credentials" };
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -164,7 +182,7 @@ export default {
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
ctx.status = 401;
|
||||
ctx.body = { success: false, message: 'Invalid credentials' };
|
||||
ctx.body = { success: false, message: "Invalid credentials" };
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,26 +190,26 @@ export default {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: user._id?.toString(),
|
||||
username: user.username
|
||||
username: user.username,
|
||||
},
|
||||
jwtConfig.secret,
|
||||
{ expiresIn: jwtConfig.expiresIn } as SignOptions
|
||||
{ expiresIn: jwtConfig.expiresIn } as SignOptions,
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
message: "Login successful",
|
||||
userId: user._id,
|
||||
username: user.username,
|
||||
token
|
||||
token,
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.status = 500;
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: 'Error during login',
|
||||
error: (error as Error).message
|
||||
message: "Error during login",
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
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) {
|
||||
@@ -13,22 +27,26 @@ export default {
|
||||
const userId = user.id;
|
||||
|
||||
try {
|
||||
const categoriesCollection = db.getCollection<Category>(dbConfig.collections.categories);
|
||||
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
|
||||
data: categories,
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.status = 500;
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: 'Error retrieving categories',
|
||||
error: (error as Error).message
|
||||
message: "Error retrieving categories",
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -47,25 +65,27 @@ export default {
|
||||
ctx.status = 400;
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: 'Category name is required'
|
||||
message: "Category name is required",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const categoriesCollection = db.getCollection<Category>(dbConfig.collections.categories);
|
||||
const categoriesCollection = db.getCollection<Category>(
|
||||
dbConfig.collections.categories,
|
||||
);
|
||||
|
||||
// Check if category already exists
|
||||
const existingCategory = await categoriesCollection.findOne({
|
||||
userId: new ObjectId(userId),
|
||||
name
|
||||
name,
|
||||
});
|
||||
|
||||
if (existingCategory) {
|
||||
ctx.status = 409;
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: 'Category already exists'
|
||||
message: "Category already exists",
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -77,7 +97,7 @@ export default {
|
||||
icon,
|
||||
color,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await categoriesCollection.insertOne(newCategory);
|
||||
@@ -85,15 +105,15 @@ export default {
|
||||
ctx.status = 201;
|
||||
ctx.body = {
|
||||
success: true,
|
||||
message: 'Category added successfully',
|
||||
categoryId: result.insertedId
|
||||
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
|
||||
message: "Error adding category",
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -114,31 +134,33 @@ export default {
|
||||
ctx.status = 400;
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: 'Valid category ID is required'
|
||||
message: "Valid category ID is required",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const categoriesCollection = db.getCollection<Category>(dbConfig.collections.categories);
|
||||
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)
|
||||
userId: new ObjectId(userId),
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
ctx.status = 404;
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: 'Category not found'
|
||||
message: "Category not found",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData: Partial<Category> = {
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (name) updateData.name = name;
|
||||
@@ -147,20 +169,246 @@ export default {
|
||||
|
||||
await categoriesCollection.updateOne(
|
||||
{ _id: new ObjectId(categoryId) },
|
||||
{ $set: updateData }
|
||||
{ $set: updateData },
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
message: 'Category updated successfully'
|
||||
message: "Category updated successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.status = 500;
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: 'Error updating category',
|
||||
error: (error as Error).message
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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,13 +61,19 @@ 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 {
|
||||
|
||||
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;
|
||||
@@ -78,23 +84,38 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,17 +1,13 @@
|
||||
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'
|
||||
]
|
||||
path: ["/api/auth/login", "/api/auth/register", "/api/auth/register-admin"],
|
||||
});
|
||||
|
||||
// Error handling middleware for JWT authentication
|
||||
@@ -24,7 +20,7 @@ export const handleJwtError: Middleware = async (ctx, next) => {
|
||||
ctx.status = 401;
|
||||
ctx.body = {
|
||||
success: false,
|
||||
message: 'Unauthorized - Invalid or expired token'
|
||||
message: "Unauthorized - Invalid or expired token",
|
||||
};
|
||||
} else {
|
||||
throw err;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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;
|
||||
@@ -21,16 +21,18 @@ class DatabaseService {
|
||||
try {
|
||||
await this.client.connect();
|
||||
this.db = this.client.db(dbConfig.dbName);
|
||||
console.log('Connected to MongoDB successfully');
|
||||
console.log("Connected to MongoDB successfully");
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to MongoDB', 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.');
|
||||
throw new Error(
|
||||
"Database connection not established. Call connect() first.",
|
||||
);
|
||||
}
|
||||
return this.db.collection<T>(collectionName);
|
||||
}
|
||||
@@ -38,7 +40,7 @@ class DatabaseService {
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
await this.client.close();
|
||||
console.log('Disconnected from MongoDB');
|
||||
console.log("Disconnected from MongoDB");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"printWidth": 80,
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"bracketSameLine": true
|
||||
}
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
+8
-3
@@ -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
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
@@ -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} didn’t 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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
-->
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
-->
|
||||
@@ -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>
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
export const userState = reactive({
|
||||
isLoggedIn: false,
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
|
||||
// It's recommended to commit this file.
|
||||
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
||||
|
||||
declare module 'vue-router/auto-routes' {
|
||||
import type {
|
||||
RouteRecordInfo,
|
||||
ParamValue,
|
||||
ParamValueOneOrMore,
|
||||
ParamValueZeroOrMore,
|
||||
ParamValueZeroOrOne,
|
||||
} from 'vue-router'
|
||||
|
||||
/**
|
||||
* Route name map generated by unplugin-vue-router
|
||||
*/
|
||||
export interface RouteNamedMap {
|
||||
'/About': RouteRecordInfo<'/About', '/About', Record<never, never>, Record<never, never>>,
|
||||
'/AddTransaction': RouteRecordInfo<'/AddTransaction', '/AddTransaction', Record<never, never>, Record<never, never>>,
|
||||
'/AdminCategories': RouteRecordInfo<'/AdminCategories', '/AdminCategories', Record<never, never>, Record<never, never>>,
|
||||
'/Categories': RouteRecordInfo<'/Categories', '/Categories', Record<never, never>, Record<never, never>>,
|
||||
'/Dashboard': RouteRecordInfo<'/Dashboard', '/Dashboard', Record<never, never>, Record<never, never>>,
|
||||
'/EditTransaction': RouteRecordInfo<'/EditTransaction', '/EditTransaction', Record<never, never>, Record<never, never>>,
|
||||
'/Login': RouteRecordInfo<'/Login', '/Login', Record<never, never>, Record<never, never>>,
|
||||
'/Reports': RouteRecordInfo<'/Reports', '/Reports', Record<never, never>, Record<never, never>>,
|
||||
'/Settings': RouteRecordInfo<'/Settings', '/Settings', Record<never, never>, Record<never, never>>,
|
||||
'/Transactions': RouteRecordInfo<'/Transactions', '/Transactions', Record<never, never>, Record<never, never>>,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user