Initial commit: Second Brain Platform
Complete platform with unified design system and real API integration. Apps: Dashboard, Fitness, Budget, Inventory, Trips, Reader, Media, Settings Infrastructure: SvelteKit + Python gateway + Docker Compose
This commit is contained in:
14
services/budget/Dockerfile
Normal file
14
services/budget/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY server.js ./
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
1443
services/budget/package-lock.json
generated
Normal file
1443
services/budget/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
services/budget/package.json
Normal file
14
services/budget/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "budget-service",
|
||||
"version": "1.0.0",
|
||||
"description": "REST API wrapper for Actual Budget",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/api": "^26.3.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
653
services/budget/server.js
Normal file
653
services/budget/server.js
Normal file
@@ -0,0 +1,653 @@
|
||||
// Polyfill browser globals that @actual-app/api v26 expects in Node.js
|
||||
if (typeof globalThis.navigator === 'undefined') {
|
||||
globalThis.navigator = { platform: 'linux', userAgent: 'node' };
|
||||
}
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const api = require('@actual-app/api');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACTUAL_SERVER_URL = process.env.ACTUAL_SERVER_URL || 'http://actualbudget:5006';
|
||||
const ACTUAL_PASSWORD = process.env.ACTUAL_PASSWORD;
|
||||
const ACTUAL_SYNC_ID = process.env.ACTUAL_SYNC_ID;
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const DATA_DIR = process.env.ACTUAL_DATA_DIR || '/app/data';
|
||||
|
||||
// How often (ms) to silently re-sync with the server in the background.
|
||||
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
let ready = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actual Budget connection lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function initActual() {
|
||||
console.log(`[budget] Connecting to Actual server at ${ACTUAL_SERVER_URL}`);
|
||||
await api.init({
|
||||
serverURL: ACTUAL_SERVER_URL,
|
||||
password: ACTUAL_PASSWORD,
|
||||
dataDir: DATA_DIR,
|
||||
});
|
||||
console.log(`[budget] Downloading budget ${ACTUAL_SYNC_ID}`);
|
||||
await api.downloadBudget(ACTUAL_SYNC_ID);
|
||||
ready = true;
|
||||
console.log('[budget] Budget loaded and ready');
|
||||
}
|
||||
|
||||
async function syncBudget() {
|
||||
try {
|
||||
await api.sync();
|
||||
console.log(`[budget] Background sync completed at ${new Date().toISOString()}`);
|
||||
} catch (err) {
|
||||
console.error('[budget] Background sync failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Middleware - reject requests until the budget is loaded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function requireReady(req, res, next) {
|
||||
if (!ready) {
|
||||
return res.status(503).json({ error: 'Budget not loaded yet' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert cents (integer) to a human-readable dollar amount. */
|
||||
function centsToDollars(cents) {
|
||||
return cents / 100;
|
||||
}
|
||||
|
||||
/** Get the first and last day of a YYYY-MM month string. */
|
||||
function monthBounds(month) {
|
||||
const [year, m] = month.split('-').map(Number);
|
||||
const start = `${month}-01`;
|
||||
const lastDay = new Date(year, m, 0).getDate();
|
||||
const end = `${month}-${String(lastDay).padStart(2, '0')}`;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** Current month as YYYY-MM. */
|
||||
function currentMonth() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Build lookup maps for payees, accounts, and categories. */
|
||||
async function buildLookups() {
|
||||
const [payees, accounts, categories] = await Promise.all([
|
||||
api.getPayees(),
|
||||
api.getAccounts(),
|
||||
api.getCategories(),
|
||||
]);
|
||||
const payeeMap = {};
|
||||
for (const p of payees) payeeMap[p.id] = p.name;
|
||||
const accountMap = {};
|
||||
for (const a of accounts) accountMap[a.id] = a.name;
|
||||
const categoryMap = {};
|
||||
for (const c of categories) categoryMap[c.id] = c.name;
|
||||
return { payeeMap, accountMap, categoryMap };
|
||||
}
|
||||
|
||||
/** Enrich a transaction with resolved names. */
|
||||
function enrichTransaction(t, payeeMap, accountMap, categoryMap) {
|
||||
return {
|
||||
...t,
|
||||
amountDollars: centsToDollars(t.amount),
|
||||
payeeName: payeeMap[t.payee] || t.imported_payee || t.notes || t.payee || '',
|
||||
accountName: t.accountName || accountMap[t.account] || t.account || '',
|
||||
categoryName: categoryMap[t.category] || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', ready });
|
||||
});
|
||||
|
||||
// ---- Accounts -------------------------------------------------------------
|
||||
|
||||
app.get('/accounts', requireReady, async (_req, res) => {
|
||||
try {
|
||||
const accounts = await api.getAccounts();
|
||||
const enriched = await Promise.all(
|
||||
accounts.map(async (acct) => {
|
||||
const balance = await api.getAccountBalance(acct.id);
|
||||
return {
|
||||
...acct,
|
||||
balance,
|
||||
balanceDollars: centsToDollars(balance),
|
||||
};
|
||||
}),
|
||||
);
|
||||
res.json(enriched);
|
||||
} catch (err) {
|
||||
console.error('[budget] GET /accounts error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Transactions (filtered) ----------------------------------------------
|
||||
|
||||
app.get('/transactions', requireReady, async (req, res) => {
|
||||
try {
|
||||
const { accountId, startDate, endDate, limit, offset } = req.query;
|
||||
|
||||
if (!accountId) {
|
||||
return res.status(400).json({ error: 'accountId query parameter is required' });
|
||||
}
|
||||
|
||||
const start = startDate || '1970-01-01';
|
||||
const end = endDate || '2099-12-31';
|
||||
const { payeeMap, accountMap, categoryMap } = await buildLookups();
|
||||
|
||||
let txns = await api.getTransactions(accountId, start, end);
|
||||
|
||||
// Sort newest first
|
||||
txns.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0));
|
||||
|
||||
const total = txns.length;
|
||||
const pageOffset = parseInt(offset, 10) || 0;
|
||||
const pageLimit = parseInt(limit, 10) || 50;
|
||||
txns = txns.slice(pageOffset, pageOffset + pageLimit);
|
||||
|
||||
res.json({
|
||||
total,
|
||||
offset: pageOffset,
|
||||
limit: pageLimit,
|
||||
transactions: txns.map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[budget] GET /transactions error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Recent transactions across all accounts ------------------------------
|
||||
|
||||
app.get('/transactions/recent', requireReady, async (_req, res) => {
|
||||
try {
|
||||
const limit = parseInt(_req.query.limit, 10) || 20;
|
||||
const accounts = await api.getAccounts();
|
||||
const { payeeMap, accountMap, categoryMap } = await buildLookups();
|
||||
|
||||
const daysBack = new Date();
|
||||
daysBack.setDate(daysBack.getDate() - 90);
|
||||
const startDate = daysBack.toISOString().slice(0, 10);
|
||||
const endDate = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let all = [];
|
||||
for (const acct of accounts) {
|
||||
const txns = await api.getTransactions(acct.id, startDate, endDate);
|
||||
all.push(...txns.map((t) => ({ ...t, accountName: acct.name })));
|
||||
}
|
||||
|
||||
all.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0));
|
||||
all = all.slice(0, limit);
|
||||
|
||||
res.json(all.map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap)));
|
||||
} catch (err) {
|
||||
console.error('[budget] GET /transactions/recent error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Categories -----------------------------------------------------------
|
||||
|
||||
app.get('/categories', requireReady, async (_req, res) => {
|
||||
try {
|
||||
const groups = await api.getCategoryGroups();
|
||||
const categories = await api.getCategories();
|
||||
|
||||
const grouped = groups.map((g) => ({
|
||||
...g,
|
||||
categories: categories.filter((c) => c.group_id === g.id),
|
||||
}));
|
||||
|
||||
res.json(grouped);
|
||||
} catch (err) {
|
||||
console.error('[budget] GET /categories error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Budget for a month ---------------------------------------------------
|
||||
|
||||
app.get('/budget/:month', requireReady, async (req, res) => {
|
||||
try {
|
||||
const { month } = req.params;
|
||||
if (!/^\d{4}-\d{2}$/.test(month)) {
|
||||
return res.status(400).json({ error: 'month must be YYYY-MM format' });
|
||||
}
|
||||
const data = await api.getBudgetMonth(month);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error(`[budget] GET /budget/${req.params.month} error:`, err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Update a transaction -------------------------------------------------
|
||||
|
||||
app.patch('/transactions/:id', requireReady, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ error: 'Request body must contain fields to update' });
|
||||
}
|
||||
|
||||
await api.updateTransaction(id, updates);
|
||||
await api.sync();
|
||||
|
||||
res.json({ success: true, id, updated: updates });
|
||||
} catch (err) {
|
||||
console.error(`[budget] PATCH /transactions/${req.params.id} error:`, err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Make Transfer (link two existing transactions) -----------------------
|
||||
|
||||
app.post('/make-transfer', requireReady, async (req, res) => {
|
||||
try {
|
||||
const { transactionId1, transactionId2 } = req.body;
|
||||
if (!transactionId1 || !transactionId2) {
|
||||
return res.status(400).json({ error: 'transactionId1 and transactionId2 are required' });
|
||||
}
|
||||
|
||||
// Get payees to find transfer payees for each account
|
||||
const payees = await api.getPayees();
|
||||
const accounts = await api.getAccounts();
|
||||
|
||||
// We need to figure out which account each transaction belongs to
|
||||
// Fetch both transactions by getting all recent + searching
|
||||
const allAccounts = await api.getAccounts();
|
||||
let tx1 = null;
|
||||
let tx2 = null;
|
||||
|
||||
for (const acct of allAccounts) {
|
||||
const txns = await api.getTransactions(acct.id, '1970-01-01', '2099-12-31');
|
||||
if (!tx1) tx1 = txns.find((t) => t.id === transactionId1);
|
||||
if (!tx2) tx2 = txns.find((t) => t.id === transactionId2);
|
||||
if (tx1 && tx2) break;
|
||||
}
|
||||
|
||||
if (!tx1 || !tx2) {
|
||||
return res.status(404).json({ error: 'Could not find one or both transactions' });
|
||||
}
|
||||
|
||||
if (tx1.account === tx2.account) {
|
||||
return res.status(400).json({ error: 'Both transactions are from the same account' });
|
||||
}
|
||||
|
||||
// Per Actual docs: set the transfer payee on ONE transaction.
|
||||
// Actual auto-creates the counterpart on the other account.
|
||||
// Then delete the other original transaction (it's now replaced).
|
||||
|
||||
// Pick the outgoing (negative) transaction to keep, delete the incoming one
|
||||
const keepTx = tx1.amount < 0 ? tx1 : tx2;
|
||||
const deleteTx = tx1.amount < 0 ? tx2 : tx1;
|
||||
const keepId = keepTx === tx1 ? transactionId1 : transactionId2;
|
||||
const deleteId = deleteTx === tx1 ? transactionId1 : transactionId2;
|
||||
|
||||
// Find the transfer payee for the account we're transferring TO (the deleted tx's account)
|
||||
const targetTransferPayee = payees.find((p) => p.transfer_acct === deleteTx.account);
|
||||
|
||||
if (!targetTransferPayee) {
|
||||
return res.status(400).json({ error: 'Could not find transfer payee for destination account' });
|
||||
}
|
||||
|
||||
// Step 1: Set transfer payee on the kept transaction → Actual creates counterpart
|
||||
await api.updateTransaction(keepId, { payee: targetTransferPayee.id });
|
||||
|
||||
// Step 2: Delete the original other transaction (Actual already made a new linked one)
|
||||
await api.deleteTransaction(deleteId);
|
||||
|
||||
await api.sync();
|
||||
|
||||
const acctName1 = accounts.find((a) => a.id === keepTx.account)?.name || keepTx.account;
|
||||
const acctName2 = accounts.find((a) => a.id === deleteTx.account)?.name || deleteTx.account;
|
||||
|
||||
console.log(`[budget] Linked transfer: ${acctName1} <-> ${acctName2} (kept ${keepId}, deleted ${deleteId})`);
|
||||
res.json({
|
||||
success: true,
|
||||
linked: { transactionId1: keepId, transactionId2: deleteId, account1: acctName1, account2: acctName2 },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[budget] POST /make-transfer error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Uncategorized count (total across all accounts) ----------------------
|
||||
|
||||
app.get('/uncategorized-count', requireReady, async (_req, res) => {
|
||||
try {
|
||||
const accounts = await api.getAccounts();
|
||||
const startDate = '2000-01-01';
|
||||
const endDate = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let total = 0;
|
||||
for (const acct of accounts) {
|
||||
if (acct.closed) continue;
|
||||
const txns = await api.getTransactions(acct.id, startDate, endDate);
|
||||
total += txns.filter((t) => !t.category && !t.transfer_id && t.amount !== 0).length;
|
||||
}
|
||||
res.json({ count: total });
|
||||
} catch (err) {
|
||||
console.error('[budget] GET /uncategorized-count error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Suggested transfers --------------------------------------------------
|
||||
|
||||
app.get('/suggested-transfers', requireReady, async (_req, res) => {
|
||||
try {
|
||||
// Sync first to get latest state
|
||||
await api.sync().catch(() => {});
|
||||
const accounts = await api.getAccounts();
|
||||
const { payeeMap, accountMap, categoryMap } = await buildLookups();
|
||||
|
||||
// Get last 90 days of transactions across all accounts
|
||||
const daysBack = new Date();
|
||||
daysBack.setDate(daysBack.getDate() - 90);
|
||||
const startDate = daysBack.toISOString().slice(0, 10);
|
||||
const endDate = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let allTxns = [];
|
||||
for (const acct of accounts) {
|
||||
if (acct.closed) continue;
|
||||
const txns = await api.getTransactions(acct.id, startDate, endDate);
|
||||
allTxns.push(
|
||||
...txns.map((t) => ({
|
||||
...t,
|
||||
accountName: acct.name,
|
||||
payeeName: payeeMap[t.payee] || t.imported_payee || t.notes || '',
|
||||
categoryName: categoryMap[t.category] || null,
|
||||
amountDollars: centsToDollars(t.amount),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Only consider transactions that aren't already transfers and aren't categorized
|
||||
const candidates = allTxns.filter(
|
||||
(t) => !t.transfer_id && t.amount !== 0 && !t.starting_balance_flag,
|
||||
);
|
||||
|
||||
// Payment-related keywords
|
||||
const paymentKeywords = [
|
||||
'payment', 'credit card', 'crd epay', 'online transfer',
|
||||
'transfer from', 'transfer to', 'autopay', 'bill pay',
|
||||
'pymt', 'payment received', 'payment thank',
|
||||
];
|
||||
|
||||
function looksLikePayment(t) {
|
||||
const text = ((t.payeeName || '') + ' ' + (t.notes || '')).toLowerCase();
|
||||
return paymentKeywords.some((kw) => text.includes(kw));
|
||||
}
|
||||
|
||||
// Build index by absolute amount
|
||||
const byAmount = {};
|
||||
for (const t of candidates) {
|
||||
const key = Math.abs(t.amount);
|
||||
if (!byAmount[key]) byAmount[key] = [];
|
||||
byAmount[key].push(t);
|
||||
}
|
||||
|
||||
const suggestions = [];
|
||||
|
||||
for (const [amt, group] of Object.entries(byAmount)) {
|
||||
if (group.length < 2) continue;
|
||||
|
||||
// Find negative (outgoing) and positive (incoming) transactions
|
||||
const negatives = group.filter((t) => t.amount < 0);
|
||||
const positives = group.filter((t) => t.amount > 0);
|
||||
|
||||
for (const neg of negatives) {
|
||||
for (const pos of positives) {
|
||||
// Must be different accounts
|
||||
if (neg.account === pos.account) continue;
|
||||
|
||||
// At least one should look like a payment
|
||||
if (!looksLikePayment(neg) && !looksLikePayment(pos)) continue;
|
||||
|
||||
// Dates should be within 3 days
|
||||
const d1 = new Date(neg.date);
|
||||
const d2 = new Date(pos.date);
|
||||
const daysDiff = Math.abs(d1 - d2) / 86400000;
|
||||
if (daysDiff > 3) continue;
|
||||
|
||||
// Calculate confidence
|
||||
let confidence = 60; // base: matching amount + different accounts
|
||||
if (looksLikePayment(neg) && looksLikePayment(pos)) confidence += 20;
|
||||
else if (looksLikePayment(neg) || looksLikePayment(pos)) confidence += 10;
|
||||
if (daysDiff === 0) confidence += 15;
|
||||
else if (daysDiff <= 1) confidence += 10;
|
||||
else if (daysDiff <= 2) confidence += 5;
|
||||
|
||||
// Check if payee name matches an account name (strong signal)
|
||||
const negPayeeLower = (neg.payeeName || '').toLowerCase();
|
||||
const posPayeeLower = (pos.payeeName || '').toLowerCase();
|
||||
const negAcctLower = (neg.accountName || '').toLowerCase();
|
||||
const posAcctLower = (pos.accountName || '').toLowerCase();
|
||||
if (
|
||||
negPayeeLower.includes(posAcctLower.slice(0, 10)) ||
|
||||
posPayeeLower.includes(negAcctLower.slice(0, 10)) ||
|
||||
negAcctLower.includes(negPayeeLower.slice(0, 10)) ||
|
||||
posAcctLower.includes(posPayeeLower.slice(0, 10))
|
||||
) {
|
||||
confidence += 15;
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
confidence: Math.min(confidence, 100),
|
||||
amount: Math.abs(neg.amount),
|
||||
amountDollars: Math.abs(neg.amountDollars),
|
||||
from: {
|
||||
id: neg.id,
|
||||
account: neg.accountName,
|
||||
accountId: neg.account,
|
||||
payee: neg.payeeName,
|
||||
date: neg.date,
|
||||
notes: neg.notes,
|
||||
},
|
||||
to: {
|
||||
id: pos.id,
|
||||
account: pos.accountName,
|
||||
accountId: pos.account,
|
||||
payee: pos.payeeName,
|
||||
date: pos.date,
|
||||
notes: pos.notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by confidence desc, then amount desc
|
||||
suggestions.sort((a, b) => b.confidence - a.confidence || b.amount - a.amount);
|
||||
|
||||
// Deduplicate — each transaction should only appear in one suggestion
|
||||
const used = new Set();
|
||||
const deduped = [];
|
||||
for (const s of suggestions) {
|
||||
if (used.has(s.from.id) || used.has(s.to.id)) continue;
|
||||
used.add(s.from.id);
|
||||
used.add(s.to.id);
|
||||
deduped.push(s);
|
||||
}
|
||||
|
||||
res.json(deduped);
|
||||
} catch (err) {
|
||||
console.error('[budget] GET /suggested-transfers error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Transfer payees (for "Make Transfer" feature) ------------------------
|
||||
|
||||
app.get('/transfer-payees', requireReady, async (_req, res) => {
|
||||
try {
|
||||
const payees = await api.getPayees();
|
||||
const accounts = await api.getAccounts();
|
||||
const accountMap = {};
|
||||
for (const a of accounts) accountMap[a.id] = a.name;
|
||||
|
||||
// Transfer payees have transfer_acct set — they map to an account
|
||||
const transferPayees = payees
|
||||
.filter((p) => p.transfer_acct)
|
||||
.map((p) => ({
|
||||
payeeId: p.id,
|
||||
payeeName: p.name,
|
||||
accountId: p.transfer_acct,
|
||||
accountName: accountMap[p.transfer_acct] || p.name,
|
||||
}));
|
||||
|
||||
res.json(transferPayees);
|
||||
} catch (err) {
|
||||
console.error('[budget] GET /transfer-payees error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Dashboard summary ----------------------------------------------------
|
||||
|
||||
app.get('/summary', requireReady, async (_req, res) => {
|
||||
try {
|
||||
// Total balance across all accounts
|
||||
const accounts = await api.getAccounts();
|
||||
let totalBalance = 0;
|
||||
for (const acct of accounts) {
|
||||
totalBalance += await api.getAccountBalance(acct.id);
|
||||
}
|
||||
|
||||
// Spending this month
|
||||
const month = currentMonth();
|
||||
const { start, end } = monthBounds(month);
|
||||
|
||||
let allTxns = [];
|
||||
for (const acct of accounts) {
|
||||
const txns = await api.getTransactions(acct.id, start, end);
|
||||
allTxns.push(...txns);
|
||||
}
|
||||
|
||||
// Spending = sum of negative amounts (expenses)
|
||||
const spending = allTxns
|
||||
.filter((t) => t.amount < 0)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
const income = allTxns
|
||||
.filter((t) => t.amount > 0)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
// Top spending categories
|
||||
const categories = await api.getCategories();
|
||||
const catMap = new Map(categories.map((c) => [c.id, c.name]));
|
||||
|
||||
const byCat = {};
|
||||
for (const t of allTxns) {
|
||||
if (t.amount < 0 && t.category) {
|
||||
const name = catMap.get(t.category) || 'Uncategorized';
|
||||
byCat[name] = (byCat[name] || 0) + t.amount;
|
||||
}
|
||||
}
|
||||
|
||||
const topCategories = Object.entries(byCat)
|
||||
.map(([name, amount]) => ({ name, amount, amountDollars: centsToDollars(amount) }))
|
||||
.sort((a, b) => a.amount - b.amount) // most negative first
|
||||
.slice(0, 10);
|
||||
|
||||
res.json({
|
||||
month,
|
||||
totalBalance,
|
||||
totalBalanceDollars: centsToDollars(totalBalance),
|
||||
spending,
|
||||
spendingDollars: centsToDollars(spending),
|
||||
income,
|
||||
incomeDollars: centsToDollars(income),
|
||||
topCategories,
|
||||
accountCount: accounts.length,
|
||||
transactionCount: allTxns.length,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[budget] GET /summary error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Force sync -----------------------------------------------------------
|
||||
|
||||
app.post('/sync', requireReady, async (_req, res) => {
|
||||
try {
|
||||
await api.sync();
|
||||
res.json({ success: true, syncedAt: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
console.error('[budget] POST /sync error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function start() {
|
||||
if (!ACTUAL_PASSWORD) {
|
||||
console.error('[budget] ACTUAL_PASSWORD environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!ACTUAL_SYNC_ID) {
|
||||
console.error('[budget] ACTUAL_SYNC_ID environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await initActual();
|
||||
} catch (err) {
|
||||
console.error('[budget] Failed to initialise Actual connection:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Periodic background sync
|
||||
setInterval(syncBudget, SYNC_INTERVAL_MS);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[budget] Server listening on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
async function shutdown() {
|
||||
console.log('[budget] Shutting down...');
|
||||
try {
|
||||
await api.shutdown();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
start();
|
||||
5
services/fitness/Dockerfile.backend
Normal file
5
services/fitness/Dockerfile.backend
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY server.py .
|
||||
EXPOSE 8095
|
||||
CMD ["python3", "server.py"]
|
||||
28
services/fitness/docker-compose.yml
Normal file
28
services/fitness/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
container_name: calorietracker-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8095:8095"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
env_file: .env
|
||||
environment:
|
||||
- PORT=8095
|
||||
- DATA_DIR=/app/data
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: calorietracker-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8096:3000"
|
||||
environment:
|
||||
- VITE_API_URL=http://calorietracker-backend:8095
|
||||
depends_on:
|
||||
- backend
|
||||
16
services/fitness/frontend-legacy/Dockerfile
Normal file
16
services/fitness/frontend-legacy/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build ./build
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build"]
|
||||
2320
services/fitness/frontend-legacy/package-lock.json
generated
Normal file
2320
services/fitness/frontend-legacy/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
services/fitness/frontend-legacy/package.json
Normal file
28
services/fitness/frontend-legacy/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "calorietracker-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"daisyui": "^5.5.19",
|
||||
"tailwindcss": "^4.2.2"
|
||||
}
|
||||
}
|
||||
25
services/fitness/frontend-legacy/src/app.css
Normal file
25
services/fitness/frontend-legacy/src/app.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin 'daisyui' {
|
||||
themes: night, dim, light;
|
||||
}
|
||||
|
||||
:root {
|
||||
--header-height: 4rem;
|
||||
}
|
||||
|
||||
* {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: oklch(0.2 0.02 260); }
|
||||
::-webkit-scrollbar-thumb { background: oklch(0.4 0.02 260); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: oklch(0.5 0.02 260); }
|
||||
|
||||
/* Card hover elevation */
|
||||
.card-hover {
|
||||
transition: box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.15), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
5
services/fitness/frontend-legacy/src/app.d.ts
vendored
Normal file
5
services/fitness/frontend-legacy/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare global {
|
||||
namespace App {}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
services/fitness/frontend-legacy/src/app.html
Normal file
12
services/fitness/frontend-legacy/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="night">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%237c3aed%22 stroke-width=%222%22><path d=%22M12 20V10%22/><path d=%22M18 20V4%22/><path d=%22M6 20v-4%22/></svg>" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-prerender="true">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
39
services/fitness/frontend-legacy/src/hooks.server.ts
Normal file
39
services/fitness/frontend-legacy/src/hooks.server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
const API_BACKEND = process.env.VITE_API_URL || 'http://localhost:8095';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
if (event.url.pathname.startsWith('/api/') || event.url.pathname.startsWith('/images/')) {
|
||||
const targetUrl = `${API_BACKEND}${event.url.pathname}${event.url.search}`;
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of event.request.headers.entries()) {
|
||||
if (['authorization', 'content-type', 'cookie', 'x-api-key', 'x-telegram-user-id'].includes(key.toLowerCase())) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: event.request.method,
|
||||
headers,
|
||||
body: event.request.method !== 'GET' && event.request.method !== 'HEAD'
|
||||
? await event.request.arrayBuffer()
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Proxy error:', err);
|
||||
return new Response(JSON.stringify({ error: 'Backend unavailable' }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
81
services/fitness/frontend-legacy/src/lib/api/client.ts
Normal file
81
services/fitness/frontend-legacy/src/lib/api/client.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return typeof window !== 'undefined' ? localStorage.getItem('session_token') : null;
|
||||
}
|
||||
|
||||
export function hasToken(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
|
||||
if (!token && typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login';
|
||||
throw new Error('No token');
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string> || {})
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (options.body && typeof options.body === 'string') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('session_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function get<T>(path: string) {
|
||||
return api<T>(path);
|
||||
}
|
||||
|
||||
export function post<T>(path: string, data: unknown) {
|
||||
return api<T>(path, { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function patch<T>(path: string, data: unknown) {
|
||||
return api<T>(path, { method: 'PATCH', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function put<T>(path: string, data: unknown) {
|
||||
return api<T>(path, { method: 'PUT', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function del<T>(path: string) {
|
||||
return api<T>(path, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function today(): string {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export function formatDate(d: string): string {
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', {
|
||||
weekday: 'short', month: 'short', day: 'numeric'
|
||||
});
|
||||
}
|
||||
165
services/fitness/frontend-legacy/src/lib/api/types.ts
Normal file
165
services/fitness/frontend-legacy/src/lib/api/types.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
telegram_user_id?: string;
|
||||
}
|
||||
|
||||
export interface Food {
|
||||
id: string;
|
||||
name: string;
|
||||
brand?: string;
|
||||
barcode?: string;
|
||||
base_unit: string;
|
||||
calories_per_base: number;
|
||||
protein_per_base: number;
|
||||
carbs_per_base: number;
|
||||
fat_per_base: number;
|
||||
status: string;
|
||||
image_path?: string;
|
||||
servings: FoodServing[];
|
||||
aliases?: FoodAlias[];
|
||||
score?: number;
|
||||
match_type?: string;
|
||||
}
|
||||
|
||||
export interface FoodServing {
|
||||
id: string;
|
||||
food_id: string;
|
||||
name: string;
|
||||
amount_in_base: number;
|
||||
is_default: number;
|
||||
}
|
||||
|
||||
export interface FoodAlias {
|
||||
id: string;
|
||||
food_id: string;
|
||||
alias: string;
|
||||
alias_normalized: string;
|
||||
}
|
||||
|
||||
export interface FoodEntry {
|
||||
id: string;
|
||||
user_id: string;
|
||||
food_id?: string;
|
||||
meal_type: string;
|
||||
entry_date: string;
|
||||
entry_type: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
serving_description?: string;
|
||||
snapshot_food_name: string;
|
||||
snapshot_serving_label?: string;
|
||||
snapshot_grams?: number;
|
||||
snapshot_calories: number;
|
||||
snapshot_protein: number;
|
||||
snapshot_carbs: number;
|
||||
snapshot_fat: number;
|
||||
source: string;
|
||||
entry_method: string;
|
||||
raw_text?: string;
|
||||
confidence_score?: number;
|
||||
note?: string;
|
||||
food_image_path?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DailyTotals {
|
||||
total_calories: number;
|
||||
total_protein: number;
|
||||
total_carbs: number;
|
||||
total_fat: number;
|
||||
entry_count: number;
|
||||
}
|
||||
|
||||
export interface Goal {
|
||||
id: string;
|
||||
user_id: string;
|
||||
start_date: string;
|
||||
end_date?: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
is_active: number;
|
||||
}
|
||||
|
||||
export interface MealTemplate {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
meal_type?: string;
|
||||
is_favorite: number;
|
||||
items: MealTemplateItem[];
|
||||
}
|
||||
|
||||
export interface MealTemplateItem {
|
||||
id: string;
|
||||
food_id: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
snapshot_food_name: string;
|
||||
snapshot_calories: number;
|
||||
snapshot_protein: number;
|
||||
snapshot_carbs: number;
|
||||
snapshot_fat: number;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
raw_text: string;
|
||||
proposed_food_id?: string;
|
||||
candidates_json?: string;
|
||||
confidence: number;
|
||||
meal_type?: string;
|
||||
entry_date?: string;
|
||||
source?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ExternalFood {
|
||||
name: string;
|
||||
brand?: string;
|
||||
barcode?: string;
|
||||
calories_per_100g: number;
|
||||
protein_per_100g: number;
|
||||
carbs_per_100g: number;
|
||||
fat_per_100g: number;
|
||||
serving_size_text?: string;
|
||||
serving_grams?: number;
|
||||
source: string;
|
||||
relevance_score?: number;
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
resolution_type: 'matched' | 'confirm' | 'queued' | 'quick_add' | 'ai_estimated' | 'external_match';
|
||||
confidence: number;
|
||||
matched_food?: Food;
|
||||
candidate_foods: Food[];
|
||||
external_results: ExternalFood[];
|
||||
ai_estimate?: Record<string, unknown>;
|
||||
parsed: {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
food_description: string;
|
||||
meal_type?: string;
|
||||
brand?: string;
|
||||
modifiers?: string;
|
||||
exclusions?: string;
|
||||
};
|
||||
raw_text: string;
|
||||
queue_id?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
export const MEAL_TYPES: MealType[] = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||
|
||||
export const MEAL_LABELS: Record<MealType, string> = {
|
||||
breakfast: 'B',
|
||||
lunch: 'L',
|
||||
dinner: 'D',
|
||||
snack: 'S'
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
import { get, post } from '$lib/api/client.ts';
|
||||
import type { Food, MealType, ResolveResult } from '$lib/api/types.ts';
|
||||
import { MEAL_TYPES } from '$lib/api/types.ts';
|
||||
|
||||
let { date = '', defaultMeal = 'snack' as MealType, onSave = () => {}, onClose = () => {} }:
|
||||
{ date: string; defaultMeal: MealType; onSave: () => void; onClose: () => void } = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<Food[]>([]);
|
||||
let recentFoods = $state<Food[]>([]);
|
||||
let searching = $state(false);
|
||||
let saving = $state(false);
|
||||
let selectedFood = $state<Food | null>(null);
|
||||
let selectedServing = $state('');
|
||||
let quantity = $state(1);
|
||||
let mealType = $state<MealType>(defaultMeal);
|
||||
let mode = $state<'search' | 'quick' | 'ai'>('search');
|
||||
let quickCalories = $state(0);
|
||||
let quickName = $state('Quick add');
|
||||
let aiQuery = $state('');
|
||||
let aiResolving = $state(false);
|
||||
let aiResult = $state<ResolveResult & { snapshot_name_override?: string; note?: string } | null>(null);
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
$effect(() => { loadRecent(); });
|
||||
|
||||
async function loadRecent() {
|
||||
try { recentFoods = await get<Food[]>('/api/foods/recent?limit=15'); } catch {}
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
if (!query.trim()) { results = []; return; }
|
||||
searching = true;
|
||||
try { results = await get<Food[]>(`/api/foods/search?q=${encodeURIComponent(query)}&limit=10`); }
|
||||
catch {} finally { searching = false; }
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(doSearch, 300);
|
||||
}
|
||||
|
||||
function selectFood(food: Food) {
|
||||
selectedFood = food;
|
||||
const defaultServing = food.servings?.find(s => s.is_default) || food.servings?.[0];
|
||||
selectedServing = defaultServing?.id || '';
|
||||
quantity = 1;
|
||||
}
|
||||
|
||||
async function aiResolve() {
|
||||
if (!aiQuery.trim()) return;
|
||||
aiResolving = true;
|
||||
aiResult = null;
|
||||
try {
|
||||
const result = await post<ResolveResult>('/api/foods/resolve', {
|
||||
raw_phrase: aiQuery,
|
||||
meal_type: mealType,
|
||||
entry_date: date,
|
||||
source: 'web',
|
||||
});
|
||||
aiResult = result;
|
||||
// If matched or AI estimated, auto-select the food
|
||||
if ((result.resolution_type === 'matched' || result.resolution_type === 'ai_estimated') && result.matched_food) {
|
||||
selectFood(result.matched_food);
|
||||
quantity = result.parsed?.quantity || 1;
|
||||
}
|
||||
} catch {} finally { aiResolving = false; }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
if (mode === 'quick') {
|
||||
await post('/api/entries', {
|
||||
entry_type: 'quick_add',
|
||||
meal_type: mealType,
|
||||
entry_date: date,
|
||||
snapshot_food_name: quickName,
|
||||
snapshot_calories: quickCalories,
|
||||
source: 'web',
|
||||
entry_method: 'quick_add',
|
||||
});
|
||||
} else if (selectedFood) {
|
||||
const entryData: Record<string, unknown> = {
|
||||
food_id: selectedFood.id,
|
||||
meal_type: mealType,
|
||||
entry_date: date,
|
||||
quantity,
|
||||
serving_id: selectedServing || undefined,
|
||||
source: 'web',
|
||||
entry_method: mode === 'ai' ? 'ai_plate' : 'search',
|
||||
raw_text: aiQuery || undefined,
|
||||
};
|
||||
if (aiResult?.snapshot_name_override) {
|
||||
entryData.snapshot_food_name_override = aiResult.snapshot_name_override;
|
||||
}
|
||||
if (aiResult?.note) {
|
||||
entryData.note = aiResult.note;
|
||||
}
|
||||
await post('/api/entries', entryData);
|
||||
}
|
||||
onSave();
|
||||
} catch {} finally { saving = false; }
|
||||
}
|
||||
|
||||
function calcNutrition(food: Food, qty: number, servingId: string) {
|
||||
const serving = food.servings?.find(s => s.id === servingId);
|
||||
const mult = serving ? qty * serving.amount_in_base : qty;
|
||||
return {
|
||||
cal: Math.round(food.calories_per_base * mult),
|
||||
pro: Math.round(food.protein_per_base * mult),
|
||||
carb: Math.round(food.carbs_per_base * mult),
|
||||
fat: Math.round(food.fat_per_base * mult),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="modal modal-open" role="dialog">
|
||||
<div class="modal-box max-w-lg max-h-[90vh]">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onClose}>X</button>
|
||||
<h3 class="font-bold text-lg mb-3">Add Food</h3>
|
||||
|
||||
<!-- Meal type pills -->
|
||||
<div class="flex gap-1 mb-3">
|
||||
{#each MEAL_TYPES as mt}
|
||||
<button class="btn btn-xs" class:btn-primary={mealType === mt} onclick={() => mealType = mt}>
|
||||
{mt}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Mode tabs -->
|
||||
<div role="tablist" class="tabs tabs-boxed mb-3">
|
||||
<button role="tab" class="tab" class:tab-active={mode === 'search'} onclick={() => { mode = 'search'; selectedFood = null; }}>Search</button>
|
||||
<button role="tab" class="tab" class:tab-active={mode === 'ai'} onclick={() => { mode = 'ai'; selectedFood = null; }}>AI Describe</button>
|
||||
<button role="tab" class="tab" class:tab-active={mode === 'quick'} onclick={() => { mode = 'quick'; selectedFood = null; }}>Quick Add</button>
|
||||
</div>
|
||||
|
||||
{#if mode === 'quick'}
|
||||
<div class="flex flex-col gap-3">
|
||||
<input class="input input-bordered w-full" placeholder="Label (optional)" bind:value={quickName} />
|
||||
<input class="input input-bordered w-full" type="number" placeholder="Calories" bind:value={quickCalories} />
|
||||
<button class="btn btn-primary w-full" onclick={save} disabled={saving || quickCalories <= 0}>
|
||||
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
Add {quickCalories} cal to {mealType}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if mode === 'ai' && !selectedFood}
|
||||
<!-- AI describe mode -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<textarea
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="3"
|
||||
placeholder="Describe what you ate, e.g. 2 mince tacos, no sour cream A scoop of vanilla ice cream Homemade smash burger"
|
||||
bind:value={aiQuery}
|
||||
></textarea>
|
||||
<button class="btn btn-primary w-full" onclick={aiResolve} disabled={aiResolving || !aiQuery.trim()}>
|
||||
{#if aiResolving}<span class="loading loading-spinner loading-sm"></span> Estimating...{:else}Estimate with AI{/if}
|
||||
</button>
|
||||
{#if aiResult && aiResult.resolution_type === 'queued'}
|
||||
<div class="alert alert-warning text-sm">
|
||||
<span>Could not estimate. Queued for review.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if aiResult && aiResult.resolution_type === 'confirm' && aiResult.candidate_foods?.length}
|
||||
<div class="text-sm font-medium">Did you mean one of these?</div>
|
||||
{#each aiResult.candidate_foods as c}
|
||||
<button class="btn btn-sm btn-outline w-full justify-between" onclick={() => selectFood(c)}>
|
||||
<span>{c.name}</span>
|
||||
<span class="text-xs">{c.calories_per_base} cal/{c.base_unit}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedFood}
|
||||
<!-- Selected food detail -->
|
||||
<div class="card bg-base-200 p-3 mb-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="font-semibold">{selectedFood.name}</div>
|
||||
{#if selectedFood.brand}<div class="text-xs text-base-content/50">{selectedFood.brand}</div>{/if}
|
||||
{#if selectedFood.status === 'ai_created'}<span class="badge badge-xs badge-info">AI estimated</span>{/if}
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs" onclick={() => selectedFood = null}>Change</button>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-2">
|
||||
<div class="form-control flex-1">
|
||||
<label class="label py-0"><span class="label-text text-xs">Qty</span></label>
|
||||
<input class="input input-bordered input-sm w-full" type="number" min="0.25" step="0.25" bind:value={quantity} />
|
||||
</div>
|
||||
{#if selectedFood.servings?.length > 0}
|
||||
<div class="form-control flex-[2]">
|
||||
<label class="label py-0"><span class="label-text text-xs">Serving</span></label>
|
||||
<select class="select select-bordered select-sm w-full" bind:value={selectedServing}>
|
||||
{#each selectedFood.servings as s}
|
||||
<option value={s.id}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedFood}
|
||||
{@const n = calcNutrition(selectedFood, quantity, selectedServing)}
|
||||
<div class="grid grid-cols-4 gap-2 mt-3 text-center text-sm">
|
||||
<div><div class="font-bold text-primary">{n.cal}</div><div class="text-xs text-base-content/50">cal</div></div>
|
||||
<div><div class="font-bold text-secondary">{n.pro}g</div><div class="text-xs text-base-content/50">protein</div></div>
|
||||
<div><div class="font-bold text-accent">{n.carb}g</div><div class="text-xs text-base-content/50">carbs</div></div>
|
||||
<div><div class="font-bold text-info">{n.fat}g</div><div class="text-xs text-base-content/50">fat</div></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn btn-primary w-full" onclick={save} disabled={saving}>
|
||||
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
Add to {mealType}
|
||||
</button>
|
||||
|
||||
{:else}
|
||||
<!-- Search mode -->
|
||||
<input
|
||||
class="input input-bordered w-full mb-2"
|
||||
placeholder="Search your foods..."
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
/>
|
||||
{#if searching}
|
||||
<div class="flex justify-center py-4"><span class="loading loading-spinner loading-md"></span></div>
|
||||
{:else}
|
||||
<div class="overflow-y-auto max-h-60">
|
||||
{#if query.trim() && results.length > 0}
|
||||
{#each results as food}
|
||||
<button class="w-full text-left px-3 py-2 hover:bg-base-200 rounded-lg flex justify-between items-center" onclick={() => selectFood(food)}>
|
||||
<div>
|
||||
<div class="text-sm font-medium">{food.name}</div>
|
||||
{#if food.brand}<span class="text-xs text-base-content/40">{food.brand}</span>{/if}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if query.trim()}
|
||||
<div class="text-center py-4">
|
||||
<div class="text-sm text-base-content/50 mb-2">No matches found</div>
|
||||
<button class="btn btn-sm btn-outline" onclick={() => { mode = 'ai'; aiQuery = query; }}>
|
||||
Try AI estimate for "{query}"
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if recentFoods.length > 0}
|
||||
<div class="text-xs text-base-content/50 mb-1">Recent</div>
|
||||
{/if}
|
||||
{#each recentFoods as food}
|
||||
<button class="w-full text-left px-3 py-2 hover:bg-base-200 rounded-lg flex justify-between items-center" onclick={() => selectFood(food)}>
|
||||
<div>
|
||||
<div class="text-sm font-medium">{food.name}</div>
|
||||
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if recentFoods.length === 0}
|
||||
<div class="text-center text-sm text-base-content/50 py-4">No foods yet. Use AI Describe to add your first food.</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button onclick={onClose}>close</button></form>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
let { value = 0, goal = 0, label = '', unit = '', color = 'primary' }:
|
||||
{ value: number; goal: number; label: string; unit: string; color: string } = $props();
|
||||
|
||||
let pct = $derived(goal > 0 ? Math.min((value / goal) * 100, 100) : 0);
|
||||
let remaining = $derived(Math.max(goal - value, 0));
|
||||
let over = $derived(value > goal && goal > 0);
|
||||
|
||||
let borderColor = $derived(over ? 'border-l-error' : color === 'primary' ? 'border-l-primary' : color === 'secondary' ? 'border-l-secondary' : color === 'accent' ? 'border-l-accent' : 'border-l-info');
|
||||
let progressClass = $derived(over ? 'progress-error' : color === 'primary' ? 'progress-primary' : color === 'secondary' ? 'progress-secondary' : color === 'accent' ? 'progress-accent' : 'progress-info');
|
||||
</script>
|
||||
|
||||
<div class="bg-base-200 rounded-xl p-4 border-l-4 {borderColor}">
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">{label}</div>
|
||||
<div class="flex items-baseline gap-1.5 mt-1">
|
||||
<span class="text-2xl font-bold">{Math.round(value)}</span>
|
||||
<span class="text-sm text-base-content/40">/ {Math.round(goal)}{unit}</span>
|
||||
</div>
|
||||
<progress class="progress w-full h-1.5 mt-2 {progressClass}" value={pct} max="100"></progress>
|
||||
<div class="text-xs mt-1 {over ? 'text-error' : 'text-base-content/40'}">
|
||||
{#if over}{Math.round(value - goal)}{unit} over{:else}{Math.round(remaining)}{unit} left{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
let theme = $state('night');
|
||||
let { user = null, currentPage = '' }: { user?: { display_name: string } | null; currentPage?: string } = $props();
|
||||
|
||||
function toggleTheme() {
|
||||
theme = theme === 'night' ? 'light' : 'night';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('session_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved) {
|
||||
theme = saved;
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mobileOpen = $state(false);
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Dashboard', page: 'dashboard' },
|
||||
{ href: '/foods', label: 'Foods', page: 'foods' },
|
||||
{ href: '/goals', label: 'Goals', page: 'goals' },
|
||||
{ href: '/templates', label: 'Templates', page: 'templates' },
|
||||
{ href: '/admin', label: 'Admin', page: 'admin' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="bg-base-200/80 backdrop-blur-md sticky top-0 z-50 border-b border-base-300">
|
||||
<div class="navbar px-4">
|
||||
<div class="flex-1 flex items-center gap-1">
|
||||
<!-- Mobile hamburger -->
|
||||
<button class="btn btn-ghost btn-sm btn-circle sm:hidden" onclick={() => mobileOpen = !mobileOpen}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<a href="/" class="flex items-center gap-2 text-xl font-bold text-primary hover:opacity-80 transition-opacity mr-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20V10"/>
|
||||
<path d="M18 20V4"/>
|
||||
<path d="M6 20v-4"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Calories</span>
|
||||
</a>
|
||||
{#each navItems as item}
|
||||
<a href={item.href} class="btn btn-ghost btn-sm hidden sm:inline-flex" class:btn-active={currentPage === item.page}>{item.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex-none flex items-center gap-2">
|
||||
<!-- Theme toggle -->
|
||||
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
|
||||
<input type="checkbox" checked={theme === 'light'} onchange={toggleTheme} />
|
||||
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/></svg>
|
||||
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
|
||||
</label>
|
||||
<!-- User dropdown -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1">
|
||||
<div class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</div>
|
||||
<span class="hidden sm:inline text-sm">{user?.display_name || 'User'}</span>
|
||||
</div>
|
||||
<ul class="dropdown-content menu bg-base-200 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300 mt-2">
|
||||
<li><button onclick={logout}>Logout</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if mobileOpen}
|
||||
<div class="sm:hidden border-t border-base-300 px-4 py-2">
|
||||
{#each navItems as item}
|
||||
<a href={item.href} class="block py-2 px-3 rounded-lg hover:bg-base-300 transition-colors {currentPage === item.page ? 'text-primary font-semibold' : 'text-base-content'}" onclick={() => mobileOpen = false}>{item.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
37
services/fitness/frontend-legacy/src/routes/+layout.svelte
Normal file
37
services/fitness/frontend-legacy/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Navbar from '$lib/components/Navbar.svelte';
|
||||
import { get, hasToken } from '$lib/api/client.ts';
|
||||
import type { User } from '$lib/api/types.ts';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { children } = $props();
|
||||
let user = $state<User | null>(null);
|
||||
|
||||
let currentPage = $derived(
|
||||
page.url.pathname === '/' ? 'dashboard' :
|
||||
page.url.pathname.startsWith('/foods') ? 'foods' :
|
||||
page.url.pathname.startsWith('/goals') ? 'goals' :
|
||||
page.url.pathname.startsWith('/templates') ? 'templates' :
|
||||
page.url.pathname.startsWith('/admin') ? 'admin' : ''
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined' && hasToken() && !page.url.pathname.startsWith('/login')) {
|
||||
get<User>('/api/user').then(u => user = u).catch(() => {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calorie Tracker</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-base-100 flex flex-col">
|
||||
{#if !page.url.pathname.startsWith('/login')}
|
||||
<Navbar {user} {currentPage} />
|
||||
{/if}
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
297
services/fitness/frontend-legacy/src/routes/+page.svelte
Normal file
297
services/fitness/frontend-legacy/src/routes/+page.svelte
Normal file
@@ -0,0 +1,297 @@
|
||||
<script lang="ts">
|
||||
import { get, del, today, formatDate } from '$lib/api/client.ts';
|
||||
import type { FoodEntry, DailyTotals, Goal, User, MealType, QueueItem } from '$lib/api/types.ts';
|
||||
import { MEAL_TYPES } from '$lib/api/types.ts';
|
||||
import AddFoodModal from '$lib/components/AddFoodModal.svelte';
|
||||
|
||||
let selectedDate = $state(today());
|
||||
let entries = $state<FoodEntry[]>([]);
|
||||
let totals = $state<DailyTotals>({ total_calories: 0, total_protein: 0, total_carbs: 0, total_fat: 0, entry_count: 0 });
|
||||
let goal = $state<Goal | null>(null);
|
||||
let users = $state<User[]>([]);
|
||||
let selectedUser = $state('');
|
||||
let showAddModal = $state(false);
|
||||
let addMealType = $state<MealType>('snack');
|
||||
let queueCount = $state(0);
|
||||
let loading = $state(true);
|
||||
let expandedMeals = $state<Set<string>>(new Set(['breakfast', 'lunch', 'dinner', 'snack']));
|
||||
|
||||
let isToday = $derived(selectedDate === today());
|
||||
|
||||
$effect(() => { loadUsers(); });
|
||||
$effect(() => { if (selectedUser) loadDay(); });
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
users = await get<User[]>('/api/users');
|
||||
const me = await get<User>('/api/user');
|
||||
selectedUser = me.id;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadDay() {
|
||||
loading = true;
|
||||
try {
|
||||
const [e, t, g, q] = await Promise.all([
|
||||
get<FoodEntry[]>(`/api/entries?date=${selectedDate}&user_id=${selectedUser}`),
|
||||
get<DailyTotals>(`/api/entries/totals?date=${selectedDate}&user_id=${selectedUser}`),
|
||||
get<Goal>(`/api/goals/for-date?date=${selectedDate}&user_id=${selectedUser}`).catch(() => null),
|
||||
get<QueueItem[]>('/api/resolution-queue').catch(() => []),
|
||||
]);
|
||||
entries = e; totals = t; goal = g; queueCount = q.length;
|
||||
} catch {} finally { loading = false; }
|
||||
}
|
||||
|
||||
function entriesByMeal(meal: MealType) { return entries.filter(e => e.meal_type === meal); }
|
||||
function mealCalories(meal: MealType) { return entriesByMeal(meal).reduce((s, e) => s + e.snapshot_calories, 0); }
|
||||
function mealProtein(meal: MealType) { return entriesByMeal(meal).reduce((s, e) => s + e.snapshot_protein, 0); }
|
||||
|
||||
async function deleteEntry(id: string) { await del(`/api/entries/${id}`); loadDay(); }
|
||||
function openAdd(meal: MealType) { addMealType = meal; showAddModal = true; }
|
||||
|
||||
function shiftDate(days: number) {
|
||||
const d = new Date(selectedDate + 'T00:00:00');
|
||||
d.setDate(d.getDate() + days);
|
||||
selectedDate = d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function toggleMeal(meal: string) {
|
||||
const next = new Set(expandedMeals);
|
||||
if (next.has(meal)) next.delete(meal); else next.add(meal);
|
||||
expandedMeals = next;
|
||||
}
|
||||
|
||||
function userName(id: string) { return users.find(u => u.id === id)?.display_name || ''; }
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- Header row: title, date picker, user — all aligned -->
|
||||
<div class="flex items-center justify-between gap-4 mb-8">
|
||||
<h1 class="text-3xl font-bold">
|
||||
{#if isToday}Today{:else}{formatDate(selectedDate)}{/if}
|
||||
</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => shiftDate(-1)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
<input type="date" class="input input-bordered input-sm" bind:value={selectedDate} />
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => shiftDate(1)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
{#if !isToday}
|
||||
<button class="btn btn-ghost btn-xs" onclick={() => selectedDate = today()}>Today</button>
|
||||
{/if}
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<select class="select select-bordered select-sm" bind:value={selectedUser}>
|
||||
{#each users as u}<option value={u.id}>{u.display_name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-16"><span class="loading loading-spinner loading-lg text-primary"></span></div>
|
||||
{:else}
|
||||
<!-- Review queue -->
|
||||
{#if queueCount > 0}
|
||||
<a href="/admin" class="flex items-center justify-between bg-base-200 rounded-xl p-4 mb-6 border-l-4 border-l-warning">
|
||||
<div>
|
||||
<div class="font-medium text-sm">{queueCount} food{queueCount > 1 ? 's' : ''} need review</div>
|
||||
<div class="text-xs text-base-content/50">Tap to resolve</div>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/30" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Stats cards (trips StatsBar style, with goal progress built in) -->
|
||||
{@const g = goal}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="card bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20 shadow-md hover:shadow-xl transition-shadow duration-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_calories)}</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Calories</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if g}
|
||||
<progress class="progress progress-primary w-full h-1.5 mt-3" value={Math.min(totals.total_calories / g.calories * 100, 100)} max="100"></progress>
|
||||
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.calories - totals.total_calories, 0))} left of {Math.round(g.calories)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-gradient-to-br from-secondary/10 to-secondary/5 border border-secondary/20 shadow-md hover:shadow-xl transition-shadow duration-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-secondary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-secondary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 6.5a3.5 3.5 0 1 0 7 0 3.5 3.5 0 1 0-7 0"/><path d="M1.5 21v-1a7 7 0 0 1 7-7"/><path d="M17.5 12l2 2 4-4"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_protein)}g</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Protein</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if g}
|
||||
<progress class="progress progress-secondary w-full h-1.5 mt-3" value={Math.min(totals.total_protein / g.protein * 100, 100)} max="100"></progress>
|
||||
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.protein - totals.total_protein, 0))}g left of {Math.round(g.protein)}g</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 shadow-md hover:shadow-xl transition-shadow duration-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-accent/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 22 16 8"/><path d="M3.47 12.53 5 11l1.53 1.53a3.5 3.5 0 0 1 0 4.94L5 19l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M7.47 8.53 9 7l1.53 1.53a3.5 3.5 0 0 1 0 4.94L9 15l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M11.47 4.53 13 3l1.53 1.53a3.5 3.5 0 0 1 0 4.94L13 11l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M20 2h2v2a4 4 0 0 1-4 4h-2V6a4 4 0 0 1 4-4Z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_carbs)}g</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Carbs</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if g}
|
||||
<progress class="progress progress-accent w-full h-1.5 mt-3" value={Math.min(totals.total_carbs / g.carbs * 100, 100)} max="100"></progress>
|
||||
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.carbs - totals.total_carbs, 0))}g left of {Math.round(g.carbs)}g</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-gradient-to-br from-info/10 to-info/5 border border-info/20 shadow-md hover:shadow-xl transition-shadow duration-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-info/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/><path d="M8.5 8.5v.01"/><path d="M16 15.5v.01"/><path d="M12 12v.01"/><path d="M11 17v.01"/><path d="M7 14v.01"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_fat)}g</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Fat</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if g}
|
||||
<progress class="progress progress-info w-full h-1.5 mt-3" value={Math.min(totals.total_fat / g.fat * 100, 100)} max="100"></progress>
|
||||
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.fat - totals.total_fat, 0))}g left of {Math.round(g.fat)}g</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !g}
|
||||
<div class="text-center text-sm text-base-content/40 mb-8">
|
||||
<a href="/goals" class="link link-primary">Set goals</a> to see progress bars
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Meals (trip day-view style: collapsible sections with timeline) -->
|
||||
{#each MEAL_TYPES as meal}
|
||||
{@const mealEntries = entriesByMeal(meal)}
|
||||
{@const mCal = mealCalories(meal)}
|
||||
{@const mPro = mealProtein(meal)}
|
||||
{@const expanded = expandedMeals.has(meal)}
|
||||
|
||||
<div class="mb-4">
|
||||
<!-- Meal header (like trip day header) -->
|
||||
<button class="w-full flex items-center gap-3 py-3 group" onclick={() => toggleMeal(meal)}>
|
||||
<div class="w-10 h-10 rounded-full {mealEntries.length > 0 ? 'bg-primary/15' : 'bg-base-300'} flex items-center justify-center shrink-0">
|
||||
<span class="text-sm font-bold {mealEntries.length > 0 ? 'text-primary' : 'text-base-content/30'}">{MEAL_TYPES.indexOf(meal) + 1}</span>
|
||||
</div>
|
||||
<div class="flex-1 text-left">
|
||||
<span class="font-bold capitalize">{meal}</span>
|
||||
{#if mCal > 0}
|
||||
<span class="text-base-content/40 text-sm ml-2">{Math.round(mCal)} cal</span>
|
||||
<span class="text-base-content/30 text-xs ml-1">· {Math.round(mPro)}g protein</span>
|
||||
{/if}
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/30 transition-transform {expanded ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="ml-4 border-l-2 border-base-300 pl-7">
|
||||
{#if mealEntries.length > 0}
|
||||
{#each mealEntries as entry}
|
||||
<div class="bg-base-200 rounded-xl mb-2 overflow-hidden group hover:shadow-md transition-shadow {entry.food_image_path ? '' : 'p-4'}">
|
||||
{#if entry.food_image_path}
|
||||
<div class="relative h-28">
|
||||
<img src="/images/{entry.food_image_path}" alt="" class="w-full h-full object-cover" />
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-base-300/90 via-base-300/30 to-transparent"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-3 flex items-end justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-base-content">{entry.snapshot_food_name}</div>
|
||||
<div class="text-xs text-base-content/60">
|
||||
{entry.serving_description || `${entry.quantity} ${entry.unit}`}
|
||||
{#if entry.entry_method === 'ai_plate' || entry.entry_method === 'ai_label'}
|
||||
<span class="badge badge-xs badge-info">AI</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.note}
|
||||
<div class="text-xs text-base-content/40 italic">{entry.note}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-right ml-3">
|
||||
<div class="font-bold text-base-content">{Math.round(entry.snapshot_calories)}</div>
|
||||
<div class="text-xs text-base-content/60">cal</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-circle absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-base-300/50"
|
||||
onclick={() => deleteEntry(entry.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/50" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium">{entry.snapshot_food_name}</div>
|
||||
<div class="text-xs text-base-content/40 mt-0.5 flex items-center gap-2">
|
||||
<span>{entry.serving_description || `${entry.quantity} ${entry.unit}`}</span>
|
||||
{#if entry.entry_method === 'ai_plate' || entry.entry_method === 'ai_label'}
|
||||
<span class="badge badge-xs badge-info">AI</span>
|
||||
{/if}
|
||||
{#if entry.entry_method === 'quick_add'}
|
||||
<span class="badge badge-xs badge-ghost">quick</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.note}
|
||||
<div class="text-xs text-base-content/30 italic mt-0.5">{entry.note}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-right">
|
||||
<div class="font-bold">{Math.round(entry.snapshot_calories)}</div>
|
||||
<div class="text-xs text-base-content/40">cal</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-circle opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => deleteEntry(entry.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/30" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="py-3 text-sm text-base-content/30">No entries</div>
|
||||
{/if}
|
||||
<button class="btn btn-ghost btn-sm gap-1 text-primary mt-1 mb-4" onclick={() => openAdd(meal)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
Add food
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- FAB for quick add (like trips) -->
|
||||
<button class="btn btn-primary btn-circle btn-lg shadow-lg fixed bottom-6 right-6 z-40" onclick={() => openAdd('snack')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
|
||||
{#if showAddModal}
|
||||
<AddFoodModal date={selectedDate} defaultMeal={addMealType} onSave={() => { showAddModal = false; loadDay(); }} onClose={() => showAddModal = false} />
|
||||
{/if}
|
||||
171
services/fitness/frontend-legacy/src/routes/admin/+page.svelte
Normal file
171
services/fitness/frontend-legacy/src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { get, post } from '$lib/api/client.ts';
|
||||
import type { QueueItem, Food } from '$lib/api/types.ts';
|
||||
|
||||
let queue = $state<QueueItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let tab = $state<'queue' | 'merge'>('queue');
|
||||
|
||||
let mergeSourceQuery = $state('');
|
||||
let mergeTargetQuery = $state('');
|
||||
let mergeSource = $state<Food | null>(null);
|
||||
let mergeTarget = $state<Food | null>(null);
|
||||
let mergeSourceResults = $state<Food[]>([]);
|
||||
let mergeTargetResults = $state<Food[]>([]);
|
||||
let merging = $state(false);
|
||||
|
||||
$effect(() => { loadQueue(); });
|
||||
|
||||
async function loadQueue() {
|
||||
loading = true;
|
||||
try { queue = await get<QueueItem[]>('/api/resolution-queue'); }
|
||||
catch {} finally { loading = false; }
|
||||
}
|
||||
|
||||
function parseCandidates(json: string | undefined): Array<{ food_id: string; name: string; score: number }> {
|
||||
if (!json) return [];
|
||||
try { return JSON.parse(json); } catch { return []; }
|
||||
}
|
||||
|
||||
async function resolveItem(queueId: string, action: string, foodId?: string) {
|
||||
await post(`/api/resolution-queue/${queueId}/resolve`, { action, food_id: foodId });
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
async function searchMerge(query: string, which: 'source' | 'target') {
|
||||
if (!query.trim()) return;
|
||||
const results = await get<Food[]>(`/api/foods/search?q=${encodeURIComponent(query)}&limit=5`);
|
||||
if (which === 'source') mergeSourceResults = results;
|
||||
else mergeTargetResults = results;
|
||||
}
|
||||
|
||||
async function doMerge() {
|
||||
if (!mergeSource || !mergeTarget) return;
|
||||
if (!confirm(`Merge "${mergeSource.name}" into "${mergeTarget.name}"? Source will be archived.`)) return;
|
||||
merging = true;
|
||||
try {
|
||||
await post('/api/foods/merge', { source_id: mergeSource.id, target_id: mergeTarget.id });
|
||||
mergeSource = null; mergeTarget = null;
|
||||
mergeSourceQuery = ''; mergeTargetQuery = '';
|
||||
alert('Merged successfully');
|
||||
} catch {} finally { merging = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold">Admin</h1>
|
||||
<p class="text-base-content/50 text-sm mt-0.5">Review queue and manage duplicates</p>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mb-6">
|
||||
<button role="tab" class="tab gap-2" class:tab-active={tab === 'queue'} onclick={() => tab = 'queue'}>
|
||||
Review Queue
|
||||
{#if queue.length > 0}<span class="badge badge-sm badge-warning">{queue.length}</span>{/if}
|
||||
</button>
|
||||
<button role="tab" class="tab" class:tab-active={tab === 'merge'} onclick={() => tab = 'merge'}>Merge Foods</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'queue'}
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
|
||||
{:else if queue.length === 0}
|
||||
<div class="text-center py-16">
|
||||
<div class="p-4 rounded-2xl bg-success/15 inline-block mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<div class="text-base-content/50">No items to review</div>
|
||||
<div class="text-sm text-base-content/30 mt-1">All caught up!</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each queue as item}
|
||||
{@const candidates = parseCandidates(item.candidates_json)}
|
||||
<div class="rounded-xl border border-warning/20 bg-gradient-to-br from-warning/5 to-warning/0 p-5 shadow-sm card-hover">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div class="font-bold text-lg">"{item.raw_text}"</div>
|
||||
<div class="flex gap-2 mt-1 text-xs text-base-content/40">
|
||||
{#if item.source}<span class="badge badge-xs badge-ghost">{item.source}</span>{/if}
|
||||
<span>confidence: {(item.confidence * 100).toFixed(0)}%</span>
|
||||
{#if item.meal_type}<span>{item.meal_type}</span>{/if}
|
||||
{#if item.entry_date}<span>{item.entry_date}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => resolveItem(item.id, 'dismissed')}>Dismiss</button>
|
||||
</div>
|
||||
|
||||
{#if candidates.length > 0}
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-2">Match to existing</div>
|
||||
<div class="space-y-1.5">
|
||||
{#each candidates as c}
|
||||
<button
|
||||
class="btn btn-sm btn-outline w-full justify-between"
|
||||
onclick={() => resolveItem(item.id, 'matched', c.food_id)}
|
||||
>
|
||||
<span>{c.name}</span>
|
||||
<span class="badge badge-sm badge-ghost">{(c.score * 100).toFixed(0)}%</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm text-base-content/30">No candidates — create a new food manually from the Foods page</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<div class="rounded-xl border border-base-300 bg-base-200/50 p-6 shadow-md">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-xl bg-secondary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary" viewBox="0 0 20 20" fill="currentColor"><path d="M8 5a1 1 0 100 2h5.586l-1.293 1.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L13.586 5H8zM12 15a1 1 0 100-2H6.414l1.293-1.293a1 1 0 10-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L6.414 15H12z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-bold text-lg">Merge Duplicate Foods</h2>
|
||||
<p class="text-xs text-base-content/40">Source gets archived. Entries and aliases move to target.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div class="text-xs text-error font-medium uppercase tracking-wide mb-2">Source (will be archived)</div>
|
||||
<div class="flex gap-1 mb-2">
|
||||
<input class="input input-bordered input-sm flex-1" placeholder="Search..." bind:value={mergeSourceQuery} />
|
||||
<button class="btn btn-sm btn-ghost" onclick={() => searchMerge(mergeSourceQuery, 'source')}>Go</button>
|
||||
</div>
|
||||
{#if mergeSource}
|
||||
<div class="rounded-lg border border-error/30 bg-error/5 p-3 text-sm font-medium">{mergeSource.name}</div>
|
||||
{/if}
|
||||
{#each mergeSourceResults as f}
|
||||
<button class="btn btn-sm btn-ghost w-full justify-start mt-1 text-left" onclick={() => { mergeSource = f; mergeSourceResults = []; }}>
|
||||
{f.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs text-success font-medium uppercase tracking-wide mb-2">Target (will keep)</div>
|
||||
<div class="flex gap-1 mb-2">
|
||||
<input class="input input-bordered input-sm flex-1" placeholder="Search..." bind:value={mergeTargetQuery} />
|
||||
<button class="btn btn-sm btn-ghost" onclick={() => searchMerge(mergeTargetQuery, 'target')}>Go</button>
|
||||
</div>
|
||||
{#if mergeTarget}
|
||||
<div class="rounded-lg border border-success/30 bg-success/5 p-3 text-sm font-medium">{mergeTarget.name}</div>
|
||||
{/if}
|
||||
{#each mergeTargetResults as f}
|
||||
<button class="btn btn-sm btn-ghost w-full justify-start mt-1 text-left" onclick={() => { mergeTarget = f; mergeTargetResults = []; }}>
|
||||
{f.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-warning w-full mt-6 gap-2" onclick={doMerge} disabled={!mergeSource || !mergeTarget || merging}>
|
||||
{#if merging}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
Merge Foods
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
338
services/fitness/frontend-legacy/src/routes/foods/+page.svelte
Normal file
338
services/fitness/frontend-legacy/src/routes/foods/+page.svelte
Normal file
@@ -0,0 +1,338 @@
|
||||
<script lang="ts">
|
||||
import { get, post, patch, del } from '$lib/api/client.ts';
|
||||
import type { Food } from '$lib/api/types.ts';
|
||||
|
||||
let query = $state('');
|
||||
let allFoods = $state<Food[]>([]);
|
||||
let favorites = $state<Food[]>([]);
|
||||
let loading = $state(true);
|
||||
let editFood = $state<Food | null>(null);
|
||||
let showCreate = $state(false);
|
||||
let tab = $state<'all' | 'favorites'>('all');
|
||||
|
||||
// Image picker state
|
||||
let imageQuery = $state('');
|
||||
let imageResults = $state<Array<{url: string; thumbnail: string; title: string}>>([]);
|
||||
let imageSearching = $state(false);
|
||||
let imageSettingUrl = $state('');
|
||||
|
||||
async function searchImages() {
|
||||
if (!imageQuery.trim() || !editFood) return;
|
||||
imageSearching = true;
|
||||
try {
|
||||
const res = await post<{images: Array<{url: string; thumbnail: string; title: string}>}>('/api/images/search', { query: imageQuery + ' food', num: 9 });
|
||||
imageResults = res.images || [];
|
||||
} catch {} finally { imageSearching = false; }
|
||||
}
|
||||
|
||||
async function pickImage(fullUrl: string, thumbnailUrl: string) {
|
||||
if (!editFood) return;
|
||||
imageSettingUrl = fullUrl;
|
||||
const foodId = editFood.id;
|
||||
const urls = [fullUrl, thumbnailUrl];
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const res = await post<{success: boolean; image_path?: string}>(`/api/foods/${foodId}/image`, { url });
|
||||
if (res.success && res.image_path) {
|
||||
if (editFood) editFood.image_path = res.image_path;
|
||||
imageResults = [];
|
||||
imageSettingUrl = '';
|
||||
loadAll();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
imageSettingUrl = '';
|
||||
}
|
||||
|
||||
let newName = $state('');
|
||||
let newBrand = $state('');
|
||||
let newCal = $state(0);
|
||||
let newProtein = $state(0);
|
||||
let newCarbs = $state(0);
|
||||
let newFat = $state(0);
|
||||
let newUnit = $state('100g');
|
||||
let newServingName = $state('');
|
||||
let newServingAmount = $state(1);
|
||||
|
||||
let filtered = $derived(
|
||||
query.trim()
|
||||
? allFoods.filter(f => f.name.toLowerCase().includes(query.toLowerCase()) || (f.brand || '').toLowerCase().includes(query.toLowerCase()))
|
||||
: allFoods
|
||||
);
|
||||
|
||||
$effect(() => { loadAll(); });
|
||||
|
||||
async function loadAll() {
|
||||
loading = true;
|
||||
try {
|
||||
[allFoods, favorites] = await Promise.all([
|
||||
get<Food[]>('/api/foods'),
|
||||
get<Food[]>('/api/favorites'),
|
||||
]);
|
||||
} catch {} finally { loading = false; }
|
||||
}
|
||||
|
||||
async function toggleFavorite(food: Food) {
|
||||
const isFav = favorites.some(f => f.id === food.id);
|
||||
if (isFav) { await del(`/api/favorites/${food.id}`); }
|
||||
else { await post('/api/favorites', { food_id: food.id }); }
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function createFood() {
|
||||
const servings = [{ name: `1 ${newUnit}`, amount_in_base: 1.0, is_default: true }];
|
||||
if (newServingName) servings.push({ name: newServingName, amount_in_base: newServingAmount, is_default: false });
|
||||
await post('/api/foods', {
|
||||
name: newName, brand: newBrand || undefined,
|
||||
calories_per_base: newCal, protein_per_base: newProtein,
|
||||
carbs_per_base: newCarbs, fat_per_base: newFat,
|
||||
base_unit: newUnit, servings,
|
||||
});
|
||||
showCreate = false;
|
||||
newName = ''; newBrand = ''; newCal = 0; newProtein = 0; newCarbs = 0; newFat = 0;
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function deleteFood() {
|
||||
if (!editFood) return;
|
||||
if (!confirm(`Delete "${editFood.name}"? This will archive it.`)) return;
|
||||
await del(`/api/foods/${editFood.id}`);
|
||||
editFood = null;
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function updateFood() {
|
||||
if (!editFood) return;
|
||||
await patch(`/api/foods/${editFood.id}`, {
|
||||
name: editFood.name, brand: editFood.brand,
|
||||
calories_per_base: editFood.calories_per_base,
|
||||
protein_per_base: editFood.protein_per_base,
|
||||
carbs_per_base: editFood.carbs_per_base,
|
||||
fat_per_base: editFood.fat_per_base,
|
||||
status: editFood.status,
|
||||
});
|
||||
editFood = null;
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function addAlias(foodId: string) {
|
||||
const alias = prompt('Add alias:');
|
||||
if (alias) {
|
||||
await post(`/api/foods/${foodId}/aliases`, { alias });
|
||||
if (editFood?.id === foodId) editFood = await get<Food>(`/api/foods/${foodId}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Food Library</h1>
|
||||
<p class="text-base-content/50 text-sm mt-0.5">{allFoods.length} foods saved</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm gap-2" onclick={() => showCreate = true}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
New Food
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mb-4">
|
||||
<button role="tab" class="tab" class:tab-active={tab === 'all'} onclick={() => tab = 'all'}>All ({allFoods.length})</button>
|
||||
<button role="tab" class="tab" class:tab-active={tab === 'favorites'} onclick={() => tab = 'favorites'}>Favorites ({favorites.length})</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'all'}
|
||||
<div class="relative mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/30" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
|
||||
<input class="input input-bordered w-full pl-10" placeholder="Filter foods..." bind:value={query} />
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each filtered as food}
|
||||
<div class="rounded-xl border border-base-300 bg-base-200/50 p-4 shadow-sm card-hover flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{food.name}</span>
|
||||
{#if food.status === 'ai_created'}<span class="badge badge-xs badge-info badge-outline">AI</span>{/if}
|
||||
{#if food.status === 'needs_review'}<span class="badge badge-xs badge-warning">Review</span>{/if}
|
||||
</div>
|
||||
{#if food.brand}<div class="text-xs text-base-content/40">{food.brand}</div>{/if}
|
||||
<div class="flex gap-3 mt-1 text-xs text-base-content/50">
|
||||
<span>{food.calories_per_base} cal</span>
|
||||
<span>{food.protein_per_base}g P</span>
|
||||
<span>{food.carbs_per_base}g C</span>
|
||||
<span>{food.fat_per_base}g F</span>
|
||||
<span class="text-base-content/30">per {food.base_unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 ml-2">
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => toggleFavorite(food)} title="Favorite">
|
||||
{#if favorites.some(f => f.id === food.id)}
|
||||
<span class="text-warning">★</span>
|
||||
{:else}
|
||||
<span class="text-base-content/30">☆</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => { editFood = food; imageResults = []; imageQuery = ''; }} title="Edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if filtered.length === 0}
|
||||
<div class="text-center py-12 text-base-content/30">
|
||||
{#if query.trim()}No foods match "{query}"{:else}No foods yet{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each favorites as food}
|
||||
<div class="rounded-xl border border-warning/20 bg-gradient-to-br from-warning/5 to-warning/0 p-4 shadow-sm card-hover flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-semibold">{food.name}</div>
|
||||
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => toggleFavorite(food)}>
|
||||
<span class="text-warning">★</span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if favorites.length === 0}
|
||||
<div class="text-center py-12 text-base-content/30">No favorites yet</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create food modal -->
|
||||
{#if showCreate}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-lg">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => showCreate = false}>X</button>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg">New Food</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<input class="input input-bordered" placeholder="Food name *" bind:value={newName} />
|
||||
<input class="input input-bordered" placeholder="Brand (optional)" bind:value={newBrand} />
|
||||
<select class="select select-bordered" bind:value={newUnit}>
|
||||
<option value="100g">Per 100g</option>
|
||||
<option value="piece">Per piece</option>
|
||||
<option value="serving">Per serving</option>
|
||||
<option value="scoop">Per scoop</option>
|
||||
<option value="slice">Per slice</option>
|
||||
</select>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input class="input input-bordered" type="number" placeholder="Calories" bind:value={newCal} />
|
||||
<input class="input input-bordered" type="number" placeholder="Protein (g)" bind:value={newProtein} />
|
||||
<input class="input input-bordered" type="number" placeholder="Carbs (g)" bind:value={newCarbs} />
|
||||
<input class="input input-bordered" type="number" placeholder="Fat (g)" bind:value={newFat} />
|
||||
</div>
|
||||
<div class="divider text-xs my-0">Optional Serving</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input class="input input-bordered input-sm" placeholder="e.g. 1 cup (240g)" bind:value={newServingName} />
|
||||
<input class="input input-bordered input-sm" type="number" step="0.01" placeholder="Base multiplier" bind:value={newServingAmount} />
|
||||
</div>
|
||||
<button class="btn btn-primary mt-1" onclick={createFood} disabled={!newName}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button onclick={() => showCreate = false}>close</button></form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit food modal -->
|
||||
{#if editFood}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-lg">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => editFood = null}>X</button>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-xl bg-info/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg">Edit: {editFood.name}</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<input class="input input-bordered" bind:value={editFood.name} />
|
||||
<input class="input input-bordered" placeholder="Brand" bind:value={editFood.brand} />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.calories_per_base} />
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.protein_per_base} />
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.carbs_per_base} />
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.fat_per_base} />
|
||||
</div>
|
||||
<select class="select select-bordered" bind:value={editFood.status}>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="ai_created">AI Created</option>
|
||||
<option value="needs_review">Needs Review</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
{#if editFood.aliases}
|
||||
<div class="text-sm font-medium">Aliases</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each editFood.aliases as a}
|
||||
<span class="badge badge-sm badge-outline">{a.alias}</span>
|
||||
{/each}
|
||||
<button class="badge badge-sm badge-primary badge-outline cursor-pointer" onclick={() => addAlias(editFood!.id)}>+ add</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Image picker -->
|
||||
<div class="divider text-xs my-1">Image</div>
|
||||
{#if editFood.image_path}
|
||||
<div class="relative rounded-lg overflow-hidden h-32">
|
||||
<img src="/images/{editFood.image_path}" alt="" class="w-full h-full object-cover" />
|
||||
<div class="absolute bottom-2 right-2">
|
||||
<button class="btn btn-xs btn-ghost bg-base-300/70" onclick={() => { imageQuery = editFood!.name; searchImages(); }}>Change</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="btn btn-sm btn-outline w-full" onclick={() => { imageQuery = editFood!.name; searchImages(); }}>
|
||||
Search for image
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if imageResults.length > 0 || imageSearching}
|
||||
<div class="flex gap-1 mt-2">
|
||||
<input class="input input-bordered input-sm flex-1" placeholder="Search images..." bind:value={imageQuery} onkeydown={(e) => { if (e.key === 'Enter') searchImages(); }} />
|
||||
<button class="btn btn-sm" onclick={searchImages} disabled={imageSearching}>
|
||||
{#if imageSearching}<span class="loading loading-spinner loading-xs"></span>{:else}Search{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 mt-2 max-h-48 overflow-y-auto">
|
||||
{#each imageResults as img}
|
||||
<button
|
||||
class="relative rounded-lg overflow-hidden h-20 hover:ring-2 hover:ring-primary transition-all {imageSettingUrl === img.url ? 'opacity-50' : ''}"
|
||||
onclick={() => pickImage(img.url, img.thumbnail)}
|
||||
disabled={!!imageSettingUrl}
|
||||
>
|
||||
<img src={img.thumbnail} alt={img.title} class="w-full h-full object-cover" />
|
||||
{#if imageSettingUrl === img.url}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-base-300/50">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button class="btn btn-primary flex-1" onclick={updateFood}>Save</button>
|
||||
<button class="btn btn-error btn-outline" onclick={deleteFood}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button onclick={() => editFood = null}>close</button></form>
|
||||
</div>
|
||||
{/if}
|
||||
136
services/fitness/frontend-legacy/src/routes/goals/+page.svelte
Normal file
136
services/fitness/frontend-legacy/src/routes/goals/+page.svelte
Normal file
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { get, put } from '$lib/api/client.ts';
|
||||
import type { Goal, User } from '$lib/api/types.ts';
|
||||
import { today } from '$lib/api/client.ts';
|
||||
|
||||
let users = $state<User[]>([]);
|
||||
let selectedUser = $state('');
|
||||
let goals = $state<Goal[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
let calories = $state(2000);
|
||||
let protein = $state(150);
|
||||
let carbs = $state(200);
|
||||
let fat = $state(65);
|
||||
let startDate = $state(today());
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => { loadUsers(); });
|
||||
$effect(() => { if (selectedUser) loadGoals(); });
|
||||
|
||||
async function loadUsers() {
|
||||
users = await get<User[]>('/api/users');
|
||||
const me = await get<User>('/api/user');
|
||||
selectedUser = me.id;
|
||||
}
|
||||
|
||||
async function loadGoals() {
|
||||
loading = true;
|
||||
try {
|
||||
goals = await get<Goal[]>(`/api/goals?user_id=${selectedUser}`);
|
||||
const current = goals.find(g => g.is_active);
|
||||
if (current) {
|
||||
calories = current.calories; protein = current.protein;
|
||||
carbs = current.carbs; fat = current.fat;
|
||||
}
|
||||
} catch {} finally { loading = false; }
|
||||
}
|
||||
|
||||
async function saveGoal() {
|
||||
saving = true;
|
||||
try {
|
||||
await put('/api/goals', { user_id: selectedUser, start_date: startDate, calories, protein, carbs, fat });
|
||||
loadGoals();
|
||||
} catch {} finally { saving = false; }
|
||||
}
|
||||
|
||||
function userName(id: string) { return users.find(u => u.id === id)?.display_name || ''; }
|
||||
</script>
|
||||
|
||||
<div class="max-w-2xl mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Goals</h1>
|
||||
<p class="text-base-content/50 text-sm mt-0.5">Daily nutrition targets</p>
|
||||
</div>
|
||||
<select class="select select-bordered select-sm" bind:value={selectedUser}>
|
||||
{#each users as u}
|
||||
<option value={u.id}>{u.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
|
||||
{:else}
|
||||
<!-- Set goals card -->
|
||||
<div class="rounded-xl border border-primary/20 bg-gradient-to-br from-primary/10 to-primary/5 p-6 mb-8 shadow-md">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<h2 class="font-bold text-lg">Set Goals for {userName(selectedUser)}</h2>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Start Date</div>
|
||||
<input type="date" class="input input-bordered input-sm w-full max-w-xs" bind:value={startDate} />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Calories</div>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={calories} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Protein (g)</div>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={protein} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Carbs (g)</div>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={carbs} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Fat (g)</div>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={fat} />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary gap-2 mt-1" onclick={saveGoal} disabled={saving}>
|
||||
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
Save Goals
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goal history -->
|
||||
{#if goals.length > 0}
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-2 h-2 rounded-full bg-info"></div>
|
||||
<h2 class="text-xl font-bold">History</h2>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each goals as g}
|
||||
<div class="rounded-xl border p-4 shadow-sm card-hover {g.is_active ? 'border-primary/30 bg-gradient-to-br from-primary/5 to-transparent' : 'border-base-300 bg-base-200/50'}">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
{g.start_date}{g.end_date ? ` — ${g.end_date}` : ''}
|
||||
</div>
|
||||
<div class="flex gap-3 text-xs text-base-content/50 mt-1">
|
||||
<span>{g.calories} cal</span>
|
||||
<span>{g.protein}g P</span>
|
||||
<span>{g.carbs}g C</span>
|
||||
<span>{g.fat}g F</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if g.is_active}
|
||||
<span class="badge badge-primary badge-sm">Active</span>
|
||||
{:else}
|
||||
<span class="badge badge-ghost badge-sm">Ended</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.trim() || !password) { error = 'Enter username and password'; return; }
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.trim(), password }),
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
localStorage.setItem('session_token', data.token);
|
||||
goto('/');
|
||||
} else { error = 'Invalid credentials'; }
|
||||
} catch { error = 'Connection failed'; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="card bg-base-200 shadow-xl w-full max-w-md border border-base-300">
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-6">
|
||||
<div class="p-4 rounded-2xl bg-primary/15 inline-block mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20V10"/>
|
||||
<path d="M18 20V4"/>
|
||||
<path d="M6 20v-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-base-content">Calorie Tracker</h2>
|
||||
<p class="text-base-content/50 text-sm mt-1">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error text-sm py-2"><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}>
|
||||
<div class="form-control">
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Username</div>
|
||||
<input type="text" class="input input-bordered w-full" bind:value={username} />
|
||||
</div>
|
||||
<div class="form-control mt-3">
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Password</div>
|
||||
<input type="password" class="input input-bordered w-full" bind:value={password} />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full mt-5 gap-2" disabled={loading}>
|
||||
{#if loading}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { get, post, del } from '$lib/api/client.ts';
|
||||
import type { MealTemplate, MealType } from '$lib/api/types.ts';
|
||||
import { MEAL_TYPES } from '$lib/api/types.ts';
|
||||
import { today } from '$lib/api/client.ts';
|
||||
|
||||
let templates = $state<MealTemplate[]>([]);
|
||||
let loading = $state(true);
|
||||
let logDate = $state(today());
|
||||
let logMeal = $state<MealType>('lunch');
|
||||
let loggingId = $state('');
|
||||
|
||||
$effect(() => { loadTemplates(); });
|
||||
|
||||
async function loadTemplates() {
|
||||
loading = true;
|
||||
try { templates = await get<MealTemplate[]>('/api/templates'); }
|
||||
catch {} finally { loading = false; }
|
||||
}
|
||||
|
||||
async function logTemplate(id: string) {
|
||||
loggingId = id;
|
||||
try {
|
||||
await post(`/api/templates/${id}/log`, { meal_type: logMeal, entry_date: logDate });
|
||||
alert('Logged!');
|
||||
} catch {} finally { loggingId = ''; }
|
||||
}
|
||||
|
||||
async function deleteTemplate(id: string) {
|
||||
if (confirm('Archive this template?')) {
|
||||
await del(`/api/templates/${id}`);
|
||||
loadTemplates();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold">Meal Templates</h1>
|
||||
<p class="text-base-content/50 text-sm mt-0.5">Save and reuse your favorite meals</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-info/20 bg-gradient-to-br from-info/10 to-info/5 p-4 mb-6 shadow-md flex items-center gap-4 flex-wrap">
|
||||
<span class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Log to:</span>
|
||||
<input type="date" class="input input-bordered input-sm" bind:value={logDate} />
|
||||
<select class="select select-bordered select-sm" bind:value={logMeal}>
|
||||
{#each MEAL_TYPES as mt}
|
||||
<option value={mt}>{mt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
|
||||
{:else if templates.length === 0}
|
||||
<div class="text-center py-16">
|
||||
<div class="p-4 rounded-2xl bg-primary/15 inline-block mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>
|
||||
</div>
|
||||
<div class="text-base-content/50">No templates yet</div>
|
||||
<div class="text-sm text-base-content/30 mt-1">Templates can be created from the daily log or via API</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each templates as t}
|
||||
<div class="rounded-xl border border-base-300 bg-base-200/50 p-5 shadow-sm card-hover">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold text-lg">{t.name}</span>
|
||||
{#if t.meal_type}<span class="badge badge-sm badge-outline">{t.meal_type}</span>{/if}
|
||||
{#if t.is_favorite}<span class="text-warning">★</span>{/if}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-primary btn-sm gap-1" onclick={() => logTemplate(t.id)} disabled={loggingId === t.id}>
|
||||
{#if loggingId === t.id}<span class="loading loading-spinner loading-xs"></span>{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
{/if}
|
||||
Log
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => deleteTemplate(t.id)}>Archive</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if t.items?.length > 0}
|
||||
<div class="space-y-1.5">
|
||||
{#each t.items as item}
|
||||
<div class="flex justify-between text-sm text-base-content/70">
|
||||
<span>{item.snapshot_food_name} <span class="text-base-content/30">x{item.quantity}</span></span>
|
||||
<span class="font-medium">{Math.round(item.snapshot_calories * item.quantity)} cal</span>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="border-t border-base-300/50 pt-2 mt-2 flex justify-between font-bold text-sm">
|
||||
<span>Total</span>
|
||||
<span>{Math.round(t.items.reduce((s, i) => s + i.snapshot_calories * i.quantity, 0))} cal</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
14
services/fitness/frontend-legacy/svelte.config.js
Normal file
14
services/fitness/frontend-legacy/svelte.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
},
|
||||
vitePlugin: {
|
||||
dynamicCompileOptions: ({ filename }) =>
|
||||
filename.includes('node_modules') ? undefined : { runes: true }
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
15
services/fitness/frontend-legacy/tsconfig.json
Normal file
15
services/fitness/frontend-legacy/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
19
services/fitness/frontend-legacy/vite.config.ts
Normal file
19
services/fitness/frontend-legacy/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8095',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/images': {
|
||||
target: 'http://localhost:8095',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
2695
services/fitness/server.py
Normal file
2695
services/fitness/server.py
Normal file
File diff suppressed because it is too large
Load Diff
18
services/inventory/Dockerfile
Normal file
18
services/inventory/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
19
services/inventory/package.json
Executable file
19
services/inventory/package.json
Executable file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "nocodb-photo-uploader",
|
||||
"version": "1.0.0",
|
||||
"description": "Simple photo uploader for NocoDB",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
2137
services/inventory/server.js
Executable file
2137
services/inventory/server.js
Executable file
File diff suppressed because it is too large
Load Diff
11
services/trips/Dockerfile
Normal file
11
services/trips/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN pip install --no-cache-dir PyPDF2
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python3", "server.py"]
|
||||
29
services/trips/docker-compose.yml
Normal file
29
services/trips/docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
trips:
|
||||
build: .
|
||||
container_name: trips
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8087:8087"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=America/Chicago
|
||||
|
||||
trips-frontend:
|
||||
build: ./frontend
|
||||
container_name: trips-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8091:3000"
|
||||
environment:
|
||||
- ORIGIN=https://trips.quadjourney.com
|
||||
- VITE_API_URL=http://trips:8087
|
||||
- TRIPS_API_KEY=${TRIPS_API_KEY}
|
||||
- IMMICH_URL=${IMMICH_URL}
|
||||
- IMMICH_API_KEY=${IMMICH_API_KEY}
|
||||
- TZ=America/Chicago
|
||||
depends_on:
|
||||
- trips
|
||||
115
services/trips/email-worker/README.md
Normal file
115
services/trips/email-worker/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Trips Email Worker
|
||||
|
||||
Forward booking confirmation emails to your Trips app for automatic parsing.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Email arrives at `travel@quadjourney.com`
|
||||
2. Cloudflare Email Routing forwards to this Worker
|
||||
3. Worker extracts email content and attachments
|
||||
4. Sends to Trips API for AI parsing
|
||||
5. You get a Telegram notification with parsed details
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Generate an API Key
|
||||
|
||||
Generate a secure random key:
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### 2. Add API Key to Trips Docker
|
||||
|
||||
In your `docker-compose.yml`, add the environment variable:
|
||||
```yaml
|
||||
services:
|
||||
trips:
|
||||
environment:
|
||||
- EMAIL_API_KEY=your-generated-key-here
|
||||
- TELEGRAM_BOT_TOKEN=your-bot-token # Optional
|
||||
- TELEGRAM_CHAT_ID=your-chat-id # Optional
|
||||
```
|
||||
|
||||
Restart the container:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. Create Cloudflare Worker
|
||||
|
||||
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) → Workers & Pages
|
||||
2. Click "Create Worker"
|
||||
3. Name it `trips-email-worker`
|
||||
4. Paste the contents of `worker.js`
|
||||
5. Click "Deploy"
|
||||
|
||||
### 4. Add Worker Secrets
|
||||
|
||||
In the Worker settings, add these environment variables:
|
||||
|
||||
| Variable | Value |
|
||||
|----------|-------|
|
||||
| `TRIPS_API_URL` | `https://trips.quadjourney.com` |
|
||||
| `TRIPS_API_KEY` | Your generated API key |
|
||||
| `FORWARD_TO` | (Optional) Backup email address |
|
||||
|
||||
Or via CLI:
|
||||
```bash
|
||||
cd email-worker
|
||||
npm install wrangler
|
||||
wrangler secret put TRIPS_API_KEY
|
||||
```
|
||||
|
||||
### 5. Set Up Email Routing
|
||||
|
||||
1. Go to Cloudflare Dashboard → quadjourney.com → Email → Email Routing
|
||||
2. Click "Routing Rules" → "Create address"
|
||||
3. Set:
|
||||
- Custom address: `travel`
|
||||
- Action: "Send to Worker"
|
||||
- Destination: `trips-email-worker`
|
||||
4. Click "Save"
|
||||
|
||||
### 6. Verify DNS
|
||||
|
||||
Cloudflare should auto-configure MX records. Verify:
|
||||
- MX record pointing to Cloudflare's email servers
|
||||
- SPF/DKIM records if sending replies
|
||||
|
||||
## Testing
|
||||
|
||||
Forward a booking confirmation email to `travel@quadjourney.com`.
|
||||
|
||||
Check your Telegram for the parsed result, or check server logs:
|
||||
```bash
|
||||
docker logs trips --tail 50
|
||||
```
|
||||
|
||||
## Supported Email Types
|
||||
|
||||
- Flight confirmations (airlines, booking sites)
|
||||
- Hotel/lodging reservations
|
||||
- Car rental confirmations
|
||||
- Activity bookings
|
||||
|
||||
## Attachments
|
||||
|
||||
The worker can process:
|
||||
- PDF attachments (itineraries, e-tickets)
|
||||
- Image attachments (screenshots)
|
||||
- Plain text/HTML email body
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Worker not receiving emails:**
|
||||
- Check Email Routing is enabled for domain
|
||||
- Verify MX records are configured
|
||||
- Check Worker logs in Cloudflare dashboard
|
||||
|
||||
**API returns 401:**
|
||||
- Verify API key matches in Worker and Docker
|
||||
|
||||
**No Telegram notification:**
|
||||
- Check TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set
|
||||
- Verify bot has permission to message the chat
|
||||
325
services/trips/email-worker/worker.js
Normal file
325
services/trips/email-worker/worker.js
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Cloudflare Email Worker for Trips App
|
||||
* Receives emails at travel@quadjourney.com and forwards to trips API for parsing
|
||||
*/
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
return new Response('Trips Email Worker - Send emails to travel@quadjourney.com', {
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
},
|
||||
|
||||
async email(message, env, ctx) {
|
||||
console.log(`Received email from: ${message.from}`);
|
||||
console.log(`Subject: ${message.headers.get('subject')}`);
|
||||
|
||||
try {
|
||||
// Read the raw email
|
||||
const rawEmail = await new Response(message.raw).text();
|
||||
|
||||
// Debug: log first 500 chars and search for Content-Type
|
||||
console.log('Raw email preview:', rawEmail.substring(0, 500));
|
||||
|
||||
// Find where Content-Type header is
|
||||
const ctIndex = rawEmail.indexOf('Content-Type:');
|
||||
console.log('Content-Type header at position:', ctIndex);
|
||||
if (ctIndex > 0) {
|
||||
console.log('Content-Type section:', rawEmail.substring(ctIndex, ctIndex + 200));
|
||||
}
|
||||
|
||||
// Extract email parts
|
||||
const subject = decodeRFC2047(message.headers.get('subject') || '');
|
||||
const from = message.from;
|
||||
|
||||
// Parse email body and attachments
|
||||
const { textBody, htmlBody, attachments } = parseEmail(rawEmail);
|
||||
|
||||
console.log('Parsed text length:', textBody.length);
|
||||
console.log('Parsed html length:', htmlBody.length);
|
||||
|
||||
// Prepare the content for the trips API
|
||||
const bodyContent = textBody || stripHtml(htmlBody) || '(No text content)';
|
||||
const emailContent = `
|
||||
Subject: ${subject}
|
||||
From: ${from}
|
||||
Date: ${new Date().toISOString()}
|
||||
|
||||
${bodyContent}
|
||||
`.trim();
|
||||
|
||||
console.log('Final content length:', emailContent.length);
|
||||
|
||||
// Send to trips API
|
||||
const result = await sendToTripsAPI(env, emailContent, attachments);
|
||||
|
||||
console.log('Parse result:', JSON.stringify(result));
|
||||
|
||||
// Optionally forward the email to a backup address
|
||||
if (env.FORWARD_TO) {
|
||||
await message.forward(env.FORWARD_TO);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing email:', error);
|
||||
if (env.FORWARD_TO) {
|
||||
await message.forward(env.FORWARD_TO);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode RFC 2047 encoded words (=?UTF-8?Q?...?= or =?UTF-8?B?...?=)
|
||||
*/
|
||||
function decodeRFC2047(str) {
|
||||
if (!str) return '';
|
||||
|
||||
// Match encoded words
|
||||
return str.replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (match, charset, encoding, text) => {
|
||||
try {
|
||||
if (encoding.toUpperCase() === 'B') {
|
||||
// Base64
|
||||
return atob(text);
|
||||
} else if (encoding.toUpperCase() === 'Q') {
|
||||
// Quoted-printable
|
||||
return decodeQuotedPrintable(text.replace(/_/g, ' '));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to decode RFC2047:', e);
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode quoted-printable encoding
|
||||
*/
|
||||
function decodeQuotedPrintable(str) {
|
||||
return str
|
||||
.replace(/=\r?\n/g, '') // Remove soft line breaks
|
||||
.replace(/=([0-9A-Fa-f]{2})/g, (match, hex) => {
|
||||
return String.fromCharCode(parseInt(hex, 16));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse email to extract body and attachments (iterative, handles nested multipart)
|
||||
*/
|
||||
function parseEmail(rawEmail) {
|
||||
const result = {
|
||||
textBody: '',
|
||||
htmlBody: '',
|
||||
attachments: []
|
||||
};
|
||||
|
||||
// Normalize line endings to \r\n
|
||||
const normalizedEmail = rawEmail.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n');
|
||||
|
||||
// Collect all parts to process (use a queue instead of recursion)
|
||||
const partsToProcess = [normalizedEmail];
|
||||
const processedBoundaries = new Set();
|
||||
|
||||
while (partsToProcess.length > 0) {
|
||||
const content = partsToProcess.shift();
|
||||
|
||||
// Find boundary - search more of the content since headers can be long
|
||||
// First try to find the Content-Type header with boundary
|
||||
const boundaryMatch = content.match(/Content-Type:.*?boundary="?([^"\r\n;]+)"?/is);
|
||||
|
||||
console.log('Searching for boundary, found:', boundaryMatch ? boundaryMatch[1] : 'NONE');
|
||||
|
||||
if (boundaryMatch) {
|
||||
const boundary = boundaryMatch[1].trim();
|
||||
console.log('Found boundary:', boundary);
|
||||
|
||||
// Skip if we've already processed this boundary
|
||||
if (processedBoundaries.has(boundary)) {
|
||||
continue;
|
||||
}
|
||||
processedBoundaries.add(boundary);
|
||||
|
||||
// Split by boundary
|
||||
const parts = content.split('--' + boundary);
|
||||
console.log('Split into', parts.length, 'parts');
|
||||
|
||||
for (let i = 1; i < parts.length; i++) { // Skip first part (preamble)
|
||||
const part = parts[i];
|
||||
if (part.trim() === '--' || part.trim() === '') continue;
|
||||
|
||||
// Check if this part has its own boundary (nested multipart)
|
||||
const partHeader = part.substring(0, 1000);
|
||||
const nestedBoundary = partHeader.match(/boundary="?([^"\r\n;]+)"?/i);
|
||||
if (nestedBoundary) {
|
||||
console.log('Found nested boundary:', nestedBoundary[1]);
|
||||
// Add to queue for processing
|
||||
partsToProcess.push(part);
|
||||
} else if (part.includes('Content-Type:')) {
|
||||
// Extract content from this part
|
||||
extractPartContent(part, result);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not multipart - try to extract content directly
|
||||
const bodyStart = content.indexOf('\r\n\r\n');
|
||||
if (bodyStart !== -1) {
|
||||
const headers = content.substring(0, bodyStart).toLowerCase();
|
||||
const body = content.substring(bodyStart + 4);
|
||||
|
||||
if (!result.textBody && !headers.includes('content-type:')) {
|
||||
// Plain text email without explicit content-type
|
||||
result.textBody = body;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decode quoted-printable in results
|
||||
if (result.textBody) {
|
||||
result.textBody = decodeQuotedPrintable(result.textBody);
|
||||
}
|
||||
if (result.htmlBody) {
|
||||
result.htmlBody = decodeQuotedPrintable(result.htmlBody);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content from a single MIME part
|
||||
*/
|
||||
function extractPartContent(part, result) {
|
||||
const contentTypeMatch = part.match(/Content-Type:\s*([^\r\n;]+)/i);
|
||||
const contentType = contentTypeMatch ? contentTypeMatch[1].toLowerCase().trim() : '';
|
||||
const contentDisposition = part.match(/Content-Disposition:\s*([^\r\n]+)/i)?.[1] || '';
|
||||
|
||||
console.log('Extracting part with Content-Type:', contentType);
|
||||
|
||||
// Find the body (after double newline)
|
||||
const bodyStart = part.indexOf('\r\n\r\n');
|
||||
if (bodyStart === -1) {
|
||||
console.log('No body separator found in part');
|
||||
return;
|
||||
}
|
||||
|
||||
let body = part.substring(bodyStart + 4);
|
||||
console.log('Body length before trim:', body.length);
|
||||
|
||||
// Remove trailing boundary markers
|
||||
const boundaryIndex = body.indexOf('\r\n--');
|
||||
if (boundaryIndex !== -1) {
|
||||
body = body.substring(0, boundaryIndex);
|
||||
}
|
||||
|
||||
body = body.trim();
|
||||
if (!body) return;
|
||||
|
||||
// Check encoding
|
||||
const isBase64 = part.toLowerCase().includes('content-transfer-encoding: base64');
|
||||
const isQuotedPrintable = part.toLowerCase().includes('content-transfer-encoding: quoted-printable');
|
||||
|
||||
// Decode if needed
|
||||
if (isBase64) {
|
||||
try {
|
||||
body = atob(body.replace(/\s/g, ''));
|
||||
} catch (e) {
|
||||
console.error('Base64 decode failed:', e);
|
||||
}
|
||||
} else if (isQuotedPrintable) {
|
||||
body = decodeQuotedPrintable(body);
|
||||
}
|
||||
|
||||
// Categorize the content
|
||||
if (contentType.includes('text/plain') && !contentDisposition.includes('attachment')) {
|
||||
// Append to existing text (for forwarded emails with multiple text parts)
|
||||
result.textBody += (result.textBody ? '\n\n' : '') + body;
|
||||
} else if (contentType.includes('text/html') && !contentDisposition.includes('attachment')) {
|
||||
result.htmlBody += (result.htmlBody ? '\n\n' : '') + body;
|
||||
} else if (contentDisposition.includes('attachment') ||
|
||||
contentType.includes('application/pdf') ||
|
||||
contentType.includes('image/')) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?([^";\r\n]+)"?/i);
|
||||
const filename = filenameMatch ? filenameMatch[1] : 'attachment';
|
||||
|
||||
result.attachments.push({
|
||||
filename,
|
||||
contentType,
|
||||
data: body.replace(/\s/g, ''),
|
||||
isBase64: isBase64
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from content
|
||||
*/
|
||||
function stripHtml(html) {
|
||||
if (!html) return '';
|
||||
return html
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send parsed content to Trips API
|
||||
*/
|
||||
async function sendToTripsAPI(env, textContent, attachments) {
|
||||
const apiUrl = env.TRIPS_API_URL || 'https://trips.quadjourney.com';
|
||||
const apiKey = env.TRIPS_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('TRIPS_API_KEY not configured');
|
||||
}
|
||||
|
||||
// If we have PDF/image attachments, send the first one
|
||||
if (attachments.length > 0) {
|
||||
const attachment = attachments[0];
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
try {
|
||||
const binaryData = atob(attachment.data);
|
||||
const bytes = new Uint8Array(binaryData.length);
|
||||
for (let i = 0; i < binaryData.length; i++) {
|
||||
bytes[i] = binaryData.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([bytes], { type: attachment.contentType });
|
||||
formData.append('file', blob, attachment.filename);
|
||||
} catch (e) {
|
||||
console.error('Failed to process attachment:', e);
|
||||
}
|
||||
|
||||
formData.append('text', textContent);
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/parse-email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': apiKey
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Text only
|
||||
const response = await fetch(`${apiUrl}/api/parse-email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': apiKey
|
||||
},
|
||||
body: JSON.stringify({ text: textContent })
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
10
services/trips/email-worker/wrangler.toml
Normal file
10
services/trips/email-worker/wrangler.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
name = "trips-email-worker"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
|
||||
[vars]
|
||||
TRIPS_API_URL = "https://trips.quadjourney.com"
|
||||
# FORWARD_TO = "yusuf@example.com" # Optional backup forwarding
|
||||
|
||||
# Add your API key as a secret:
|
||||
# wrangler secret put TRIPS_API_KEY
|
||||
3
services/trips/frontend-legacy/.dockerignore
Normal file
3
services/trips/frontend-legacy/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
23
services/trips/frontend-legacy/.gitignore
vendored
Normal file
23
services/trips/frontend-legacy/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
services/trips/frontend-legacy/.npmrc
Normal file
1
services/trips/frontend-legacy/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
3
services/trips/frontend-legacy/.vscode/extensions.json
vendored
Normal file
3
services/trips/frontend-legacy/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
16
services/trips/frontend-legacy/Dockerfile
Normal file
16
services/trips/frontend-legacy/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build ./build
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build"]
|
||||
42
services/trips/frontend-legacy/README.md
Normal file
42
services/trips/frontend-legacy/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.12.8 create --template minimal --types ts --no-install frontend
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
2320
services/trips/frontend-legacy/package-lock.json
generated
Normal file
2320
services/trips/frontend-legacy/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
services/trips/frontend-legacy/package.json
Normal file
29
services/trips/frontend-legacy/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"daisyui": "^5.5.19",
|
||||
"tailwindcss": "^4.2.2"
|
||||
}
|
||||
}
|
||||
29
services/trips/frontend-legacy/src/app.css
Normal file
29
services/trips/frontend-legacy/src/app.css
Normal file
@@ -0,0 +1,29 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin 'daisyui' {
|
||||
themes: night, dim, light;
|
||||
}
|
||||
|
||||
/* Custom overrides for AdventureLog-style feel */
|
||||
:root {
|
||||
--header-height: 4rem;
|
||||
}
|
||||
|
||||
/* Smooth transitions on theme change */
|
||||
* {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Better scrollbar for dark theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: oklch(0.2 0.02 260);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0.02 260);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.5 0.02 260);
|
||||
}
|
||||
13
services/trips/frontend-legacy/src/app.d.ts
vendored
Normal file
13
services/trips/frontend-legacy/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
13
services/trips/frontend-legacy/src/app.html
Normal file
13
services/trips/frontend-legacy/src/app.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="night">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#1a1d2e" />
|
||||
<title>Trips</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="min-h-screen bg-base-100">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
70
services/trips/frontend-legacy/src/hooks.server.ts
Normal file
70
services/trips/frontend-legacy/src/hooks.server.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
const API_BACKEND = process.env.VITE_API_URL || 'http://localhost:8087';
|
||||
const IMMICH_URL = process.env.IMMICH_URL || '';
|
||||
const IMMICH_API_KEY = process.env.IMMICH_API_KEY || '';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Direct Immich thumbnail proxy (bypass Python backend for speed)
|
||||
if (event.url.pathname.startsWith('/api/immich/thumb/') && IMMICH_URL && IMMICH_API_KEY) {
|
||||
const assetId = event.url.pathname.split('/').pop();
|
||||
try {
|
||||
const response = await fetch(`${IMMICH_URL}/api/assets/${assetId}/thumbnail`, {
|
||||
headers: { 'x-api-key': IMMICH_API_KEY }
|
||||
});
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('Content-Type') || 'image/webp',
|
||||
'Cache-Control': 'public, max-age=86400'
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return new Response('', { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy /api/* and /images/* requests to the Python backend
|
||||
if (event.url.pathname.startsWith('/api/') || event.url.pathname.startsWith('/images/')) {
|
||||
const targetUrl = `${API_BACKEND}${event.url.pathname}${event.url.search}`;
|
||||
|
||||
const headers = new Headers();
|
||||
// Forward relevant headers
|
||||
for (const [key, value] of event.request.headers.entries()) {
|
||||
if (['authorization', 'content-type', 'cookie', 'x-api-key'].includes(key.toLowerCase())) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// For image/asset requests without auth (e.g. <img> tags), use the server-side API key
|
||||
if (!headers.has('authorization') && !headers.has('cookie')) {
|
||||
const apiKey = process.env.TRIPS_API_KEY;
|
||||
if (apiKey) {
|
||||
headers.set('Authorization', `Bearer ${apiKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: event.request.method,
|
||||
headers,
|
||||
body: event.request.method !== 'GET' && event.request.method !== 'HEAD'
|
||||
? await event.request.arrayBuffer()
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Proxy error:', err);
|
||||
return new Response(JSON.stringify({ error: 'Backend unavailable' }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
53
services/trips/frontend-legacy/src/lib/api/client.ts
Normal file
53
services/trips/frontend-legacy/src/lib/api/client.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export function hasToken(): boolean {
|
||||
return typeof window !== 'undefined' && !!localStorage.getItem('api_token');
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('api_token') : null;
|
||||
|
||||
if (!token && typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login';
|
||||
throw new Error('No token');
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string> || {})
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (options.body && typeof options.body === 'string') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('api_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function get<T>(path: string) {
|
||||
return api<T>(path);
|
||||
}
|
||||
|
||||
export function post<T>(path: string, data: unknown) {
|
||||
return api<T>(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
109
services/trips/frontend-legacy/src/lib/api/types.ts
Normal file
109
services/trips/frontend-legacy/src/lib/api/types.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export interface Trip {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
image_path: string | null;
|
||||
cover_image: string | null;
|
||||
share_token: string | null;
|
||||
created_at: string;
|
||||
immich_album_id: string | null;
|
||||
}
|
||||
|
||||
export interface Transportation {
|
||||
id: string;
|
||||
trip_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
flight_number: string;
|
||||
from_location: string;
|
||||
to_location: string;
|
||||
date: string;
|
||||
end_date: string;
|
||||
timezone: string;
|
||||
description: string;
|
||||
link: string;
|
||||
cost_points: number;
|
||||
cost_cash: number;
|
||||
from_place_id: string;
|
||||
to_place_id: string;
|
||||
from_lat: number | null;
|
||||
from_lng: number | null;
|
||||
to_lat: number | null;
|
||||
to_lng: number | null;
|
||||
}
|
||||
|
||||
export interface Lodging {
|
||||
id: string;
|
||||
trip_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
location: string;
|
||||
check_in: string;
|
||||
check_out: string;
|
||||
timezone: string;
|
||||
reservation_number: string;
|
||||
description: string;
|
||||
link: string;
|
||||
cost_points: number;
|
||||
cost_cash: number;
|
||||
place_id: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
id: string;
|
||||
trip_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
category: string;
|
||||
visit_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
link: string;
|
||||
cost_points: number;
|
||||
cost_cash: number;
|
||||
hike_distance: string;
|
||||
hike_difficulty: string;
|
||||
hike_time: string;
|
||||
place_id: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
trip_id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface TripDetail extends Trip {
|
||||
transportations: Transportation[];
|
||||
lodging: Lodging[];
|
||||
locations: Location[];
|
||||
notes: Note[];
|
||||
images: TripImage[];
|
||||
documents: TripDocument[];
|
||||
}
|
||||
|
||||
export interface TripImage {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
file_path: string;
|
||||
is_primary: number;
|
||||
}
|
||||
|
||||
export interface TripDocument {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
file_path: string;
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { post } from '$lib/api/client';
|
||||
|
||||
let {
|
||||
tripId,
|
||||
tripName = '',
|
||||
onClose
|
||||
}: {
|
||||
tripId: string;
|
||||
tripName?: string;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let modal = $state<HTMLDialogElement | null>(null);
|
||||
let loading = $state(false);
|
||||
let content = $state('');
|
||||
let error = $state('');
|
||||
let provider = $state<'gemini' | 'openai'>('gemini');
|
||||
|
||||
let initialized = false;
|
||||
$effect(() => {
|
||||
if (modal && !initialized) {
|
||||
initialized = true;
|
||||
modal.showModal();
|
||||
}
|
||||
});
|
||||
|
||||
async function generate() {
|
||||
loading = true;
|
||||
error = '';
|
||||
content = '';
|
||||
try {
|
||||
const data = await post<{ suggestions?: string; content?: string; error?: string }>('/api/ai-guide', {
|
||||
trip_id: tripId,
|
||||
provider
|
||||
});
|
||||
if (data.error) {
|
||||
error = data.error;
|
||||
} else {
|
||||
content = data.suggestions || data.content || '';
|
||||
}
|
||||
} catch {
|
||||
error = 'Failed to generate guide';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() { modal?.close(); onClose(); }
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal} class="modal modal-bottom sm:modal-middle" onclose={onClose}>
|
||||
<div class="modal-box max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
|
||||
<h3 class="font-bold text-lg flex items-center gap-2 mb-4 shrink-0">
|
||||
<div class="p-2 rounded-xl bg-accent/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
AI Trip Guide
|
||||
<span class="text-sm font-normal text-base-content/40">— {tripName}</span>
|
||||
</h3>
|
||||
|
||||
{#if !content && !loading}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-base-content/60">
|
||||
Generate personalized travel suggestions, tips, and recommendations for your trip using AI with real-time web search.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input type="radio" name="provider" class="radio radio-sm radio-primary" value="gemini" bind:group={provider} />
|
||||
<span class="label-text">Gemini (with web search)</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input type="radio" name="provider" class="radio radio-sm radio-primary" value="openai" bind:group={provider} />
|
||||
<span class="label-text">OpenAI</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error text-sm py-2"><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-primary w-full" onclick={generate}>
|
||||
Generate Trip Guide
|
||||
</button>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="flex-1 flex flex-col items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
||||
<p class="text-sm text-base-content/50">Researching your trip...</p>
|
||||
<p class="text-xs text-base-content/30 mt-1">This may take 15-30 seconds</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div class="prose prose-sm max-w-none text-base-content/80">
|
||||
{@html content.replace(/\n/g, '<br>')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4 pt-4 border-t border-base-300 shrink-0">
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => { content = ''; error = ''; }}>
|
||||
Regenerate
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick={close}>Close</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { post } from '$lib/api/client';
|
||||
|
||||
let {
|
||||
entityType,
|
||||
entityId,
|
||||
documents = [],
|
||||
onUpload
|
||||
}: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
documents: any[];
|
||||
onUpload: () => void;
|
||||
} = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let deletingId = $state('');
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files?.length || !entityId) return;
|
||||
|
||||
uploading = true;
|
||||
try {
|
||||
for (const file of input.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('entity_type', entityType);
|
||||
formData.append('entity_id', entityId);
|
||||
formData.append('file', file);
|
||||
|
||||
const token = localStorage.getItem('api_token') || '';
|
||||
await fetch('/api/document/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
onUpload();
|
||||
} catch (e) {
|
||||
console.error('Upload failed:', e);
|
||||
} finally {
|
||||
uploading = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDoc(docId: string) {
|
||||
deletingId = docId;
|
||||
try {
|
||||
await post('/api/document/delete', { document_id: docId });
|
||||
onUpload();
|
||||
} catch (e) {
|
||||
console.error('Delete failed:', e);
|
||||
} finally {
|
||||
deletingId = '';
|
||||
}
|
||||
}
|
||||
|
||||
function fileIcon(mime: string): string {
|
||||
if (mime?.includes('pdf')) return 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6';
|
||||
if (mime?.includes('image')) return 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM8.5 8.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21';
|
||||
return 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Existing documents -->
|
||||
{#if documents.length > 0}
|
||||
<div class="space-y-1">
|
||||
{#each documents as doc}
|
||||
<div class="flex items-center gap-2 p-2 rounded-lg bg-base-300/30 group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/40 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={fileIcon(doc.mime_type)}/>
|
||||
</svg>
|
||||
<a href={doc.url} target="_blank" class="flex-1 text-sm text-base-content/70 hover:text-primary truncate">
|
||||
{doc.file_name || doc.original_name || 'Document'}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 text-error"
|
||||
onclick={() => deleteDoc(doc.id)}
|
||||
disabled={deletingId === doc.id}
|
||||
>
|
||||
{#if deletingId === doc.id}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
✕
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload button -->
|
||||
{#if entityId}
|
||||
<label class="btn btn-ghost btn-sm gap-2 cursor-pointer">
|
||||
{#if uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
Uploading...
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/>
|
||||
</svg>
|
||||
Add Document
|
||||
{/if}
|
||||
<input type="file" accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png,.webp" multiple class="hidden" onchange={handleFileSelect} disabled={uploading} />
|
||||
</label>
|
||||
{:else}
|
||||
<p class="text-xs text-base-content/30">Save first to add documents</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts">
|
||||
import { post } from '$lib/api/client';
|
||||
import ImmichPicker from './ImmichPicker.svelte';
|
||||
|
||||
let {
|
||||
entityType,
|
||||
entityId,
|
||||
images = [],
|
||||
documents = [],
|
||||
entityName = '',
|
||||
tripStart = '',
|
||||
tripEnd = '',
|
||||
onUpload
|
||||
}: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
images: any[];
|
||||
documents?: any[];
|
||||
entityName?: string;
|
||||
tripStart?: string;
|
||||
tripEnd?: string;
|
||||
onUpload: () => void;
|
||||
} = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let deletingId = $state('');
|
||||
let showImmich = $state(false);
|
||||
let uploadingDoc = $state(false);
|
||||
let deletingDocId = $state('');
|
||||
|
||||
// Google image search
|
||||
let showSearch = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<Array<{ url: string; thumbnail: string; title: string }>>([]);
|
||||
let searching = $state(false);
|
||||
let savingUrl = $state('');
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files?.length || !entityId) return;
|
||||
|
||||
uploading = true;
|
||||
try {
|
||||
for (const file of input.files) {
|
||||
const reader = new FileReader();
|
||||
const base64 = await new Promise<string>((resolve) => {
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
await post('/api/image/upload', {
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
image_data: base64,
|
||||
filename: file.name
|
||||
});
|
||||
}
|
||||
onUpload();
|
||||
} catch (e) {
|
||||
console.error('Upload failed:', e);
|
||||
} finally {
|
||||
uploading = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteImage(imageId: string) {
|
||||
deletingId = imageId;
|
||||
try {
|
||||
await post('/api/image/delete', { id: imageId });
|
||||
onUpload();
|
||||
} catch (e) {
|
||||
console.error('Delete failed:', e);
|
||||
} finally {
|
||||
deletingId = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function searchImages() {
|
||||
if (!searchQuery.trim()) return;
|
||||
searching = true;
|
||||
try {
|
||||
const data = await post<{ images: typeof searchResults }>('/api/image/search', { query: searchQuery });
|
||||
searchResults = data.images || [];
|
||||
} catch {
|
||||
searchResults = [];
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFromUrl(url: string) {
|
||||
savingUrl = url;
|
||||
try {
|
||||
await post('/api/image/upload-from-url', {
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
url
|
||||
});
|
||||
onUpload();
|
||||
showSearch = false;
|
||||
searchResults = [];
|
||||
} catch (e) {
|
||||
console.error('Save from URL failed:', e);
|
||||
} finally {
|
||||
savingUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
searchQuery = entityName || '';
|
||||
showSearch = true;
|
||||
if (searchQuery) searchImages();
|
||||
}
|
||||
|
||||
async function handleDocSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files?.length || !entityId) return;
|
||||
uploadingDoc = true;
|
||||
try {
|
||||
for (const file of input.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('entity_type', entityType);
|
||||
formData.append('entity_id', entityId);
|
||||
formData.append('file', file);
|
||||
const token = localStorage.getItem('api_token') || '';
|
||||
await fetch('/api/document/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
onUpload();
|
||||
} catch (e) { console.error('Doc upload failed:', e); }
|
||||
finally { uploadingDoc = false; input.value = ''; }
|
||||
}
|
||||
|
||||
async function deleteDoc(docId: string) {
|
||||
deletingDocId = docId;
|
||||
try {
|
||||
await post('/api/document/delete', { document_id: docId });
|
||||
onUpload();
|
||||
} catch (e) { console.error('Doc delete failed:', e); }
|
||||
finally { deletingDocId = ''; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Existing images -->
|
||||
{#if images.length > 0}
|
||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||
{#each images as img}
|
||||
<div class="relative shrink-0 group">
|
||||
<img src={img.url} alt="" class="h-24 w-32 object-cover rounded-lg" />
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-1 right-1 btn btn-circle btn-xs bg-error/80 border-0 text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => deleteImage(img.id)}
|
||||
disabled={deletingId === img.id}
|
||||
>
|
||||
{#if deletingId === img.id}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
✕
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Existing documents -->
|
||||
{#if documents.length > 0}
|
||||
<div class="space-y-1">
|
||||
{#each documents as doc}
|
||||
<div class="flex items-center gap-2 p-1.5 rounded-lg bg-base-300/30 group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-base-content/40 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>
|
||||
</svg>
|
||||
<a href={doc.url} target="_blank" class="flex-1 text-xs text-base-content/60 hover:text-primary truncate">
|
||||
{doc.file_name || doc.original_name || 'Document'}
|
||||
</a>
|
||||
<button type="button" class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 text-error" onclick={() => deleteDoc(doc.id)} disabled={deletingDocId === doc.id}>
|
||||
{#if deletingDocId === doc.id}<span class="loading loading-spinner loading-xs"></span>{:else}✕{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons -->
|
||||
{#if entityId}
|
||||
<div class="flex flex-wrap gap-2 justify-center">
|
||||
<label class="btn btn-ghost btn-sm gap-2 cursor-pointer">
|
||||
{#if uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
Uploading...
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
Upload
|
||||
{/if}
|
||||
<input type="file" accept="image/*" multiple class="hidden" onchange={handleFileSelect} disabled={uploading} />
|
||||
</label>
|
||||
|
||||
<button type="button" class="btn btn-ghost btn-sm gap-2" onclick={openSearch}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
Search Web
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-ghost btn-sm gap-2" onclick={() => showImmich = true}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
Immich
|
||||
</button>
|
||||
|
||||
<label class="btn btn-ghost btn-sm gap-2 cursor-pointer">
|
||||
{#if uploadingDoc}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>
|
||||
</svg>
|
||||
Document
|
||||
{/if}
|
||||
<input type="file" accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png,.webp" multiple class="hidden" onchange={handleDocSelect} disabled={uploadingDoc} />
|
||||
</label>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-base-content/30">Save first to add photos</p>
|
||||
{/if}
|
||||
|
||||
<!-- Google Image Search Panel -->
|
||||
{#if showSearch}
|
||||
<div class="border border-base-300 rounded-lg p-3 bg-base-300/30">
|
||||
<div class="flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-sm flex-1"
|
||||
placeholder="Search for images..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); searchImages(); } }}
|
||||
/>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick={searchImages} disabled={searching}>
|
||||
{#if searching}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
Search
|
||||
{/if}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost" onclick={() => { showSearch = false; searchResults = []; }}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if searchResults.length > 0}
|
||||
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2 max-h-48 overflow-y-auto">
|
||||
{#each searchResults as result}
|
||||
<button
|
||||
type="button"
|
||||
class="relative group aspect-square rounded-lg overflow-hidden bg-base-300 hover:ring-2 hover:ring-primary transition-all"
|
||||
onclick={() => saveFromUrl(result.url)}
|
||||
disabled={savingUrl === result.url}
|
||||
>
|
||||
<img src={result.thumbnail} alt={result.title} class="w-full h-full object-cover" />
|
||||
{#if savingUrl === result.url}
|
||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<span class="loading loading-spinner loading-sm text-white"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
|
||||
<span class="text-white opacity-0 group-hover:opacity-100 text-xs font-medium">Save</span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !searching && searchQuery}
|
||||
<p class="text-sm text-base-content/40 text-center py-4">No results</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Immich Picker -->
|
||||
{#if showImmich && entityId}
|
||||
<ImmichPicker {entityType} {entityId} {tripStart} {tripEnd} onImport={() => { showImmich = false; onUpload(); }} onClose={() => showImmich = false} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { post } from '$lib/api/client';
|
||||
|
||||
let {
|
||||
entityType,
|
||||
entityId,
|
||||
tripStart = '',
|
||||
tripEnd = '',
|
||||
onImport,
|
||||
onClose
|
||||
}: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
tripStart?: string;
|
||||
tripEnd?: string;
|
||||
onImport: () => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
interface ImmichPhoto {
|
||||
id: string;
|
||||
thumbnail_url: string;
|
||||
filename: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
let modal = $state<HTMLDialogElement | null>(null);
|
||||
let photos = $state<ImmichPhoto[]>([]);
|
||||
let firstLoad = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let importing = $state<Set<string>>(new Set());
|
||||
let page = $state(1);
|
||||
let total = $state(0);
|
||||
let hasMore = $state(true);
|
||||
let useTripdates = $state(true);
|
||||
let scrollContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
let initialized = false;
|
||||
$effect(() => {
|
||||
if (modal && !initialized) {
|
||||
initialized = true;
|
||||
modal.showModal();
|
||||
fetchPhotos(true);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchPhotos(reset = false) {
|
||||
if (loadingMore) return;
|
||||
|
||||
if (reset) {
|
||||
page = 1;
|
||||
photos = [];
|
||||
hasMore = true;
|
||||
firstLoad = true;
|
||||
}
|
||||
|
||||
loadingMore = true;
|
||||
try {
|
||||
const body: any = { page, per_page: 30 };
|
||||
if (useTripdates && tripStart) body.start_date = tripStart;
|
||||
if (useTripdates && tripEnd) body.end_date = tripEnd;
|
||||
|
||||
const data = await post<{ photos: ImmichPhoto[]; total: number }>('/api/immich/photos', body);
|
||||
const newPhotos = data.photos || [];
|
||||
total = data.total || 0;
|
||||
hasMore = newPhotos.length >= 30;
|
||||
|
||||
if (reset) {
|
||||
photos = newPhotos;
|
||||
} else {
|
||||
photos = photos.concat(newPhotos);
|
||||
}
|
||||
} catch {
|
||||
hasMore = false;
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
firstLoad = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollContainer || loadingMore || !hasMore) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||
if (scrollHeight - scrollTop - clientHeight < 200) {
|
||||
page++;
|
||||
fetchPhotos();
|
||||
}
|
||||
}
|
||||
|
||||
async function importPhoto(photo: ImmichPhoto) {
|
||||
const next = new Set(importing);
|
||||
next.add(photo.id);
|
||||
importing = next;
|
||||
|
||||
try {
|
||||
await post('/api/immich/download', {
|
||||
asset_id: photo.id,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId
|
||||
});
|
||||
onImport();
|
||||
} catch (e) {
|
||||
console.error('Import failed:', e);
|
||||
}
|
||||
|
||||
const done = new Set(importing);
|
||||
done.delete(photo.id);
|
||||
importing = done;
|
||||
}
|
||||
|
||||
function close() { modal?.close(); onClose(); }
|
||||
|
||||
function toggleDateFilter() {
|
||||
useTripdates = !useTripdates;
|
||||
fetchPhotos(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal} class="modal modal-bottom sm:modal-middle" onclose={onClose}>
|
||||
<div class="modal-box max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
|
||||
<h3 class="font-bold text-lg flex items-center gap-2 mb-4 shrink-0">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
</div>
|
||||
Import from Immich
|
||||
</h3>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center justify-between mb-3 shrink-0">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input type="checkbox" class="toggle toggle-sm toggle-primary" checked={useTripdates} onchange={toggleDateFilter} />
|
||||
<span class="label-text text-sm">Trip dates only</span>
|
||||
</label>
|
||||
<span class="text-xs text-base-content/40">{total} photos</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
{#if firstLoad}
|
||||
<div class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if photos.length === 0}
|
||||
<div class="text-center py-12 text-base-content/40">No photos found</div>
|
||||
{:else}
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
onscroll={handleScroll}
|
||||
class="overflow-y-auto flex-1 min-h-0"
|
||||
>
|
||||
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2 p-1">
|
||||
{#each photos as photo (photo.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="relative aspect-square rounded-lg overflow-hidden bg-base-300 hover:ring-2 hover:ring-primary transition-all"
|
||||
onclick={() => importPhoto(photo)}
|
||||
disabled={importing.has(photo.id)}
|
||||
>
|
||||
<img
|
||||
src="/api/immich/thumb/{photo.id}"
|
||||
alt={photo.filename}
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{#if importing.has(photo.id)}
|
||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<span class="loading loading-spinner loading-sm text-white"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loadingMore}
|
||||
<div class="flex justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm text-primary"></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hasMore}
|
||||
<div class="text-center py-3 text-xs text-base-content/30">All photos loaded</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script lang="ts">
|
||||
import { post } from '$lib/api/client';
|
||||
import type { Location } from '$lib/api/types';
|
||||
import PlacesAutocomplete from './PlacesAutocomplete.svelte';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
|
||||
let {
|
||||
location = null,
|
||||
tripId,
|
||||
tripStart = '',
|
||||
tripEnd = '',
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose
|
||||
}: {
|
||||
location: Location | null;
|
||||
tripId: string;
|
||||
tripStart?: string;
|
||||
tripEnd?: string;
|
||||
onSave: (loc: any) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let modal = $state<HTMLDialogElement | null>(null);
|
||||
let saving = $state(false);
|
||||
let deleting = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
|
||||
let form = $state({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
visit_date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
link: '',
|
||||
cost_points: 0,
|
||||
cost_cash: 0,
|
||||
address: '',
|
||||
place_id: '',
|
||||
hike_distance: '',
|
||||
hike_difficulty: '',
|
||||
hike_time: ''
|
||||
});
|
||||
|
||||
let isEdit = $derived(!!location?.id);
|
||||
let images = $derived((location as any)?.images || []);
|
||||
|
||||
$effect(() => {
|
||||
if (location) {
|
||||
form = {
|
||||
name: location.name || '',
|
||||
description: location.description || '',
|
||||
category: location.category || '',
|
||||
visit_date: location.visit_date || '',
|
||||
start_time: location.start_time || '',
|
||||
end_time: location.end_time || '',
|
||||
link: location.link || '',
|
||||
cost_points: location.cost_points || 0,
|
||||
cost_cash: location.cost_cash || 0,
|
||||
address: location.address || '',
|
||||
place_id: location.place_id || '',
|
||||
hike_distance: location.hike_distance || '',
|
||||
hike_difficulty: location.hike_difficulty || '',
|
||||
hike_time: location.hike_time || ''
|
||||
};
|
||||
}
|
||||
modal?.showModal();
|
||||
});
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
const payload = { ...form, trip_id: tripId };
|
||||
if (isEdit) {
|
||||
(payload as any).id = location!.id;
|
||||
await post('/api/location/update', payload);
|
||||
} else {
|
||||
await post('/api/location', payload);
|
||||
}
|
||||
onSave(payload);
|
||||
} catch (e) {
|
||||
console.error('Failed to save location:', e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!location?.id) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await post('/api/location/delete', { id: location.id });
|
||||
onDelete(location.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e);
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
modal?.close();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleModalRefresh() {
|
||||
// Reload the trip to get updated images
|
||||
onSave({});
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
return div.textContent || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal} class="modal modal-bottom sm:modal-middle" onclose={onClose}>
|
||||
<div class="modal-box max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
|
||||
<h3 class="font-bold text-lg flex items-center gap-2 mb-4">
|
||||
<div class="p-2 rounded-xl bg-accent/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
{isEdit ? 'Edit Activity' : 'New Activity'}
|
||||
</h3>
|
||||
|
||||
<!-- Images -->
|
||||
{#if isEdit}
|
||||
<div class="mb-4">
|
||||
<ImageUpload entityType="location" entityId={location?.id || ''} {images} documents={(location as any)?.documents || []} entityName={form.name} {tripStart} {tripEnd} onUpload={handleModalRefresh} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<div class="space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Name</span></label>
|
||||
<PlacesAutocomplete
|
||||
bind:value={form.name}
|
||||
placeholder="Search for a place..."
|
||||
onSelect={(details) => {
|
||||
form.address = details.address || form.address;
|
||||
form.place_id = details.place_id || form.place_id;
|
||||
if (details.category) form.category = details.category;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Category</span></label>
|
||||
<select class="select select-bordered w-full" bind:value={form.category}>
|
||||
<option value="">None</option>
|
||||
<option value="attraction">Attraction</option>
|
||||
<option value="restaurant">Restaurant</option>
|
||||
<option value="cafe">Cafe</option>
|
||||
<option value="bar">Bar</option>
|
||||
<option value="hike">Hike</option>
|
||||
<option value="shopping">Shopping</option>
|
||||
<option value="beach">Beach</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Date</span></label>
|
||||
<input type="date" class="input input-bordered w-full" bind:value={form.visit_date} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Start Time</span></label>
|
||||
<input type="datetime-local" class="input input-bordered w-full" bind:value={form.start_time} />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">End Time</span></label>
|
||||
<input type="datetime-local" class="input input-bordered w-full" bind:value={form.end_time} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Description</span></label>
|
||||
<textarea class="textarea textarea-bordered w-full" rows="3" bind:value={form.description}></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Address</span></label>
|
||||
<input type="text" class="input input-bordered w-full" bind:value={form.address} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Link</span></label>
|
||||
<input type="url" class="input input-bordered w-full" bind:value={form.link} placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Cost (Cash)</span></label>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={form.cost_cash} step="0.01" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Cost (Points)</span></label>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={form.cost_points} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form.category === 'hike'}
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Distance</span></label>
|
||||
<input type="text" class="input input-bordered w-full" bind:value={form.hike_distance} placeholder="5.2 mi" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Difficulty</span></label>
|
||||
<input type="text" class="input input-bordered w-full" bind:value={form.hike_difficulty} placeholder="Moderate" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Time</span></label>
|
||||
<input type="text" class="input input-bordered w-full" bind:value={form.hike_time} placeholder="3h" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-action flex justify-between">
|
||||
<div>
|
||||
{#if isEdit}
|
||||
{#if confirmDelete}
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-error btn-sm" onclick={doDelete} disabled={deleting}>
|
||||
{#if deleting}<span class="loading loading-spinner loading-xs"></span>{/if}
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={() => confirmDelete = false}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class="btn btn-ghost btn-sm text-error" onclick={() => confirmDelete = true}>Delete</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-ghost" onclick={close}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={saving || !form.name.trim()}>
|
||||
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
{isEdit ? 'Save' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import { post } from '$lib/api/client';
|
||||
import type { Lodging } from '$lib/api/types';
|
||||
import PlacesAutocomplete from './PlacesAutocomplete.svelte';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
|
||||
let {
|
||||
lodging = null,
|
||||
tripId,
|
||||
tripStart = '',
|
||||
tripEnd = '',
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose
|
||||
}: {
|
||||
lodging: Lodging | null;
|
||||
tripId: string;
|
||||
tripStart?: string;
|
||||
tripEnd?: string;
|
||||
onSave: (l: any) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let modal = $state<HTMLDialogElement | null>(null);
|
||||
let saving = $state(false);
|
||||
let deleting = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
|
||||
let form = $state({
|
||||
name: '', type: 'hotel', location: '', check_in: '', check_out: '',
|
||||
timezone: '', reservation_number: '', description: '', link: '',
|
||||
cost_points: 0, cost_cash: 0, place_id: ''
|
||||
});
|
||||
|
||||
let isEdit = $derived(!!lodging?.id);
|
||||
let images = $derived((lodging as any)?.images || []);
|
||||
|
||||
$effect(() => {
|
||||
if (lodging) {
|
||||
form = {
|
||||
name: lodging.name || '', type: lodging.type || 'hotel',
|
||||
location: lodging.location || '', check_in: lodging.check_in || '',
|
||||
check_out: lodging.check_out || '', timezone: lodging.timezone || '',
|
||||
reservation_number: lodging.reservation_number || '',
|
||||
description: lodging.description || '', link: lodging.link || '',
|
||||
cost_points: lodging.cost_points || 0, cost_cash: lodging.cost_cash || 0,
|
||||
place_id: lodging.place_id || ''
|
||||
};
|
||||
}
|
||||
modal?.showModal();
|
||||
});
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
const payload = { ...form, trip_id: tripId };
|
||||
if (isEdit) {
|
||||
(payload as any).id = lodging!.id;
|
||||
await post('/api/lodging/update', payload);
|
||||
} else {
|
||||
await post('/api/lodging', payload);
|
||||
}
|
||||
onSave(payload);
|
||||
} catch (e) { console.error('Failed to save lodging:', e); }
|
||||
finally { saving = false; }
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!lodging?.id) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await post('/api/lodging/delete', { id: lodging.id });
|
||||
onDelete(lodging.id);
|
||||
} catch (e) { console.error('Failed to delete:', e); }
|
||||
finally { deleting = false; }
|
||||
}
|
||||
|
||||
function close() { modal?.close(); onClose(); }
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal} class="modal modal-bottom sm:modal-middle" onclose={onClose}>
|
||||
<div class="modal-box max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
|
||||
<h3 class="font-bold text-lg flex items-center gap-2 mb-4">
|
||||
<div class="p-2 rounded-xl bg-secondary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 14h18v7H3z"/><path d="M3 7v7"/><path d="M21 7v7"/><path d="M3 7l9-4 9 4"/>
|
||||
</svg>
|
||||
</div>
|
||||
{isEdit ? 'Edit Lodging' : 'New Lodging'}
|
||||
</h3>
|
||||
|
||||
{#if isEdit}
|
||||
<div class="mb-4">
|
||||
<ImageUpload entityType="lodging" entityId={lodging?.id || ''} {images} documents={(lodging as any)?.documents || []} entityName={form.name} {tripStart} {tripEnd} onUpload={() => onSave({})} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="form-control col-span-2">
|
||||
<label class="label"><span class="label-text">Name</span></label>
|
||||
<PlacesAutocomplete
|
||||
bind:value={form.name}
|
||||
placeholder="Search hotels..."
|
||||
onSelect={(details) => {
|
||||
form.location = details.address || form.location;
|
||||
form.place_id = details.place_id || form.place_id;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Type</span></label>
|
||||
<select class="select select-bordered w-full" bind:value={form.type}>
|
||||
<option value="hotel">Hotel</option>
|
||||
<option value="airbnb">Airbnb</option>
|
||||
<option value="hostel">Hostel</option>
|
||||
<option value="resort">Resort</option>
|
||||
<option value="camping">Camping</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Location / Address</span></label>
|
||||
<input type="text" class="input input-bordered w-full" bind:value={form.location} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Check-in</span></label>
|
||||
<input type="datetime-local" class="input input-bordered w-full" bind:value={form.check_in} />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Check-out</span></label>
|
||||
<input type="datetime-local" class="input input-bordered w-full" bind:value={form.check_out} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Confirmation / Reservation #</span></label>
|
||||
<input type="text" class="input input-bordered w-full" bind:value={form.reservation_number} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Description</span></label>
|
||||
<textarea class="textarea textarea-bordered w-full" rows="2" bind:value={form.description}></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Link</span></label>
|
||||
<input type="url" class="input input-bordered w-full" bind:value={form.link} placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Cost (Cash)</span></label>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={form.cost_cash} step="0.01" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Cost (Points)</span></label>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={form.cost_points} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action flex justify-between">
|
||||
<div>
|
||||
{#if isEdit}
|
||||
{#if confirmDelete}
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-error btn-sm" onclick={doDelete} disabled={deleting}>
|
||||
{#if deleting}<span class="loading loading-spinner loading-xs"></span>{/if}
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={() => confirmDelete = false}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class="btn btn-ghost btn-sm text-error" onclick={() => confirmDelete = true}>Delete</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-ghost" onclick={close}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={saving || !form.name.trim()}>
|
||||
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
{isEdit ? 'Save' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
lat = null,
|
||||
lng = null,
|
||||
name = '',
|
||||
address = '',
|
||||
placeId = ''
|
||||
}: {
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
name?: string;
|
||||
address?: string;
|
||||
placeId?: string;
|
||||
} = $props();
|
||||
|
||||
let showPicker = $state(false);
|
||||
|
||||
function openGoogle(e: Event) {
|
||||
e.stopPropagation();
|
||||
let url;
|
||||
if (lat && lng) {
|
||||
url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
|
||||
} else if (address) {
|
||||
url = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(address)}`;
|
||||
} else if (placeId) {
|
||||
url = `https://www.google.com/maps/dir/?api=1&destination=place_id:${placeId}`;
|
||||
} else {
|
||||
url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(name)}`;
|
||||
}
|
||||
window.open(url, '_blank');
|
||||
showPicker = false;
|
||||
}
|
||||
|
||||
function openApple(e: Event) {
|
||||
e.stopPropagation();
|
||||
let url;
|
||||
if (address) {
|
||||
url = `https://maps.apple.com/?daddr=${encodeURIComponent(address)}&dirflg=d`;
|
||||
} else if (lat && lng) {
|
||||
url = `https://maps.apple.com/?daddr=${lat},${lng}&dirflg=d`;
|
||||
} else {
|
||||
url = `https://maps.apple.com/?q=${encodeURIComponent(name)}`;
|
||||
}
|
||||
window.open(url, '_blank');
|
||||
showPicker = false;
|
||||
}
|
||||
|
||||
function toggle(e: Event) {
|
||||
e.stopPropagation();
|
||||
showPicker = !showPicker;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs gap-1 text-base-content/40 hover:text-primary"
|
||||
onclick={toggle}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
Maps
|
||||
</button>
|
||||
|
||||
{#if showPicker}
|
||||
<div class="absolute bottom-full right-0 mb-1 bg-base-200 border border-base-300 rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<button class="w-full flex items-center gap-2 px-4 py-2.5 hover:bg-base-300 transition-colors text-sm whitespace-nowrap" onclick={openGoogle}>
|
||||
<span class="text-base">G</span> Google Maps
|
||||
</button>
|
||||
<button class="w-full flex items-center gap-2 px-4 py-2.5 hover:bg-base-300 transition-colors text-sm whitespace-nowrap" onclick={openApple}>
|
||||
<span class="text-base">A</span> Apple Maps
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showPicker}
|
||||
<div class="fixed inset-0 z-40" onclick={(e) => { e.stopPropagation(); showPicker = false; }}></div>
|
||||
{/if}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
let theme = $state('night');
|
||||
|
||||
function toggleTheme() {
|
||||
theme = theme === 'night' ? 'light' : 'night';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved) {
|
||||
theme = saved;
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="navbar bg-base-200/80 backdrop-blur-md sticky top-0 z-50 border-b border-base-300 px-4">
|
||||
<div class="flex-1">
|
||||
<a href="/" class="flex items-center gap-2 text-xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z"/>
|
||||
<path d="M9 4v13"/>
|
||||
<path d="M15 7v13"/>
|
||||
</svg>
|
||||
Trips
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none flex items-center gap-2">
|
||||
<!-- Theme toggle -->
|
||||
<label class="swap swap-rotate btn btn-ghost btn-circle">
|
||||
<input type="checkbox" checked={theme === 'light'} onchange={toggleTheme} />
|
||||
<!-- sun -->
|
||||
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/>
|
||||
</svg>
|
||||
<!-- moon -->
|
||||
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/>
|
||||
</svg>
|
||||
</label>
|
||||
<!-- Settings/menu -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
|
||||
<div class="w-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300 mt-2">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { post } from '$lib/api/client';
|
||||
import type { Note } from '$lib/api/types';
|
||||
|
||||
let {
|
||||
note = null,
|
||||
tripId,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose
|
||||
}: {
|
||||
note: Note | null;
|
||||
tripId: string;
|
||||
onSave: (n: any) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let modal = $state<HTMLDialogElement | null>(null);
|
||||
let saving = $state(false);
|
||||
let deleting = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
|
||||
let form = $state({ name: '', content: '', date: '' });
|
||||
let isEdit = $derived(!!note?.id);
|
||||
|
||||
$effect(() => {
|
||||
if (note) {
|
||||
form = {
|
||||
name: note.name || '',
|
||||
content: note.content || '',
|
||||
date: note.date || ''
|
||||
};
|
||||
}
|
||||
modal?.showModal();
|
||||
});
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
const payload = { ...form, trip_id: tripId };
|
||||
if (isEdit) {
|
||||
(payload as any).id = note!.id;
|
||||
await post('/api/note/update', payload);
|
||||
} else {
|
||||
await post('/api/note', payload);
|
||||
}
|
||||
onSave(payload);
|
||||
} catch (e) { console.error('Failed to save note:', e); }
|
||||
finally { saving = false; }
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!note?.id) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await post('/api/note/delete', { id: note.id });
|
||||
onDelete(note.id);
|
||||
} catch (e) { console.error('Failed to delete:', e); }
|
||||
finally { deleting = false; }
|
||||
}
|
||||
|
||||
function close() { modal?.close(); onClose(); }
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal} class="modal modal-bottom sm:modal-middle" onclose={onClose}>
|
||||
<div class="modal-box max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
|
||||
<h3 class="font-bold text-lg flex items-center gap-2 mb-4">
|
||||
<div class="p-2 rounded-xl bg-warning/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-warning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>
|
||||
</svg>
|
||||
</div>
|
||||
{isEdit ? 'Edit Note' : 'New Note'}
|
||||
</h3>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="form-control col-span-2">
|
||||
<label class="label"><span class="label-text">Title</span></label>
|
||||
<input type="text" class="input input-bordered w-full" bind:value={form.name} required />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Date</span></label>
|
||||
<input type="date" class="input input-bordered w-full" bind:value={form.date} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Content</span></label>
|
||||
<textarea class="textarea textarea-bordered w-full" rows="8" bind:value={form.content}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action flex justify-between">
|
||||
<div>
|
||||
{#if isEdit}
|
||||
{#if confirmDelete}
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-error btn-sm" onclick={doDelete} disabled={deleting}>
|
||||
{#if deleting}<span class="loading loading-spinner loading-xs"></span>{/if}
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={() => confirmDelete = false}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class="btn btn-ghost btn-sm text-error" onclick={() => confirmDelete = true}>Delete</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-ghost" onclick={close}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={saving || !form.name.trim()}>
|
||||
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
{isEdit ? 'Save' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import { post, api } from '$lib/api/client';
|
||||
|
||||
let {
|
||||
tripId,
|
||||
tripStart = '',
|
||||
tripEnd = '',
|
||||
onParsed,
|
||||
onClose
|
||||
}: {
|
||||
tripId: string;
|
||||
tripStart?: string;
|
||||
tripEnd?: string;
|
||||
onParsed: () => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let modal = $state<HTMLDialogElement | null>(null);
|
||||
let textInput = $state('');
|
||||
let parsing = $state(false);
|
||||
let saving = $state(false);
|
||||
let result = $state<any>(null);
|
||||
let error = $state('');
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
$effect(() => { modal?.showModal(); });
|
||||
|
||||
async function parseText() {
|
||||
if (!textInput.trim()) return;
|
||||
parsing = true;
|
||||
error = '';
|
||||
result = null;
|
||||
try {
|
||||
result = await post('/api/parse', {
|
||||
text: textInput,
|
||||
trip_start: tripStart,
|
||||
trip_end: tripEnd
|
||||
});
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
result = null;
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Parse failed';
|
||||
} finally {
|
||||
parsing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
|
||||
parsing = true;
|
||||
error = '';
|
||||
result = null;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', input.files[0]);
|
||||
formData.append('text', textInput || '');
|
||||
if (tripStart) formData.append('trip_start', tripStart);
|
||||
if (tripEnd) formData.append('trip_end', tripEnd);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('api_token') || '';
|
||||
const res = await fetch('/api/parse', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData
|
||||
});
|
||||
result = await res.json();
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
result = null;
|
||||
}
|
||||
} catch {
|
||||
error = 'Parse failed';
|
||||
} finally {
|
||||
parsing = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePlaceId(name: string, address: string): Promise<{ place_id?: string; address?: string }> {
|
||||
try {
|
||||
const query = address ? `${name} ${address}` : name;
|
||||
const acData = await post<{ predictions: Array<{ place_id: string; address: string }> }>(
|
||||
'/api/places/autocomplete', { query }
|
||||
);
|
||||
if (acData.predictions?.length > 0) {
|
||||
const placeId = acData.predictions[0].place_id;
|
||||
// Get full details for coordinates
|
||||
const token = localStorage.getItem('api_token') || '';
|
||||
const res = await fetch(`/api/places/details?place_id=${encodeURIComponent(placeId)}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const details = await res.json();
|
||||
return {
|
||||
place_id: placeId,
|
||||
address: details.address || acData.predictions[0].address || address
|
||||
};
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
async function saveResult() {
|
||||
if (!result?.data) return;
|
||||
saving = true;
|
||||
try {
|
||||
const data = { ...result.data, trip_id: tripId };
|
||||
const type = result.type;
|
||||
|
||||
// Resolve place_id for locations and lodging
|
||||
if (type === 'location' || type === 'activity' || type === 'hotel' || type === 'lodging') {
|
||||
const place = await resolvePlaceId(data.name || '', data.address || data.location || '');
|
||||
if (place.place_id) data.place_id = place.place_id;
|
||||
if (place.address) {
|
||||
if (type === 'hotel' || type === 'lodging') {
|
||||
data.location = place.address;
|
||||
} else {
|
||||
data.address = place.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve from/to place_ids for transport
|
||||
if (type === 'flight' || type === 'transportation' || type === 'car' || type === 'train') {
|
||||
if (data.from_location) {
|
||||
const from = await resolvePlaceId(data.from_location, '');
|
||||
if (from.place_id) data.from_place_id = from.place_id;
|
||||
}
|
||||
if (data.to_location) {
|
||||
const to = await resolvePlaceId(data.to_location, '');
|
||||
if (to.place_id) data.to_place_id = to.place_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'flight' || type === 'transportation' || type === 'car' || type === 'train') {
|
||||
await post('/api/transportation', data);
|
||||
} else if (type === 'hotel' || type === 'lodging') {
|
||||
await post('/api/lodging', data);
|
||||
} else if (type === 'location' || type === 'activity') {
|
||||
await post('/api/location', data);
|
||||
} else {
|
||||
// Default to note
|
||||
await post('/api/note', {
|
||||
trip_id: tripId,
|
||||
name: data.name || 'Parsed Note',
|
||||
content: JSON.stringify(data, null, 2),
|
||||
date: data.date?.split('T')[0] || ''
|
||||
});
|
||||
}
|
||||
onParsed();
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e);
|
||||
error = 'Failed to save';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'flight': return 'Flight';
|
||||
case 'hotel': case 'lodging': return 'Hotel';
|
||||
case 'location': case 'activity': return 'Activity';
|
||||
case 'car': return 'Car Rental';
|
||||
case 'train': return 'Train';
|
||||
default: return type || 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function close() { modal?.close(); onClose(); }
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal} class="modal modal-bottom sm:modal-middle" onclose={onClose}>
|
||||
<div class="modal-box max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
|
||||
<h3 class="font-bold text-lg flex items-center gap-2 mb-4">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
AI Parse
|
||||
</h3>
|
||||
|
||||
{#if !result}
|
||||
<!-- Input -->
|
||||
<div class="space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Paste confirmation email, booking text, or details</span></label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="6"
|
||||
placeholder="Paste flight confirmation, hotel booking, activity details..."
|
||||
bind:value={textInput}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-primary flex-1" onclick={parseText} disabled={parsing || !textInput.trim()}>
|
||||
{#if parsing}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Parsing...
|
||||
{:else}
|
||||
Parse Text
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<label class="btn btn-outline flex-1 cursor-pointer">
|
||||
{#if parsing}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>
|
||||
</svg>
|
||||
Upload PDF / Image
|
||||
{/if}
|
||||
<input type="file" accept="image/*,.pdf" class="hidden" bind:this={fileInput} onchange={parseFile} disabled={parsing} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error text-sm py-2"><span>{error}</span></div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Result preview -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-primary">{typeLabel(result.type)}</span>
|
||||
{#if result.confidence}
|
||||
<span class="text-xs text-base-content/40">{Math.round(result.confidence * 100)}% confident</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="bg-base-200 rounded-lg p-4 space-y-2">
|
||||
{#each Object.entries(result.data || {}) as [key, value]}
|
||||
{#if value && key !== 'type'}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/50 capitalize">{key.replace(/_/g, ' ')}</span>
|
||||
<span class="text-base-content font-medium text-right max-w-xs truncate">{value}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error text-sm py-2"><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-ghost flex-1" onclick={() => { result = null; error = ''; }}>
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary flex-1" onclick={saveResult} disabled={saving}>
|
||||
{#if saving}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Save to Trip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { post } from '$lib/api/client';
|
||||
|
||||
interface Prediction {
|
||||
place_id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
description: string;
|
||||
types: string[];
|
||||
}
|
||||
|
||||
interface PlaceDetails {
|
||||
name: string;
|
||||
address: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
types: string[];
|
||||
category: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholder = 'Search for a place...',
|
||||
onSelect
|
||||
}: {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onSelect: (details: PlaceDetails & { place_id: string }) => void;
|
||||
} = $props();
|
||||
|
||||
let predictions = $state<Prediction[]>([]);
|
||||
let showDropdown = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
async function search(query: string) {
|
||||
if (query.length < 2) {
|
||||
predictions = [];
|
||||
showDropdown = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await post<{ predictions: Prediction[] }>('/api/places/autocomplete', { query });
|
||||
predictions = data.predictions || [];
|
||||
showDropdown = predictions.length > 0;
|
||||
} catch {
|
||||
predictions = [];
|
||||
}
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => search(value), 250);
|
||||
}
|
||||
|
||||
async function selectPlace(pred: Prediction) {
|
||||
showDropdown = false;
|
||||
value = pred.name;
|
||||
|
||||
// Fetch full details
|
||||
try {
|
||||
const res = await fetch(`/api/places/details?place_id=${encodeURIComponent(pred.place_id)}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('api_token') || ''}` }
|
||||
});
|
||||
const details: PlaceDetails = await res.json();
|
||||
onSelect({ ...details, place_id: pred.place_id });
|
||||
} catch {
|
||||
// Fallback with autocomplete data
|
||||
onSelect({
|
||||
place_id: pred.place_id,
|
||||
name: pred.name,
|
||||
address: pred.address,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
types: pred.types,
|
||||
category: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
{placeholder}
|
||||
bind:value
|
||||
oninput={onInput}
|
||||
onfocus={() => { if (predictions.length > 0) showDropdown = true; }}
|
||||
onblur={() => setTimeout(() => showDropdown = false, 200)}
|
||||
/>
|
||||
{#if showDropdown}
|
||||
<div class="absolute top-full left-0 right-0 mt-1 bg-base-200 border border-base-300 rounded-box shadow-xl z-50 max-h-48 overflow-y-auto">
|
||||
{#each predictions as pred}
|
||||
<button
|
||||
class="w-full flex flex-col p-3 hover:bg-base-300 transition-colors text-left"
|
||||
onmousedown={() => selectPlace(pred)}
|
||||
>
|
||||
<span class="font-medium text-sm text-base-content">{pred.name}</span>
|
||||
{#if pred.address}
|
||||
<span class="text-xs text-base-content/40">{pred.address}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
import { get } from '$lib/api/client';
|
||||
|
||||
interface TripSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total_trips: number;
|
||||
cities_visited: number;
|
||||
countries_visited: number;
|
||||
total_points_redeemed: number;
|
||||
total_activities: number;
|
||||
trips_by_year: Record<string, TripSummary[]>;
|
||||
cities_by_country: Record<string, string[]>;
|
||||
points_by_year: Record<string, number>;
|
||||
points_by_category: { flights: number; hotels: number; activities: number };
|
||||
}
|
||||
|
||||
let stats = $state<Stats | null>(null);
|
||||
|
||||
let tripsModal = $state<HTMLDialogElement | null>(null);
|
||||
let citiesModal = $state<HTMLDialogElement | null>(null);
|
||||
let countriesModal = $state<HTMLDialogElement | null>(null);
|
||||
let pointsModal = $state<HTMLDialogElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
loadStats();
|
||||
});
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats = await get<Stats>('/api/stats');
|
||||
} catch (e) {
|
||||
console.error('Failed to load stats:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function formatDate(d: string): string {
|
||||
if (!d) return '';
|
||||
const date = new Date(d + 'T00:00:00');
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatDateRange(start: string, end: string): string {
|
||||
if (!start) return '';
|
||||
const s = formatDate(start);
|
||||
const e = end ? formatDate(end) : '';
|
||||
return e ? `${s} - ${e}` : s;
|
||||
}
|
||||
|
||||
function maxPoints(): number {
|
||||
if (!stats?.points_by_year) return 1;
|
||||
return Math.max(...Object.values(stats.points_by_year), 1);
|
||||
}
|
||||
|
||||
function maxCategoryPoints(): number {
|
||||
if (!stats?.points_by_category) return 1;
|
||||
return Math.max(...Object.values(stats.points_by_category), 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if stats}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<!-- Total Trips -->
|
||||
<div
|
||||
class="card bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20 shadow-md hover:shadow-xl transition-shadow duration-300 cursor-pointer"
|
||||
onclick={() => tripsModal?.showModal()}
|
||||
>
|
||||
<div class="card-body p-4 flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z"/><path d="M9 4v13"/><path d="M15 7v13"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{formatNumber(stats.total_trips)}</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Trips</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cities Visited -->
|
||||
<div
|
||||
class="card bg-gradient-to-br from-info/10 to-info/5 border border-info/20 shadow-md hover:shadow-xl transition-shadow duration-300 cursor-pointer"
|
||||
onclick={() => citiesModal?.showModal()}
|
||||
>
|
||||
<div class="card-body p-4 flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-info/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 21h18"/><path d="M5 21V7l8-4v18"/><path d="M19 21V11l-6-4"/><path d="M9 9h.01"/><path d="M9 12h.01"/><path d="M9 15h.01"/><path d="M9 18h.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{formatNumber(stats.cities_visited)}</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Cities Visited</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Countries Visited -->
|
||||
<div
|
||||
class="card bg-gradient-to-br from-success/10 to-success/5 border border-success/20 shadow-md hover:shadow-xl transition-shadow duration-300 cursor-pointer"
|
||||
onclick={() => countriesModal?.showModal()}
|
||||
>
|
||||
<div class="card-body p-4 flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-success/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{formatNumber(stats.countries_visited)}</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Countries</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Points Redeemed -->
|
||||
<div
|
||||
class="card bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 shadow-md hover:shadow-xl transition-shadow duration-300 cursor-pointer"
|
||||
onclick={() => pointsModal?.showModal()}
|
||||
>
|
||||
<div class="card-body p-4 flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-accent/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{formatNumber(stats.total_points_redeemed)}</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Miles Redeemed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trips Modal -->
|
||||
<dialog bind:this={tripsModal} class="modal modal-bottom sm:modal-middle">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<h3 class="font-bold text-lg flex items-center gap-2">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z"/><path d="M9 4v13"/><path d="M15 7v13"/>
|
||||
</svg>
|
||||
</div>
|
||||
Trips
|
||||
<span class="badge badge-primary">{stats.total_trips}</span>
|
||||
</h3>
|
||||
<div class="mt-4 max-h-[60vh] overflow-y-auto">
|
||||
{#each Object.entries(stats.trips_by_year) as [year, trips]}
|
||||
<div class="divider text-base-content/50 text-sm">{year}</div>
|
||||
<div class="space-y-2">
|
||||
{#each trips as trip}
|
||||
<a
|
||||
href="/trip/{trip.id}"
|
||||
class="flex items-center justify-between p-3 rounded-lg bg-base-200/50 hover:bg-base-200 transition-colors"
|
||||
onclick={() => tripsModal?.close()}
|
||||
>
|
||||
<span class="font-medium">{trip.name}</span>
|
||||
<span class="text-sm text-base-content/50">{formatDateRange(trip.start_date, trip.end_date)}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Cities Modal -->
|
||||
<dialog bind:this={citiesModal} class="modal modal-bottom sm:modal-middle">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<h3 class="font-bold text-lg flex items-center gap-2">
|
||||
<div class="p-2 rounded-xl bg-info/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 21h18"/><path d="M5 21V7l8-4v18"/><path d="M19 21V11l-6-4"/><path d="M9 9h.01"/><path d="M9 12h.01"/><path d="M9 15h.01"/><path d="M9 18h.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
Cities Visited
|
||||
<span class="badge badge-info">{stats.cities_visited}</span>
|
||||
</h3>
|
||||
<div class="mt-4 max-h-[60vh] overflow-y-auto">
|
||||
{#each Object.entries(stats.cities_by_country) as [country, cities]}
|
||||
<div class="divider text-base-content/50 text-sm">{country}</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each cities as city}
|
||||
<span class="badge badge-outline badge-lg">{city}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Countries Modal -->
|
||||
<dialog bind:this={countriesModal} class="modal modal-bottom sm:modal-middle">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<h3 class="font-bold text-lg flex items-center gap-2">
|
||||
<div class="p-2 rounded-xl bg-success/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>
|
||||
</svg>
|
||||
</div>
|
||||
Countries Visited
|
||||
<span class="badge badge-success">{stats.countries_visited}</span>
|
||||
</h3>
|
||||
<div class="mt-4 max-h-[60vh] overflow-y-auto space-y-2">
|
||||
{#each Object.entries(stats.cities_by_country) as [country, cities]}
|
||||
<div class="flex items-center justify-between p-3 rounded-lg bg-base-200/50">
|
||||
<span class="font-medium">{country}</span>
|
||||
<span class="badge badge-success badge-outline">{cities.length} {cities.length === 1 ? 'city' : 'cities'}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Points Modal -->
|
||||
<dialog bind:this={pointsModal} class="modal modal-bottom sm:modal-middle">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<h3 class="font-bold text-lg flex items-center gap-2">
|
||||
<div class="p-2 rounded-xl bg-accent/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
Miles Redeemed
|
||||
<span class="badge badge-accent">{formatNumber(stats.total_points_redeemed)}</span>
|
||||
</h3>
|
||||
<div class="mt-4 max-h-[60vh] overflow-y-auto">
|
||||
<!-- By Year -->
|
||||
<div class="divider text-base-content/50 text-sm">By Year</div>
|
||||
<div class="space-y-3">
|
||||
{#each Object.entries(stats.points_by_year) as [year, points]}
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="font-medium">{year}</span>
|
||||
<span class="text-base-content/70">{formatNumber(points)}</span>
|
||||
</div>
|
||||
<div class="w-full bg-base-200 rounded-full h-3">
|
||||
<div
|
||||
class="bg-accent rounded-full h-3 transition-all duration-500"
|
||||
style="width: {(points / maxPoints()) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- By Category -->
|
||||
<div class="divider text-base-content/50 text-sm">By Category</div>
|
||||
<div class="space-y-3">
|
||||
{#each [
|
||||
{ key: 'flights', label: 'Flights', color: 'bg-primary' },
|
||||
{ key: 'hotels', label: 'Hotels', color: 'bg-secondary' },
|
||||
{ key: 'activities', label: 'Activities', color: 'bg-success' }
|
||||
] as cat}
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="font-medium">{cat.label}</span>
|
||||
<span class="text-base-content/70">{formatNumber(stats.points_by_category[cat.key as keyof typeof stats.points_by_category])}</span>
|
||||
</div>
|
||||
<div class="w-full bg-base-200 rounded-full h-3">
|
||||
<div
|
||||
class="{cat.color} rounded-full h-3 transition-all duration-500"
|
||||
style="width: {(stats.points_by_category[cat.key as keyof typeof stats.points_by_category] / maxCategoryPoints()) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { post } from '$lib/api/client';
|
||||
import type { Transportation } from '$lib/api/types';
|
||||
import PlacesAutocomplete from './PlacesAutocomplete.svelte';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
|
||||
let {
|
||||
transport = null,
|
||||
tripId,
|
||||
tripStart = '',
|
||||
tripEnd = '',
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose
|
||||
}: {
|
||||
transport: Transportation | null;
|
||||
tripId: string;
|
||||
tripStart?: string;
|
||||
tripEnd?: string;
|
||||
onSave: (t: any) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let modal = $state<HTMLDialogElement | null>(null);
|
||||
let saving = $state(false);
|
||||
let deleting = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
let flightStatus = $state<any>(null);
|
||||
let checkingFlight = $state(false);
|
||||
|
||||
let form = $state({
|
||||
name: '', type: 'plane', flight_number: '', from_location: '', to_location: '',
|
||||
date: '', end_date: '', timezone: '', description: '', link: '',
|
||||
cost_points: 0, cost_cash: 0, from_place_id: '', to_place_id: ''
|
||||
});
|
||||
|
||||
let isEdit = $derived(!!transport?.id);
|
||||
let images = $derived((transport as any)?.images || []);
|
||||
|
||||
$effect(() => {
|
||||
if (transport) {
|
||||
form = {
|
||||
name: transport.name || '', type: transport.type || 'plane',
|
||||
flight_number: transport.flight_number || '',
|
||||
from_location: transport.from_location || '', to_location: transport.to_location || '',
|
||||
date: transport.date || '', end_date: transport.end_date || '',
|
||||
timezone: transport.timezone || '', description: transport.description || '',
|
||||
link: transport.link || '', cost_points: transport.cost_points || 0,
|
||||
cost_cash: transport.cost_cash || 0,
|
||||
from_place_id: transport.from_place_id || '', to_place_id: transport.to_place_id || ''
|
||||
};
|
||||
}
|
||||
modal?.showModal();
|
||||
});
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
const payload = { ...form, trip_id: tripId };
|
||||
if (isEdit) {
|
||||
(payload as any).id = transport!.id;
|
||||
await post('/api/transportation/update', payload);
|
||||
} else {
|
||||
await post('/api/transportation', payload);
|
||||
}
|
||||
onSave(payload);
|
||||
} catch (e) { console.error('Failed to save transport:', e); }
|
||||
finally { saving = false; }
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!transport?.id) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await post('/api/transportation/delete', { id: transport.id });
|
||||
onDelete(transport.id);
|
||||
} catch (e) { console.error('Failed to delete:', e); }
|
||||
finally { deleting = false; }
|
||||
}
|
||||
|
||||
async function checkFlightStatus() {
|
||||
if (!form.flight_number) return;
|
||||
checkingFlight = true;
|
||||
try {
|
||||
flightStatus = await post('/api/flight-status', {
|
||||
flight_number: form.flight_number,
|
||||
date: form.date?.split('T')[0] || ''
|
||||
});
|
||||
} catch { flightStatus = { error: 'Failed to check status' }; }
|
||||
finally { checkingFlight = false; }
|
||||
}
|
||||
|
||||
function close() { modal?.close(); onClose(); }
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal} class="modal modal-bottom sm:modal-middle" onclose={onClose}>
|
||||
<div class="modal-box max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
|
||||
<h3 class="font-bold text-lg flex items-center gap-2 mb-4">
|
||||
<div class="p-2 rounded-xl bg-info/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{isEdit ? 'Edit Transport' : 'New Transport'}
|
||||
</h3>
|
||||
|
||||
{#if isEdit}
|
||||
<div class="mb-4">
|
||||
<ImageUpload entityType="transportation" entityId={transport?.id || ''} {images} documents={(transport as any)?.documents || []} entityName={form.name || form.flight_number || ''} {tripStart} {tripEnd} onUpload={() => onSave({})} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Name</span></label>
|
||||
<input type="text" class="input input-bordered w-full" bind:value={form.name} placeholder="e.g. United Airlines" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Type</span></label>
|
||||
<select class="select select-bordered w-full" bind:value={form.type}>
|
||||
<option value="plane">Flight</option>
|
||||
<option value="train">Train</option>
|
||||
<option value="car">Car</option>
|
||||
<option value="bus">Bus</option>
|
||||
<option value="ferry">Ferry</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Flight / Reference #</span></label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" class="input input-bordered flex-1" bind:value={form.flight_number} placeholder="e.g. UA 1234" />
|
||||
{#if form.type === 'plane' && form.flight_number}
|
||||
<button type="button" class="btn btn-outline btn-sm self-center" onclick={checkFlightStatus} disabled={checkingFlight}>
|
||||
{#if checkingFlight}<span class="loading loading-spinner loading-xs"></span>{:else}Status{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if flightStatus && !flightStatus.error}
|
||||
<div class="bg-base-300/50 rounded-lg p-3 text-sm space-y-1">
|
||||
{#if flightStatus.status}<div class="font-medium text-base-content">{flightStatus.status}</div>{/if}
|
||||
{#if flightStatus.departure}<div class="text-base-content/60">Departure: {flightStatus.departure}</div>{/if}
|
||||
{#if flightStatus.arrival}<div class="text-base-content/60">Arrival: {flightStatus.arrival}</div>{/if}
|
||||
{#if flightStatus.gate}<div class="text-base-content/60">Gate: {flightStatus.gate}</div>{/if}
|
||||
{#if flightStatus.terminal}<div class="text-base-content/60">Terminal: {flightStatus.terminal}</div>{/if}
|
||||
</div>
|
||||
{:else if flightStatus?.error}
|
||||
<div class="text-xs text-error">{flightStatus.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">From</span></label>
|
||||
<PlacesAutocomplete
|
||||
bind:value={form.from_location}
|
||||
placeholder="e.g. DFW"
|
||||
onSelect={(details) => { form.from_place_id = details.place_id || ''; }}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">To</span></label>
|
||||
<PlacesAutocomplete
|
||||
bind:value={form.to_location}
|
||||
placeholder="e.g. LAX"
|
||||
onSelect={(details) => { form.to_place_id = details.place_id || ''; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Departure</span></label>
|
||||
<input type="datetime-local" class="input input-bordered w-full" bind:value={form.date} />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Arrival</span></label>
|
||||
<input type="datetime-local" class="input input-bordered w-full" bind:value={form.end_date} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Description</span></label>
|
||||
<textarea class="textarea textarea-bordered w-full" rows="2" bind:value={form.description}></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Link</span></label>
|
||||
<input type="url" class="input input-bordered w-full" bind:value={form.link} placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Cost (Cash)</span></label>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={form.cost_cash} step="0.01" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Cost (Points)</span></label>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={form.cost_points} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action flex justify-between">
|
||||
<div>
|
||||
{#if isEdit}
|
||||
{#if confirmDelete}
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-error btn-sm" onclick={doDelete} disabled={deleting}>
|
||||
{#if deleting}<span class="loading loading-spinner loading-xs"></span>{/if}
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={() => confirmDelete = false}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class="btn btn-ghost btn-sm text-error" onclick={() => confirmDelete = true}>Delete</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-ghost" onclick={close}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={saving}>
|
||||
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
{isEdit ? 'Save' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import type { Trip } from '$lib/api/types';
|
||||
|
||||
let { trip }: { trip: Trip } = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function getTripStatus(trip: Trip): { label: string; class: string } {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const start = trip.start_date ? new Date(trip.start_date + 'T00:00:00') : null;
|
||||
const end = trip.end_date ? new Date(trip.end_date + 'T00:00:00') : null;
|
||||
|
||||
if (start && end && now >= start && now <= end) {
|
||||
return { label: 'Active', class: 'badge-success' };
|
||||
} else if (start && now < start) {
|
||||
const days = Math.ceil((start.getTime() - now.getTime()) / 86400000);
|
||||
return { label: `In ${days}d`, class: 'badge-info' };
|
||||
} else {
|
||||
return { label: 'Past', class: 'badge-ghost' };
|
||||
}
|
||||
}
|
||||
|
||||
function getDuration(trip: Trip): string {
|
||||
if (!trip.start_date || !trip.end_date) return '';
|
||||
const start = new Date(trip.start_date + 'T00:00:00');
|
||||
const end = new Date(trip.end_date + 'T00:00:00');
|
||||
const days = Math.ceil((end.getTime() - start.getTime()) / 86400000) + 1;
|
||||
return `${days} day${days !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
let status = $derived(getTripStatus(trip));
|
||||
let duration = $derived(getDuration(trip));
|
||||
let imageUrl = $derived(trip.cover_image || (trip.image_path ? `/images/${trip.image_path}` : null));
|
||||
</script>
|
||||
|
||||
<a href="/trip/{trip.id}" class="group block">
|
||||
<div class="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1 overflow-hidden border border-base-300 hover:border-primary/30">
|
||||
<!-- Image section -->
|
||||
<figure class="relative h-48 overflow-hidden bg-base-300">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={trip.name}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary/10 to-secondary/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z"/>
|
||||
<path d="M9 4v13"/>
|
||||
<path d="M15 7v13"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Status badge overlay -->
|
||||
<div class="absolute top-3 right-3">
|
||||
<span class="badge {status.class} badge-sm font-medium">{status.label}</span>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<!-- Content section -->
|
||||
<div class="card-body p-4 gap-2">
|
||||
<h2 class="card-title text-lg font-bold text-base-content group-hover:text-primary transition-colors">
|
||||
{trip.name}
|
||||
</h2>
|
||||
{#if trip.start_date}
|
||||
<div class="flex items-center gap-4 text-sm text-base-content/60">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
{formatDate(trip.start_date)} — {formatDate(trip.end_date)}
|
||||
</span>
|
||||
</div>
|
||||
{#if duration}
|
||||
<div class="text-xs text-base-content/40">{duration}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if trip.description}
|
||||
<p class="text-sm text-base-content/50 line-clamp-2">{trip.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { post } from '$lib/api/client';
|
||||
import type { Trip } from '$lib/api/types';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
|
||||
let {
|
||||
trip,
|
||||
onSave,
|
||||
onClose
|
||||
}: {
|
||||
trip: Trip;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let modal = $state<HTMLDialogElement | null>(null);
|
||||
let saving = $state(false);
|
||||
let deleting = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
let sharing = $state(false);
|
||||
let shareUrl = $state('');
|
||||
let copied = $state(false);
|
||||
|
||||
let form = $state({
|
||||
name: '', description: '', start_date: '', end_date: ''
|
||||
});
|
||||
|
||||
let tripImages = $derived((trip as any)?.images || []);
|
||||
|
||||
$effect(() => {
|
||||
if (trip.share_token) {
|
||||
shareUrl = `${window.location.origin}/view/${trip.share_token}`;
|
||||
} else {
|
||||
shareUrl = '';
|
||||
}
|
||||
form = {
|
||||
name: trip.name || '',
|
||||
description: trip.description || '',
|
||||
start_date: trip.start_date || '',
|
||||
end_date: trip.end_date || ''
|
||||
};
|
||||
modal?.showModal();
|
||||
});
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
await post('/api/trip/update', { id: trip.id, ...form });
|
||||
onSave();
|
||||
} catch (e) {
|
||||
console.error('Failed to update trip:', e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
deleting = true;
|
||||
try {
|
||||
await post('/api/trip/delete', { id: trip.id });
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
console.error('Failed to delete trip:', e);
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleShare() {
|
||||
sharing = true;
|
||||
try {
|
||||
if (shareUrl) {
|
||||
await post('/api/share/delete', { trip_id: trip.id });
|
||||
shareUrl = '';
|
||||
} else {
|
||||
const result = await post<{ share_token: string }>('/api/share/create', { trip_id: trip.id });
|
||||
shareUrl = `${window.location.origin}/view/${result.share_token}`;
|
||||
}
|
||||
} catch (e) { console.error('Share failed:', e); }
|
||||
finally { sharing = false; }
|
||||
}
|
||||
|
||||
async function copyShareUrl() {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
copied = true;
|
||||
setTimeout(() => copied = false, 2000);
|
||||
}
|
||||
|
||||
function close() { modal?.close(); onClose(); }
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal} class="modal modal-bottom sm:modal-middle" onclose={onClose}>
|
||||
<div class="modal-box max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
|
||||
<h3 class="font-bold text-lg flex items-center gap-2 mb-4">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
Edit Trip
|
||||
</h3>
|
||||
|
||||
<!-- Cover Image -->
|
||||
<div class="mb-4">
|
||||
<label class="label"><span class="label-text text-sm">Cover Image</span></label>
|
||||
<ImageUpload entityType="trip" entityId={trip.id} images={tripImages} entityName={form.name} onUpload={onSave} />
|
||||
</div>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<div class="space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Name</span></label>
|
||||
<input type="text" class="input input-bordered w-full" bind:value={form.name} required />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Description</span></label>
|
||||
<textarea class="textarea textarea-bordered w-full" rows="2" bind:value={form.description}></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Start Date</span></label>
|
||||
<input type="date" class="input input-bordered w-full" bind:value={form.start_date} />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">End Date</span></label>
|
||||
<input type="date" class="input input-bordered w-full" bind:value={form.end_date} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sharing -->
|
||||
<div class="divider text-xs text-base-content/30">Sharing</div>
|
||||
{#if shareUrl}
|
||||
<div class="flex gap-2">
|
||||
<input type="text" class="input input-bordered input-sm flex-1 text-xs" value={shareUrl} readonly />
|
||||
<button type="button" class="btn btn-sm btn-ghost" onclick={copyShareUrl}>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost text-error" onclick={toggleShare} disabled={sharing}>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class="btn btn-sm btn-outline gap-2" onclick={toggleShare} disabled={sharing}>
|
||||
{#if sharing}<span class="loading loading-spinner loading-xs"></span>{/if}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Create Share Link
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-action flex justify-between">
|
||||
<div>
|
||||
{#if confirmDelete}
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-error btn-sm" onclick={doDelete} disabled={deleting}>
|
||||
{#if deleting}<span class="loading loading-spinner loading-xs"></span>{/if}
|
||||
Yes, Delete Trip
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={() => confirmDelete = false}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class="btn btn-ghost btn-sm text-error" onclick={() => confirmDelete = true}>Delete Trip</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-ghost" onclick={close}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={saving || !form.name.trim()}>
|
||||
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
116
services/trips/frontend-legacy/src/lib/components/TripMap.svelte
Normal file
116
services/trips/frontend-legacy/src/lib/components/TripMap.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import type { TripDetail } from '$lib/api/types';
|
||||
|
||||
let {
|
||||
trip,
|
||||
onClose
|
||||
}: {
|
||||
trip: TripDetail;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let modal = $state<HTMLDialogElement | null>(null);
|
||||
|
||||
interface Marker {
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
let markers = $derived.by(() => {
|
||||
const m: Marker[] = [];
|
||||
for (const loc of trip.locations || []) {
|
||||
if (loc.latitude && loc.longitude) {
|
||||
m.push({ lat: loc.latitude, lng: loc.longitude, label: loc.name, type: 'activity' });
|
||||
}
|
||||
}
|
||||
for (const l of trip.lodging || []) {
|
||||
if ((l as any).latitude && (l as any).longitude) {
|
||||
m.push({ lat: (l as any).latitude, lng: (l as any).longitude, label: l.name, type: 'hotel' });
|
||||
}
|
||||
}
|
||||
for (const t of trip.transportations || []) {
|
||||
if (t.from_lat && t.from_lng) {
|
||||
m.push({ lat: t.from_lat, lng: t.from_lng, label: t.from_location, type: 'transport' });
|
||||
}
|
||||
if (t.to_lat && t.to_lng) {
|
||||
m.push({ lat: t.to_lat, lng: t.to_lng, label: t.to_location, type: 'transport' });
|
||||
}
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
// Open in Google Maps with all locations
|
||||
function openInGoogleMaps() {
|
||||
if (markers.length === 0) return;
|
||||
if (markers.length === 1) {
|
||||
window.open(`https://www.google.com/maps/search/?api=1&query=${markers[0].lat},${markers[0].lng}`, '_blank');
|
||||
} else {
|
||||
const origin = `${markers[0].lat},${markers[0].lng}`;
|
||||
const dest = `${markers[markers.length - 1].lat},${markers[markers.length - 1].lng}`;
|
||||
const waypoints = markers.slice(1, -1).map(m => `${m.lat},${m.lng}`).join('|');
|
||||
window.open(`https://www.google.com/maps/dir/?api=1&origin=${origin}&destination=${dest}${waypoints ? '&waypoints=' + waypoints : ''}`, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
function typeBadge(type: string): string {
|
||||
switch (type) {
|
||||
case 'activity': return 'badge-accent';
|
||||
case 'hotel': return 'badge-secondary';
|
||||
case 'transport': return 'badge-info';
|
||||
default: return 'badge-ghost';
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { modal?.showModal(); });
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal} class="modal modal-bottom sm:modal-middle" onclose={onClose}>
|
||||
<div class="modal-box max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
|
||||
<h3 class="font-bold text-lg flex items-center gap-2 mb-4">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
Map
|
||||
<span class="badge badge-primary badge-sm">{markers.length} pins</span>
|
||||
</h3>
|
||||
|
||||
{#if markers.length === 0}
|
||||
<div class="text-center py-12 text-base-content/40">No locations with coordinates</div>
|
||||
{:else}
|
||||
<!-- Open in Google Maps -->
|
||||
<button type="button" class="btn btn-primary btn-sm gap-2 mb-4 w-full" onclick={openInGoogleMaps}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
Open in Google Maps
|
||||
</button>
|
||||
|
||||
<!-- Location list -->
|
||||
<div class="space-y-1">
|
||||
{#each markers as marker, i}
|
||||
<a
|
||||
href="https://www.google.com/maps/search/?api=1&query={marker.lat},{marker.lng}"
|
||||
target="_blank"
|
||||
class="flex items-center gap-3 p-2 rounded-lg hover:bg-base-200 transition-colors"
|
||||
>
|
||||
<span class="w-6 h-6 rounded-full bg-base-300 flex items-center justify-center text-xs font-bold text-base-content/50 shrink-0">{i + 1}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">{marker.label}</div>
|
||||
<div class="text-xs text-base-content/30">{marker.lat.toFixed(4)}, {marker.lng.toFixed(4)}</div>
|
||||
</div>
|
||||
<span class="badge badge-sm {typeBadge(marker.type)}">{marker.type}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
1
services/trips/frontend-legacy/src/lib/index.ts
Normal file
1
services/trips/frontend-legacy/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
17
services/trips/frontend-legacy/src/routes/+layout.svelte
Normal file
17
services/trips/frontend-legacy/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Navbar from '$lib/components/Navbar.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Trips</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-base-100 flex flex-col">
|
||||
<Navbar />
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
627
services/trips/frontend-legacy/src/routes/+page.svelte
Normal file
627
services/trips/frontend-legacy/src/routes/+page.svelte
Normal file
@@ -0,0 +1,627 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { get, post } from '$lib/api/client';
|
||||
import type { Trip } from '$lib/api/types';
|
||||
import TripCard from '$lib/components/TripCard.svelte';
|
||||
import StatsBar from '$lib/components/StatsBar.svelte';
|
||||
|
||||
interface TripsResponse {
|
||||
trips: Trip[];
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
type: string;
|
||||
id: string;
|
||||
trip_id: string;
|
||||
name: string;
|
||||
detail: string;
|
||||
trip_name: string;
|
||||
}
|
||||
|
||||
let trips = $state<Trip[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<SearchResult[]>([]);
|
||||
let showSearchResults = $state(false);
|
||||
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
// New trip modal
|
||||
let newTripModal = $state<HTMLDialogElement | null>(null);
|
||||
let newTrip = $state({ name: '', description: '', start_date: '', end_date: '' });
|
||||
let creating = $state(false);
|
||||
|
||||
function typeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'trip': return 'M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z';
|
||||
case 'location': return 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z';
|
||||
case 'lodging': return 'M3 14h18v7H3zM3 7v7M21 7v7M3 7l9-4 9 4';
|
||||
case 'transportation': return 'M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z';
|
||||
case 'note': return 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z';
|
||||
default: return 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z';
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'trip': return 'Trip';
|
||||
case 'location': return 'Activity';
|
||||
case 'lodging': return 'Hotel';
|
||||
case 'transportation': return 'Flight';
|
||||
case 'note': return 'Note';
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
|
||||
function typeBadgeClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'trip': return 'badge-primary';
|
||||
case 'location': return 'badge-accent';
|
||||
case 'lodging': return 'badge-secondary';
|
||||
case 'transportation': return 'badge-info';
|
||||
case 'note': return 'badge-warning';
|
||||
default: return 'badge-ghost';
|
||||
}
|
||||
}
|
||||
|
||||
async function doSearch(query: string) {
|
||||
if (query.length < 2) {
|
||||
searchResults = [];
|
||||
showSearchResults = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await get<{ results: SearchResult[] }>(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
searchResults = data.results;
|
||||
showSearchResults = true;
|
||||
} catch {
|
||||
searchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(() => doSearch(searchQuery), 250);
|
||||
}
|
||||
|
||||
function selectResult(result: SearchResult) {
|
||||
showSearchResults = false;
|
||||
searchQuery = '';
|
||||
goto(`/trip/${result.trip_id}`);
|
||||
}
|
||||
|
||||
// Separate trips into categories
|
||||
let activeTrips = $derived(trips.filter(t => {
|
||||
const now = new Date(); now.setHours(0,0,0,0);
|
||||
const start = t.start_date ? new Date(t.start_date + 'T00:00:00') : null;
|
||||
const end = t.end_date ? new Date(t.end_date + 'T00:00:00') : null;
|
||||
return start && end && now >= start && now <= end;
|
||||
}));
|
||||
|
||||
let upcomingTrips = $derived(trips.filter(t => {
|
||||
const now = new Date(); now.setHours(0,0,0,0);
|
||||
const start = t.start_date ? new Date(t.start_date + 'T00:00:00') : null;
|
||||
return start && now < start;
|
||||
}).sort((a, b) => a.start_date.localeCompare(b.start_date)));
|
||||
|
||||
let pastTrips = $derived(trips.filter(t => {
|
||||
const now = new Date(); now.setHours(0,0,0,0);
|
||||
const end = t.end_date ? new Date(t.end_date + 'T00:00:00') : null;
|
||||
return end && now > end;
|
||||
}).sort((a, b) => b.start_date.localeCompare(a.start_date)));
|
||||
|
||||
// Next upcoming trip countdown
|
||||
let nextTrip = $derived(upcomingTrips.length > 0 ? upcomingTrips[0] : null);
|
||||
let daysUntilNext = $derived(() => {
|
||||
if (!nextTrip?.start_date) return 0;
|
||||
const now = new Date(); now.setHours(0,0,0,0);
|
||||
const start = new Date(nextTrip.start_date + 'T00:00:00');
|
||||
return Math.ceil((start.getTime() - now.getTime()) / 86400000);
|
||||
});
|
||||
|
||||
function formatDateShort(d: string): string {
|
||||
if (!d) return '';
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadTrips();
|
||||
});
|
||||
|
||||
async function loadTrips() {
|
||||
try {
|
||||
const data = await get<TripsResponse>('/api/trips');
|
||||
trips = data.trips;
|
||||
} catch (e) {
|
||||
error = 'Failed to load trips';
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let quickInput = $state('');
|
||||
let quickParsed = $state(false);
|
||||
let descLookupTimer: ReturnType<typeof setTimeout>;
|
||||
let lastLookedUpName = '';
|
||||
|
||||
async function lookupDescription(name: string) {
|
||||
if (!name || name.length < 2 || name === lastLookedUpName) return;
|
||||
lastLookedUpName = name;
|
||||
try {
|
||||
const data = await post<{ predictions: Array<{ name: string; address: string }> }>(
|
||||
'/api/places/autocomplete', { query: name }
|
||||
);
|
||||
if (data.predictions?.length > 0) {
|
||||
const place = data.predictions[0];
|
||||
const addr = place.address || '';
|
||||
if (addr && !newTrip.description) {
|
||||
newTrip.description = `Trip to ${place.name}${addr ? ', ' + addr : ''}`;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function parseQuickInput(input: string) {
|
||||
if (!input.trim()) {
|
||||
quickParsed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const text = input.trim();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// Month name mapping
|
||||
const months: Record<string, string> = {
|
||||
jan: '01', january: '01', feb: '02', february: '02', mar: '03', march: '03',
|
||||
apr: '04', april: '04', may: '05', jun: '06', june: '06',
|
||||
jul: '07', july: '07', aug: '08', august: '08', sep: '09', september: '09',
|
||||
oct: '10', october: '10', nov: '11', november: '11', dec: '12', december: '12'
|
||||
};
|
||||
|
||||
function parseDate(str: string, refYear?: number): string | null {
|
||||
str = str.trim().replace(/,/g, '');
|
||||
|
||||
// ISO format: 2026-10-01
|
||||
const isoMatch = str.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||
if (isoMatch) return `${isoMatch[1]}-${isoMatch[2].padStart(2, '0')}-${isoMatch[3].padStart(2, '0')}`;
|
||||
|
||||
// MM/DD/YYYY or MM/DD/YY or MM/DD
|
||||
const slashMatch = str.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/);
|
||||
if (slashMatch) {
|
||||
const y = slashMatch[3] ? (slashMatch[3].length === 2 ? '20' + slashMatch[3] : slashMatch[3]) : String(refYear || currentYear);
|
||||
return `${y}-${slashMatch[1].padStart(2, '0')}-${slashMatch[2].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// "Oct 1" or "October 1" or "Oct 1 2026"
|
||||
const monthNameMatch = str.match(/^([a-z]+)\s+(\d{1,2})(?:\s+(\d{4}))?$/i);
|
||||
if (monthNameMatch) {
|
||||
const m = months[monthNameMatch[1].toLowerCase()];
|
||||
if (m) {
|
||||
const y = monthNameMatch[3] || String(refYear || currentYear);
|
||||
return `${y}-${m}-${monthNameMatch[2].padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// "1 Oct" or "1 October 2026"
|
||||
const dayFirstMatch = str.match(/^(\d{1,2})\s+([a-z]+)(?:\s+(\d{4}))?$/i);
|
||||
if (dayFirstMatch) {
|
||||
const m = months[dayFirstMatch[2].toLowerCase()];
|
||||
if (m) {
|
||||
const y = dayFirstMatch[3] || String(refYear || currentYear);
|
||||
return `${y}-${m}-${dayFirstMatch[1].padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try splitting by common separators: "to", "-", "–", "through"
|
||||
const separators = [/\s+to\s+/i, /\s*[-–—]\s*(?=\d|[a-z])/i, /\s+through\s+/i];
|
||||
let name = '';
|
||||
let startStr = '';
|
||||
let endStr = '';
|
||||
let description = '';
|
||||
let matched = false;
|
||||
|
||||
// Date patterns to look for
|
||||
const datePatterns = [
|
||||
/(\d{1,2}\/\d{1,2}(?:\/\d{2,4})?)/,
|
||||
/(\d{4}-\d{1,2}-\d{1,2})/,
|
||||
/([A-Za-z]+\s+\d{1,2}(?:\s+\d{4})?)/,
|
||||
/(\d{1,2}\s+[A-Za-z]+(?:\s+\d{4})?)/,
|
||||
];
|
||||
|
||||
for (const sep of separators) {
|
||||
const parts = text.split(sep);
|
||||
if (parts.length >= 2) {
|
||||
const beforeSep = parts.slice(0, -1).join(' ').trim();
|
||||
const afterSep = parts[parts.length - 1].trim();
|
||||
|
||||
// Find start date in beforeSep (from right)
|
||||
for (const dp of datePatterns) {
|
||||
// Anchor to end for start date search
|
||||
const anchored = new RegExp(dp.source + '\\s*$');
|
||||
const dm = beforeSep.match(anchored);
|
||||
if (dm && dm.index !== undefined) {
|
||||
name = beforeSep.slice(0, dm.index).trim();
|
||||
startStr = dm[1];
|
||||
|
||||
// afterSep may be "1/10/27 Umrah and Maldives trip" or just "1/10/27"
|
||||
// Try to extract end date from the beginning of afterSep
|
||||
for (const edp of datePatterns) {
|
||||
const anchStart = new RegExp('^' + edp.source);
|
||||
const em = afterSep.match(anchStart);
|
||||
if (em) {
|
||||
endStr = em[1];
|
||||
// Everything after the end date is the description
|
||||
const remainder = afterSep.slice(em[0].length).trim();
|
||||
if (remainder) {
|
||||
description = remainder;
|
||||
}
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
// No date pattern found — treat entire afterSep as end date
|
||||
endStr = afterSep;
|
||||
matched = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
// No date range found — just use as name
|
||||
newTrip.name = text;
|
||||
quickParsed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the dates
|
||||
const startDate = parseDate(startStr);
|
||||
const startYear = startDate ? parseInt(startDate.split('-')[0]) : currentYear;
|
||||
const endDate = parseDate(endStr, startYear);
|
||||
|
||||
if (name) newTrip.name = name;
|
||||
if (description) newTrip.description = description;
|
||||
if (startDate) newTrip.start_date = startDate;
|
||||
if (endDate) newTrip.end_date = endDate;
|
||||
|
||||
// If end date is before start date, assume next year
|
||||
if (startDate && endDate && endDate < startDate) {
|
||||
const y = parseInt(endDate.split('-')[0]) + 1;
|
||||
newTrip.end_date = `${y}${endDate.slice(4)}`;
|
||||
}
|
||||
|
||||
quickParsed = !!(startDate || endDate);
|
||||
}
|
||||
|
||||
function openNewTrip() {
|
||||
newTrip = { name: '', description: '', start_date: '', end_date: '' };
|
||||
quickInput = '';
|
||||
quickParsed = false;
|
||||
newTripModal?.showModal();
|
||||
}
|
||||
|
||||
async function createTrip() {
|
||||
if (!newTrip.name.trim()) return;
|
||||
creating = true;
|
||||
try {
|
||||
const result = await post<{ success: boolean; id: string }>('/api/trip', newTrip);
|
||||
if (result.success) {
|
||||
newTripModal?.close();
|
||||
goto(`/trip/${result.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create trip:', e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content">My Trips</h1>
|
||||
<p class="text-base-content/50 mt-1">Plan and track your adventures</p>
|
||||
</div>
|
||||
<button class="btn btn-primary gap-2" onclick={openNewTrip}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
New Trip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<!-- Next trip countdown -->
|
||||
{#if nextTrip}
|
||||
<a href="/trip/{nextTrip.id}" class="flex items-center gap-4 p-4 mb-6 rounded-xl bg-gradient-to-r from-info/10 to-primary/10 border border-info/20 hover:border-info/40 transition-colors group">
|
||||
<div class="p-2.5 rounded-xl bg-info/15 shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-info/70 uppercase tracking-wide font-medium mb-0.5">Next Adventure</div>
|
||||
<div class="font-semibold text-base-content group-hover:text-info transition-colors truncate">{nextTrip.name}</div>
|
||||
<div class="text-sm text-base-content/50">{formatDateShort(nextTrip.start_date)} — {formatDateShort(nextTrip.end_date)}</div>
|
||||
</div>
|
||||
<div class="text-center shrink-0">
|
||||
<div class="text-2xl font-bold text-info leading-none">{daysUntilNext()}</div>
|
||||
<div class="text-xs text-base-content/40 mt-0.5">days</div>
|
||||
</div>
|
||||
</a>
|
||||
{:else if activeTrips.length > 0}
|
||||
<a href="/trip/{activeTrips[0].id}" class="flex items-center gap-3 p-4 mb-6 rounded-xl bg-gradient-to-r from-success/10 to-success/5 border border-success/20 hover:border-success/40 transition-colors group">
|
||||
<div class="p-2 rounded-xl bg-success/15">
|
||||
<span class="relative flex h-3 w-3">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 bg-success"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold text-base-content group-hover:text-success transition-colors">{activeTrips[0].name}</span>
|
||||
<span class="text-base-content/40 mx-2">·</span>
|
||||
<span class="text-sm text-success">Happening now!</span>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<StatsBar />
|
||||
|
||||
<!-- Search -->
|
||||
{#if trips.length > 0}
|
||||
<div class="mb-6">
|
||||
<div class="relative max-w-md">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search trips, hotels, flights, places..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
onfocus={() => { if (searchResults.length > 0) showSearchResults = true; }}
|
||||
onblur={() => setTimeout(() => showSearchResults = false, 200)}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-circle absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onclick={() => { searchQuery = ''; searchResults = []; showSearchResults = false; }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Search results dropdown -->
|
||||
{#if showSearchResults && searchResults.length > 0}
|
||||
<div class="absolute top-full left-0 right-0 mt-1 bg-base-200 border border-base-300 rounded-box shadow-xl z-50 max-h-80 overflow-y-auto">
|
||||
{#each searchResults as result}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 p-3 hover:bg-base-300 transition-colors text-left"
|
||||
onmousedown={() => selectResult(result)}
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg bg-base-300 flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={typeIcon(result.type)}/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm text-base-content truncate">{result.name}</div>
|
||||
{#if result.detail}
|
||||
<div class="text-xs text-base-content/40 truncate">{result.detail}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="badge badge-sm {typeBadgeClass(result.type)}">{typeLabel(result.type)}</span>
|
||||
{#if result.type !== 'trip'}
|
||||
<span class="text-xs text-base-content/30 max-w-24 truncate">{result.trip_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if showSearchResults && searchQuery.length >= 2 && searchResults.length === 0}
|
||||
<div class="absolute top-full left-0 right-0 mt-1 bg-base-200 border border-base-300 rounded-box shadow-xl z-50 p-4 text-center text-base-content/40 text-sm">
|
||||
No results for "{searchQuery}"
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Active trips -->
|
||||
{#if activeTrips.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="badge badge-success badge-sm"></span>
|
||||
<h2 class="text-xl font-semibold text-base-content">Active Now</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each activeTrips as trip (trip.id)}
|
||||
<TripCard {trip} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Upcoming trips -->
|
||||
{#if upcomingTrips.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="badge badge-info badge-sm"></span>
|
||||
<h2 class="text-xl font-semibold text-base-content">Upcoming</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each upcomingTrips as trip (trip.id)}
|
||||
<TripCard {trip} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Past trips -->
|
||||
{#if pastTrips.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="badge badge-ghost badge-sm"></span>
|
||||
<h2 class="text-xl font-semibold text-base-content">Past Adventures</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each pastTrips as trip (trip.id)}
|
||||
<TripCard {trip} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if trips.length === 0}
|
||||
<div class="text-center py-20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto text-base-content/20 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z"/>
|
||||
<path d="M9 4v13"/>
|
||||
<path d="M15 7v13"/>
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-base-content/60 mb-2">No trips yet</h3>
|
||||
<p class="text-base-content/40 mb-6">Start planning your first adventure</p>
|
||||
<button class="btn btn-primary" onclick={openNewTrip}>Create Your First Trip</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- New Trip Modal -->
|
||||
<dialog bind:this={newTripModal} class="modal modal-bottom sm:modal-middle">
|
||||
<div class="modal-box max-w-lg">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<h3 class="font-bold text-lg flex items-center gap-2 mb-4">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</div>
|
||||
New Trip
|
||||
</h3>
|
||||
<form onsubmit={(e) => { e.preventDefault(); createTrip(); }}>
|
||||
<div class="space-y-4">
|
||||
<!-- Quick input -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="quick-input">
|
||||
<span class="label-text">Quick Add</span>
|
||||
<span class="label-text-alt text-base-content/30">auto-fills below</span>
|
||||
</label>
|
||||
<input
|
||||
id="quick-input"
|
||||
type="text"
|
||||
placeholder="e.g. Toronto Oct 1 to Oct 10 Family vacation"
|
||||
class="input input-bordered input-primary w-full"
|
||||
bind:value={quickInput}
|
||||
oninput={() => parseQuickInput(quickInput)}
|
||||
/>
|
||||
{#if quickParsed}
|
||||
<label class="label pb-0">
|
||||
<span class="label-text-alt text-success flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Parsed — check fields below
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="divider text-base-content/20 text-xs my-1">or fill in manually</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="trip-name">
|
||||
<span class="label-text">Trip Name</span>
|
||||
</label>
|
||||
<input
|
||||
id="trip-name"
|
||||
type="text"
|
||||
placeholder="e.g. Japan 2026"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={newTrip.name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="trip-desc">
|
||||
<span class="label-text">Description</span>
|
||||
<span class="label-text-alt text-base-content/30">Optional</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="trip-desc"
|
||||
placeholder="What's this trip about?"
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="2"
|
||||
bind:value={newTrip.description}
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="trip-start">
|
||||
<span class="label-text">Start Date</span>
|
||||
</label>
|
||||
<input
|
||||
id="trip-start"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={newTrip.start_date}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="trip-end">
|
||||
<span class="label-text">End Date</span>
|
||||
</label>
|
||||
<input
|
||||
id="trip-end"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={newTrip.end_date}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" onclick={() => newTripModal?.close()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={creating || !newTrip.name.trim()}>
|
||||
{#if creating}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Create Trip
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
82
services/trips/frontend-legacy/src/routes/login/+page.svelte
Normal file
82
services/trips/frontend-legacy/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let token = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleLogin() {
|
||||
if (!token.trim()) {
|
||||
error = 'Please enter your API token';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
// Test the token
|
||||
const res = await fetch('/api/trips', {
|
||||
headers: { 'Authorization': `Bearer ${token.trim()}` }
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
localStorage.setItem('api_token', token.trim());
|
||||
goto('/');
|
||||
} else {
|
||||
error = 'Invalid API token';
|
||||
}
|
||||
} catch {
|
||||
error = 'Connection failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4">
|
||||
<div class="card bg-base-200 shadow-xl w-full max-w-md border border-base-300">
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-primary mb-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z"/>
|
||||
<path d="M9 4v13"/>
|
||||
<path d="M15 7v13"/>
|
||||
</svg>
|
||||
<h2 class="text-2xl font-bold text-base-content">Trips</h2>
|
||||
<p class="text-base-content/50 text-sm mt-1">Enter your API token to continue</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error text-sm py-2">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}>
|
||||
<div class="form-control">
|
||||
<label class="label" for="token">
|
||||
<span class="label-text">API Token</span>
|
||||
</label>
|
||||
<input
|
||||
id="token"
|
||||
type="password"
|
||||
placeholder="Bearer token"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={token}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full mt-4"
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Connect
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('api_token');
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<h1 class="text-3xl font-bold text-base-content mb-8">Settings</h1>
|
||||
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base-content">Session</h2>
|
||||
<p class="text-base-content/50 text-sm">Clear your API token and return to login.</p>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button class="btn btn-error btn-outline" onclick={logout}>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
751
services/trips/frontend-legacy/src/routes/trip/[id]/+page.svelte
Normal file
751
services/trips/frontend-legacy/src/routes/trip/[id]/+page.svelte
Normal file
@@ -0,0 +1,751 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { get } from '$lib/api/client';
|
||||
import type { TripDetail, Location, Lodging, Transportation, Note } from '$lib/api/types';
|
||||
import LocationModal from '$lib/components/LocationModal.svelte';
|
||||
import LodgingModal from '$lib/components/LodgingModal.svelte';
|
||||
import TransportModal from '$lib/components/TransportModal.svelte';
|
||||
import NoteModal from '$lib/components/NoteModal.svelte';
|
||||
import TripEditModal from '$lib/components/TripEditModal.svelte';
|
||||
import ParseModal from '$lib/components/ParseModal.svelte';
|
||||
import TripMap from '$lib/components/TripMap.svelte';
|
||||
import AIGuideModal from '$lib/components/AIGuideModal.svelte';
|
||||
import MapsButton from '$lib/components/MapsButton.svelte';
|
||||
|
||||
interface DayGroup {
|
||||
date: string;
|
||||
dayLabel: string;
|
||||
dayNumber: number;
|
||||
items: DayItem[];
|
||||
}
|
||||
|
||||
interface DayItem {
|
||||
type: 'transportation' | 'lodging' | 'location' | 'note';
|
||||
sortTime: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
let trip = $state<TripDetail | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let currentSlide = $state(0);
|
||||
let fabOpen = $state(false);
|
||||
let autoplayTimer: ReturnType<typeof setInterval>;
|
||||
let expandedDays = $state<Set<string>>(new Set());
|
||||
|
||||
// Modal state
|
||||
let showLocationModal = $state(false);
|
||||
let showLodgingModal = $state(false);
|
||||
let showTransportModal = $state(false);
|
||||
let showNoteModal = $state(false);
|
||||
let showTripEditModal = $state(false);
|
||||
let showParseModal = $state(false);
|
||||
let showMapModal = $state(false);
|
||||
let showAIGuide = $state(false);
|
||||
let editLocation = $state<Location | null>(null);
|
||||
let editLodging = $state<Lodging | null>(null);
|
||||
let editTransport = $state<Transportation | null>(null);
|
||||
let editNote = $state<Note | null>(null);
|
||||
|
||||
// Weather
|
||||
let weather = $state<Record<string, any>>({});
|
||||
|
||||
async function loadWeather() {
|
||||
if (!trip?.start_date) return;
|
||||
const now = new Date();
|
||||
const start = new Date(trip.start_date + 'T00:00:00');
|
||||
const end = new Date(trip.end_date + 'T00:00:00');
|
||||
|
||||
// Only fetch weather for dates within 16 days from now
|
||||
const maxDate = new Date(now); maxDate.setDate(maxDate.getDate() + 16);
|
||||
const dates: string[] = [];
|
||||
for (let d = new Date(Math.max(start.getTime(), now.getTime())); d <= end && d <= maxDate; d.setDate(d.getDate() + 1)) {
|
||||
dates.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
if (dates.length === 0) return;
|
||||
|
||||
try {
|
||||
const data = await post<{ forecasts: Record<string, any> }>('/api/weather', {
|
||||
trip_id: trip.id,
|
||||
dates
|
||||
});
|
||||
weather = data.forecasts || {};
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
$effect(() => { if (trip) loadWeather(); });
|
||||
|
||||
function openLocationEdit(loc: Location) { editLocation = loc; showLocationModal = true; }
|
||||
function openLodgingEdit(l: Lodging) { editLodging = l; showLodgingModal = true; }
|
||||
function openTransportEdit(t: Transportation) { editTransport = t; showTransportModal = true; }
|
||||
function openNoteEdit(n: Note) { editNote = n; showNoteModal = true; }
|
||||
|
||||
function openNewLocation() { editLocation = null; showLocationModal = true; fabOpen = false; }
|
||||
function openNewLodging() { editLodging = null; showLodgingModal = true; fabOpen = false; }
|
||||
function openNewTransport() { editTransport = null; showTransportModal = true; fabOpen = false; }
|
||||
function openNewNote() { editNote = null; showNoteModal = true; fabOpen = false; }
|
||||
|
||||
function handleModalSave() {
|
||||
showLocationModal = false; showLodgingModal = false;
|
||||
showTransportModal = false; showNoteModal = false;
|
||||
if (tripId) loadTrip(tripId);
|
||||
}
|
||||
|
||||
function handleModalDelete() {
|
||||
showLocationModal = false; showLodgingModal = false;
|
||||
showTransportModal = false; showNoteModal = false;
|
||||
if (tripId) loadTrip(tripId);
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
showLocationModal = false; showLodgingModal = false;
|
||||
showTransportModal = false; showNoteModal = false;
|
||||
}
|
||||
|
||||
let tripId = $derived(page.params.id);
|
||||
|
||||
$effect(() => {
|
||||
if (tripId) loadTrip(tripId);
|
||||
});
|
||||
|
||||
async function loadTrip(id: string) {
|
||||
loading = true;
|
||||
try {
|
||||
trip = await get<TripDetail>(`/api/trip/${id}`);
|
||||
} catch (e) {
|
||||
error = 'Failed to load trip';
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Build itinerary grouped by day
|
||||
let days = $derived.by(() => {
|
||||
if (!trip?.start_date || !trip?.end_date) return [];
|
||||
|
||||
const start = new Date(trip.start_date + 'T00:00:00');
|
||||
const end = new Date(trip.end_date + 'T00:00:00');
|
||||
const dayMap = new Map<string, DayGroup>();
|
||||
|
||||
// Create all days
|
||||
let dayNum = 1;
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
dayMap.set(dateStr, {
|
||||
date: dateStr,
|
||||
dayLabel: d.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }),
|
||||
dayNumber: dayNum++,
|
||||
items: []
|
||||
});
|
||||
}
|
||||
|
||||
// Place transportations
|
||||
for (const t of trip.transportations || []) {
|
||||
const dateStr = extractDate(t.date);
|
||||
if (dateStr && dayMap.has(dateStr)) {
|
||||
dayMap.get(dateStr)!.items.push({
|
||||
type: 'transportation', sortTime: extractTime(t.date) || '00:00', data: t
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Place locations
|
||||
for (const loc of trip.locations || []) {
|
||||
const dateStr = loc.visit_date || extractDate(loc.start_time);
|
||||
if (dateStr && dayMap.has(dateStr)) {
|
||||
dayMap.get(dateStr)!.items.push({
|
||||
type: 'location', sortTime: extractTime(loc.start_time) || '12:00', data: loc
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Place notes
|
||||
for (const n of trip.notes || []) {
|
||||
if (n.date && dayMap.has(n.date)) {
|
||||
dayMap.get(n.date)!.items.push({
|
||||
type: 'note', sortTime: '23:00', data: n
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort items within each day by time
|
||||
for (const day of dayMap.values()) {
|
||||
day.items.sort((a, b) => a.sortTime.localeCompare(b.sortTime));
|
||||
}
|
||||
|
||||
return Array.from(dayMap.values());
|
||||
});
|
||||
|
||||
// Map each day to its overnight lodging
|
||||
let overnightByDate = $derived.by(() => {
|
||||
const map = new Map<string, any>();
|
||||
if (!trip) return map;
|
||||
for (const l of trip.lodging || []) {
|
||||
const checkIn = extractDate(l.check_in);
|
||||
const checkOut = extractDate(l.check_out);
|
||||
if (!checkIn) continue;
|
||||
// Hotel covers every night from check-in to day before check-out
|
||||
const start = new Date(checkIn + 'T00:00:00');
|
||||
const end = checkOut ? new Date(checkOut + 'T00:00:00') : start;
|
||||
for (let d = new Date(start); d < end; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
map.set(dateStr, l);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// Unscheduled items (no date or date outside trip range)
|
||||
let unscheduled = $derived.by(() => {
|
||||
if (!trip) return [];
|
||||
const items: DayItem[] = [];
|
||||
const start = trip.start_date;
|
||||
const end = trip.end_date;
|
||||
|
||||
for (const t of trip.transportations || []) {
|
||||
const d = extractDate(t.date);
|
||||
if (!d || d < start || d > end) items.push({ type: 'transportation', sortTime: '', data: t });
|
||||
}
|
||||
for (const loc of trip.locations || []) {
|
||||
const d = loc.visit_date || extractDate(loc.start_time);
|
||||
if (!d || d < start || d > end) items.push({ type: 'location', sortTime: '', data: loc });
|
||||
}
|
||||
for (const n of trip.notes || []) {
|
||||
if (!n.date || n.date < start || n.date > end) items.push({ type: 'note', sortTime: '', data: n });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
// Hero images — shuffled on each page load
|
||||
let heroImages = $derived.by(() => {
|
||||
const imgs = (trip as any)?.hero_images || [];
|
||||
if (imgs.length <= 1) return imgs;
|
||||
// Fisher-Yates shuffle (copy first to avoid mutating)
|
||||
const shuffled = [...imgs];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
});
|
||||
|
||||
// Stats
|
||||
let totalCash = $derived(
|
||||
(trip?.transportations?.reduce((s, t) => s + (t.cost_cash || 0), 0) || 0) +
|
||||
(trip?.lodging?.reduce((s, l) => s + (l.cost_cash || 0), 0) || 0) +
|
||||
(trip?.locations?.reduce((s, l) => s + (l.cost_cash || 0), 0) || 0)
|
||||
);
|
||||
let totalPoints = $derived(
|
||||
(trip?.transportations?.reduce((s, t) => s + (t.cost_points || 0), 0) || 0) +
|
||||
(trip?.lodging?.reduce((s, l) => s + (l.cost_points || 0), 0) || 0) +
|
||||
(trip?.locations?.reduce((s, l) => s + (l.cost_points || 0), 0) || 0)
|
||||
);
|
||||
|
||||
function extractDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
return dateStr.split('T')[0];
|
||||
}
|
||||
|
||||
function extractTime(dateStr: string | undefined): string {
|
||||
if (!dateStr || !dateStr.includes('T')) return '';
|
||||
return dateStr.split('T')[1]?.split('|')[0] || '';
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const time = extractTime(dateStr);
|
||||
if (!time) return '';
|
||||
const [h, m] = time.split(':');
|
||||
const hour = parseInt(h);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const h12 = hour % 12 || 12;
|
||||
return `${h12}:${m} ${ampm}`;
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr.split('T')[0] + 'T00:00:00');
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function categoryBadgeClass(category: string): string {
|
||||
switch (category) {
|
||||
case 'restaurant': case 'cafe': case 'bar': return 'badge-warning';
|
||||
case 'hike': return 'badge-success';
|
||||
case 'attraction': return 'badge-info';
|
||||
case 'shopping': return 'badge-secondary';
|
||||
default: return 'badge-ghost';
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoplay() {
|
||||
clearInterval(autoplayTimer);
|
||||
autoplayTimer = setInterval(() => {
|
||||
if (heroImages.length > 1) currentSlide = (currentSlide + 1) % heroImages.length;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function nextSlide() {
|
||||
if (heroImages.length > 0) currentSlide = (currentSlide + 1) % heroImages.length;
|
||||
startAutoplay(); // reset timer on manual nav
|
||||
}
|
||||
function prevSlide() {
|
||||
if (heroImages.length > 0) currentSlide = (currentSlide - 1 + heroImages.length) % heroImages.length;
|
||||
startAutoplay();
|
||||
}
|
||||
|
||||
// Start autoplay when hero images are available
|
||||
$effect(() => {
|
||||
if (heroImages.length > 1) {
|
||||
startAutoplay();
|
||||
}
|
||||
return () => clearInterval(autoplayTimer);
|
||||
});
|
||||
|
||||
// Auto-expand today's day or first day with items (only on initial load)
|
||||
let hasAutoExpanded = $state(false);
|
||||
$effect(() => {
|
||||
if (days.length > 0 && !hasAutoExpanded) {
|
||||
hasAutoExpanded = true;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todayDay = days.find(d => d.date === today && d.items.length > 0);
|
||||
if (todayDay) {
|
||||
expandedDays = new Set([todayDay.date]);
|
||||
} else {
|
||||
const first = days.find(d => d.items.length > 0);
|
||||
if (first) expandedDays = new Set([first.date]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function toggleDay(date: string) {
|
||||
const next = new Set(expandedDays);
|
||||
if (next.has(date)) {
|
||||
next.delete(date);
|
||||
} else {
|
||||
next.add(date);
|
||||
}
|
||||
expandedDays = next;
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
expandedDays = new Set(days.filter(d => d.items.length > 0).map(d => d.date));
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
expandedDays = new Set();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if error || !trip}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="alert alert-error"><span>{error || 'Trip not found'}</span></div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Hero Image Carousel -->
|
||||
{#if heroImages.length > 0}
|
||||
<div class="relative w-full h-64 md:h-80 overflow-hidden bg-base-300">
|
||||
{#each heroImages as img, i}
|
||||
<div
|
||||
class="absolute inset-0 transition-opacity duration-500"
|
||||
class:opacity-100={i === currentSlide}
|
||||
class:opacity-0={i !== currentSlide}
|
||||
>
|
||||
<img src={img.url} alt="" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Gradient overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-base-100 via-base-100/10 to-black/30"></div>
|
||||
<!-- Trip info centered on carousel -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center text-center px-6">
|
||||
<h1 class="text-5xl md:text-6xl font-extrabold text-white drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">{trip.name}</h1>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 mt-4">
|
||||
{#if trip.start_date}
|
||||
<span class="badge badge-lg bg-primary border-0 text-white gap-1.5 px-4 py-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
|
||||
</svg>
|
||||
{formatDateShort(trip.start_date)} - {formatDateShort(trip.end_date)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if (trip.locations?.length || 0) > 0}
|
||||
<span class="badge badge-lg bg-accent border-0 text-white gap-1.5 px-4 py-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
{trip.locations.length} Locations
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Top buttons -->
|
||||
<div class="absolute top-4 left-4 right-4 flex justify-between z-10">
|
||||
<a href="/" class="inline-flex items-center gap-1 text-sm text-white/60 hover:text-white transition-colors bg-black/20 rounded-lg px-3 py-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
<button class="inline-flex items-center gap-1 text-sm text-white/60 hover:text-white transition-colors bg-black/20 rounded-lg px-3 py-1.5" onclick={() => showTripEditModal = true}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<!-- Navigation arrows -->
|
||||
{#if heroImages.length > 1}
|
||||
<button class="absolute left-3 top-1/2 -translate-y-1/2 btn btn-circle btn-sm bg-black/30 border-0 hover:bg-black/50 text-white" onclick={prevSlide}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
</button>
|
||||
<button class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-circle btn-sm bg-black/30 border-0 hover:bg-black/50 text-white" onclick={nextSlide}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</button>
|
||||
<!-- Image counter -->
|
||||
<div class="absolute bottom-4 right-4 badge bg-black/40 border-0 text-white text-xs">
|
||||
{currentSlide + 1} / {heroImages.length}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<!-- Header (only when no hero images) -->
|
||||
{#if heroImages.length === 0}
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<a href="/" class="btn btn-ghost btn-sm gap-1 mb-2 -ml-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-base-content">{trip.name}</h1>
|
||||
{#if trip.start_date}
|
||||
<p class="text-base-content/50 mt-1">{formatDateShort(trip.start_date)} — {formatDateShort(trip.end_date)}</p>
|
||||
{/if}
|
||||
{#if trip.description}
|
||||
<p class="text-sm text-base-content/40 mt-1">{trip.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="flex flex-wrap justify-center gap-6 mb-8 py-4">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-base-content">{trip.transportations?.length || 0}</div>
|
||||
<div class="text-xs text-base-content/50 uppercase tracking-wide font-medium">Transport</div>
|
||||
</div>
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-base-content">{trip.lodging?.length || 0}</div>
|
||||
<div class="text-xs text-base-content/50 uppercase tracking-wide font-medium">Lodging</div>
|
||||
</div>
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-base-content">{trip.locations?.length || 0}</div>
|
||||
<div class="text-xs text-base-content/50 uppercase tracking-wide font-medium">Activities</div>
|
||||
</div>
|
||||
{#if totalPoints > 0}
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-base-content">{totalPoints.toLocaleString()}</div>
|
||||
<div class="text-xs text-base-content/50 uppercase tracking-wide font-medium">Points</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if totalCash > 0}
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-base-content">${totalCash.toLocaleString()}</div>
|
||||
<div class="text-xs text-base-content/50 uppercase tracking-wide font-medium">Cost</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Itinerary by Day -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-sm gap-2 text-base-content/50" onclick={() => showMapModal = true}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
Map
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm gap-2 text-base-content/50" onclick={() => showAIGuide = true}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
AI Guide
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-ghost btn-xs text-base-content/40" onclick={expandAll}>Expand all</button>
|
||||
<button class="btn btn-ghost btn-xs text-base-content/40" onclick={collapseAll}>Collapse all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each days as day}
|
||||
<section>
|
||||
<!-- Day header (clickable if has items) -->
|
||||
<button
|
||||
class="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-base-200 transition-colors {day.items.length > 0 ? 'cursor-pointer' : 'cursor-default opacity-50'}"
|
||||
onclick={() => { if (day.items.length > 0) toggleDay(day.date); }}
|
||||
>
|
||||
<div class="w-10 h-10 rounded-full {day.items.length > 0 ? 'bg-primary/15' : 'bg-base-300'} flex items-center justify-center shrink-0">
|
||||
<span class="text-sm font-bold {day.items.length > 0 ? 'text-primary' : 'text-base-content/30'}">{day.dayNumber}</span>
|
||||
</div>
|
||||
<div class="flex-1 text-left">
|
||||
<div class="font-semibold {day.items.length > 0 ? 'text-base-content' : 'text-base-content/40'}">
|
||||
{day.dayLabel}
|
||||
{#if weather[day.date]}
|
||||
<span class="text-xs font-normal text-base-content/50 ml-2">
|
||||
{weather[day.date].high}°/{weather[day.date].low}°
|
||||
{#if weather[day.date].description} · {weather[day.date].description}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/40">Day {day.dayNumber}{#if day.items.length > 0} · {day.items.length} {day.items.length === 1 ? 'item' : 'items'}{:else} · No plans yet{/if}</div>
|
||||
</div>
|
||||
{#if day.items.length > 0}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/30 transition-transform {expandedDays.has(day.date) ? 'rotate-180' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Day items (collapsible) -->
|
||||
{#if expandedDays.has(day.date)}
|
||||
<div class="ml-5 border-l-2 border-base-300 pl-6 space-y-3 mt-2">
|
||||
{#each day.items as item}
|
||||
{#if item.type === 'transportation'}
|
||||
{@const t = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm hover:border-primary/30 transition-colors cursor-pointer" onclick={() => openTransportEdit(t)}>
|
||||
{#if t.images?.length > 0}
|
||||
<figure class="h-32"><img src={t.images[0].url} alt="" class="w-full h-full object-cover" /></figure>
|
||||
{/if}
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
{#if t.type === 'plane'}<path d="M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z"/>
|
||||
{:else if t.type === 'train'}<path d="M4 11V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v7m-16 0v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6m-16 0h16"/>
|
||||
{:else}<path d="M5 17h14M5 17a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2M5 17l-1 3h16l-1-3"/>{/if}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-sm">{t.name || t.flight_number || `${t.from_location} → ${t.to_location}`}</div>
|
||||
<div class="text-xs text-base-content/50">{t.from_location} → {t.to_location}{#if t.flight_number} · {t.flight_number}{/if}</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
{#if t.date}<div class="text-xs font-medium">{formatTime(t.date)}</div>{/if}
|
||||
{#if t.cost_points}<div class="badge badge-sm badge-primary badge-outline mt-1">{t.cost_points.toLocaleString()} pts</div>{/if}
|
||||
{#if t.cost_cash}<div class="badge badge-sm badge-outline mt-1">${t.cost_cash}</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if t.to_lat || t.to_place_id || t.to_location}
|
||||
<div class="flex justify-end mt-1">
|
||||
<MapsButton lat={t.to_lat} lng={t.to_lng} name={t.to_location} placeId={t.to_place_id} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if item.type === 'location'}
|
||||
{@const loc = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm hover:border-accent/30 transition-colors cursor-pointer" onclick={() => openLocationEdit(loc)}>
|
||||
{#if loc.images?.length > 0}
|
||||
<figure class="h-32"><img src={loc.images[0].url} alt="" class="w-full h-full object-cover" /></figure>
|
||||
{/if}
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-sm">{loc.name}</span>
|
||||
{#if loc.category}<span class="badge badge-xs {categoryBadgeClass(loc.category)}">{loc.category}</span>{/if}
|
||||
</div>
|
||||
{#if loc.address}<div class="text-xs text-base-content/40 truncate">{loc.address}</div>{/if}
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
{#if loc.start_time}
|
||||
<div class="text-xs font-medium">{formatTime(loc.start_time)}</div>
|
||||
{#if loc.end_time}<div class="text-xs text-base-content/40">— {formatTime(loc.end_time)}</div>{/if}
|
||||
{/if}
|
||||
{#if loc.cost_points}<div class="badge badge-sm badge-primary badge-outline mt-1">{loc.cost_points.toLocaleString()} pts</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loc.description}
|
||||
<div class="text-xs text-base-content/50 mt-2 line-clamp-2">{@html loc.description}</div>
|
||||
{/if}
|
||||
{#if loc.latitude || loc.place_id || loc.address}
|
||||
<div class="flex justify-end mt-1">
|
||||
<MapsButton lat={loc.latitude} lng={loc.longitude} name={loc.name} address={loc.address} placeId={loc.place_id} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if item.type === 'note'}
|
||||
{@const n = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm border-l-4 border-l-warning cursor-pointer" onclick={() => openNoteEdit(n)}>
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-warning shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>
|
||||
</svg>
|
||||
<span class="font-semibold text-sm">{n.name}</span>
|
||||
</div>
|
||||
{#if n.content}
|
||||
<div class="text-xs text-base-content/60 mt-1 prose prose-sm max-w-none">{@html n.content}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Staying overnight -->
|
||||
{#if overnightByDate.has(day.date)}
|
||||
{@const hotel = overnightByDate.get(day.date)}
|
||||
<div class="mt-3 ml-5 pl-6 border-l-2 border-dashed border-secondary/30">
|
||||
<div class="text-sm text-base-content/60 font-medium mb-1.5 flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 14h18v7H3z"/><path d="M3 7v7"/><path d="M21 7v7"/><path d="M3 7l9-4 9 4"/>
|
||||
</svg>
|
||||
Staying overnight
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2 px-3 rounded-lg bg-secondary/5 border border-secondary/10 cursor-pointer hover:border-secondary/30 transition-colors" onclick={() => openLodgingEdit(hotel)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-secondary/60 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 14h18v7H3z"/><path d="M3 7v7"/><path d="M21 7v7"/><path d="M3 7l9-4 9 4"/>
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm text-base-content/70">{hotel.name}</span>
|
||||
</div>
|
||||
<span class="text-xs text-base-content/30 shrink-0">Check out: {formatDateShort(hotel.check_out)}</span>
|
||||
<MapsButton lat={hotel.latitude} lng={hotel.longitude} name={hotel.name} address={hotel.location} placeId={hotel.place_id} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<!-- Unscheduled items -->
|
||||
{#if unscheduled.length > 0}
|
||||
<section>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 rounded-full bg-base-300 flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-base-content">Unscheduled</div>
|
||||
<div class="text-xs text-base-content/40">Not assigned to a day</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 border-l-2 border-base-300/50 border-dashed pl-6 space-y-3">
|
||||
{#each unscheduled as item}
|
||||
{@const d = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-sm badge-ghost">{item.type}</span>
|
||||
<span class="font-semibold text-sm">{d.name || d.flight_number || 'Untitled'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if days.every(d => d.items.length === 0) && unscheduled.length === 0}
|
||||
<div class="text-center py-16">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-base-content/15 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-base-content/50 mb-1">No items yet</h3>
|
||||
<p class="text-sm text-base-content/30">Tap + to start planning</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Action Button -->
|
||||
<div class="fixed bottom-6 right-6 z-50">
|
||||
{#if fabOpen}
|
||||
<div class="flex flex-col-reverse gap-2 mb-3">
|
||||
<button class="btn btn-sm btn-primary shadow-lg gap-2" onclick={openNewLocation}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
Activity
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary shadow-lg gap-2" onclick={openNewLodging}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 14h18v7H3z"/><path d="M3 7v7"/><path d="M21 7v7"/><path d="M3 7l9-4 9 4"/></svg>
|
||||
Lodging
|
||||
</button>
|
||||
<button class="btn btn-sm btn-info shadow-lg gap-2" onclick={openNewTransport}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z"/></svg>
|
||||
Transport
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning shadow-lg gap-2" onclick={openNewNote}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||
Note
|
||||
</button>
|
||||
<button class="btn btn-sm btn-accent shadow-lg gap-2" onclick={() => { showParseModal = true; fabOpen = false; }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
AI Parse
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-primary btn-circle w-14 h-14 shadow-2xl hover:shadow-primary/25 transition-all duration-200 {fabOpen ? 'rotate-45' : ''}"
|
||||
onclick={() => fabOpen = !fabOpen}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 transition-transform" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop when FAB is open -->
|
||||
{#if fabOpen}
|
||||
<div class="fixed inset-0 bg-black/20 z-40" onclick={() => fabOpen = false}></div>
|
||||
{/if}
|
||||
|
||||
<!-- Modals -->
|
||||
{#if showAIGuide}
|
||||
<AIGuideModal {tripId} tripName={trip.name} onClose={() => showAIGuide = false} />
|
||||
{/if}
|
||||
{#if showMapModal}
|
||||
<TripMap {trip} onClose={() => showMapModal = false} />
|
||||
{/if}
|
||||
{#if showParseModal}
|
||||
<ParseModal {tripId} tripStart={trip.start_date} tripEnd={trip.end_date} onParsed={() => { showParseModal = false; loadTrip(tripId); }} onClose={() => showParseModal = false} />
|
||||
{/if}
|
||||
{#if showTripEditModal}
|
||||
<TripEditModal {trip} onSave={() => { showTripEditModal = false; loadTrip(tripId); }} onClose={() => showTripEditModal = false} />
|
||||
{/if}
|
||||
{#if showLocationModal}
|
||||
<LocationModal location={editLocation} tripId={trip.id} tripStart={trip.start_date} tripEnd={trip.end_date} onSave={handleModalSave} onDelete={handleModalDelete} onClose={handleModalClose} />
|
||||
{/if}
|
||||
{#if showLodgingModal}
|
||||
<LodgingModal lodging={editLodging} tripId={trip.id} tripStart={trip.start_date} tripEnd={trip.end_date} onSave={handleModalSave} onDelete={handleModalDelete} onClose={handleModalClose} />
|
||||
{/if}
|
||||
{#if showTransportModal}
|
||||
<TransportModal transport={editTransport} tripId={trip.id} tripStart={trip.start_date} tripEnd={trip.end_date} onSave={handleModalSave} onDelete={handleModalDelete} onClose={handleModalClose} />
|
||||
{/if}
|
||||
{#if showNoteModal}
|
||||
<NoteModal note={editNote} tripId={trip.id} onSave={handleModalSave} onDelete={handleModalDelete} onClose={handleModalClose} />
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
|
||||
interface ShareTrip {
|
||||
name: string;
|
||||
description: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
transportations: any[];
|
||||
lodging: any[];
|
||||
locations: any[];
|
||||
notes: any[];
|
||||
hero_images: any[];
|
||||
}
|
||||
|
||||
let trip = $state<ShareTrip | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let currentSlide = $state(0);
|
||||
|
||||
let token = $derived(page.params.token);
|
||||
|
||||
$effect(() => {
|
||||
if (token) loadShareTrip(token);
|
||||
});
|
||||
|
||||
async function loadShareTrip(t: string) {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/share/trip/${t}`);
|
||||
if (res.ok) {
|
||||
trip = await res.json();
|
||||
} else {
|
||||
error = 'Trip not found or link expired';
|
||||
}
|
||||
} catch {
|
||||
error = 'Failed to load trip';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-rotate carousel
|
||||
let autoplayTimer: ReturnType<typeof setInterval>;
|
||||
$effect(() => {
|
||||
if ((trip?.hero_images?.length || 0) > 1) {
|
||||
autoplayTimer = setInterval(() => {
|
||||
currentSlide = (currentSlide + 1) % (trip?.hero_images?.length || 1);
|
||||
}, 5000);
|
||||
}
|
||||
return () => clearInterval(autoplayTimer);
|
||||
});
|
||||
|
||||
function formatDateShort(d: string): string {
|
||||
if (!d) return '';
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
if (!dateStr || !dateStr.includes('T')) return '';
|
||||
const timePart = dateStr.split('T')[1]?.split('|')[0];
|
||||
if (!timePart) return '';
|
||||
const [h, m] = timePart.split(':');
|
||||
const hour = parseInt(h);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const h12 = hour % 12 || 12;
|
||||
return `${h12}:${m} ${ampm}`;
|
||||
}
|
||||
|
||||
function extractDate(d: string | undefined): string {
|
||||
if (!d) return '';
|
||||
return d.split('T')[0];
|
||||
}
|
||||
|
||||
function categoryBadgeClass(cat: string): string {
|
||||
switch (cat) {
|
||||
case 'restaurant': case 'cafe': case 'bar': return 'badge-warning';
|
||||
case 'hike': return 'badge-success';
|
||||
case 'attraction': return 'badge-info';
|
||||
default: return 'badge-ghost';
|
||||
}
|
||||
}
|
||||
|
||||
// Build day groups
|
||||
let days = $derived.by(() => {
|
||||
if (!trip?.start_date || !trip?.end_date) return [];
|
||||
const start = new Date(trip.start_date + 'T00:00:00');
|
||||
const end = new Date(trip.end_date + 'T00:00:00');
|
||||
const dayMap = new Map<string, { date: string; label: string; num: number; items: any[] }>();
|
||||
|
||||
let num = 1;
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const ds = d.toISOString().split('T')[0];
|
||||
dayMap.set(ds, {
|
||||
date: ds,
|
||||
label: d.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }),
|
||||
num: num++,
|
||||
items: []
|
||||
});
|
||||
}
|
||||
|
||||
for (const t of trip.transportations || []) {
|
||||
const d = extractDate(t.date);
|
||||
if (d && dayMap.has(d)) dayMap.get(d)!.items.push({ type: 'transport', data: t });
|
||||
}
|
||||
for (const loc of trip.locations || []) {
|
||||
const d = loc.visit_date || extractDate(loc.start_time);
|
||||
if (d && dayMap.has(d)) dayMap.get(d)!.items.push({ type: 'location', data: loc });
|
||||
}
|
||||
for (const n of trip.notes || []) {
|
||||
if (n.date && dayMap.has(n.date)) dayMap.get(n.date)!.items.push({ type: 'note', data: n });
|
||||
}
|
||||
|
||||
return Array.from(dayMap.values());
|
||||
});
|
||||
|
||||
// Overnight lodging
|
||||
let overnightByDate = $derived.by(() => {
|
||||
const map = new Map<string, any>();
|
||||
if (!trip) return map;
|
||||
for (const l of trip.lodging || []) {
|
||||
const checkIn = extractDate(l.check_in);
|
||||
const checkOut = extractDate(l.check_out);
|
||||
if (!checkIn) continue;
|
||||
const start = new Date(checkIn + 'T00:00:00');
|
||||
const end = checkOut ? new Date(checkOut + 'T00:00:00') : start;
|
||||
for (let d = new Date(start); d < end; d.setDate(d.getDate() + 1)) {
|
||||
map.set(d.toISOString().split('T')[0], l);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{trip?.name || 'Shared Trip'}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if error || !trip}
|
||||
<div class="container mx-auto px-4 py-20 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-base-content/20 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<h2 class="text-xl font-bold text-base-content/60">{error || 'Trip not found'}</h2>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Hero carousel -->
|
||||
{#if trip.hero_images && trip.hero_images.length > 0}
|
||||
<div class="relative w-full h-64 md:h-80 overflow-hidden bg-base-300">
|
||||
{#each trip.hero_images as img, i}
|
||||
<div class="absolute inset-0 transition-opacity duration-500" class:opacity-100={i === currentSlide} class:opacity-0={i !== currentSlide}>
|
||||
<img src={img.url} alt="" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
{/each}
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-base-100 via-base-100/10 to-black/30"></div>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center text-center px-6">
|
||||
<h1 class="text-5xl md:text-6xl font-extrabold text-white drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">{trip.name}</h1>
|
||||
{#if trip.start_date}
|
||||
<span class="badge badge-lg bg-primary border-0 text-white gap-1.5 px-4 py-3 mt-4">
|
||||
{formatDateShort(trip.start_date)} - {formatDateShort(trip.end_date)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if trip.hero_images.length > 1}
|
||||
<div class="absolute bottom-4 right-4 badge bg-black/40 border-0 text-white text-xs">
|
||||
{currentSlide + 1} / {trip.hero_images.length}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<!-- Shared badge -->
|
||||
<div class="flex justify-center mb-6">
|
||||
<span class="badge badge-outline gap-1 text-base-content/40">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
|
||||
Shared Trip
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !trip.start_date}
|
||||
<h1 class="text-3xl font-bold text-base-content text-center mb-6">{trip.name}</h1>
|
||||
{/if}
|
||||
|
||||
<!-- Itinerary -->
|
||||
<div class="space-y-3">
|
||||
{#each days as day}
|
||||
{#if day.items.length > 0}
|
||||
<section>
|
||||
<div class="flex items-center gap-3 p-3">
|
||||
<div class="w-10 h-10 rounded-full bg-primary/15 flex items-center justify-center shrink-0">
|
||||
<span class="text-sm font-bold text-primary">{day.num}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-base-content">{day.label}</div>
|
||||
<div class="text-xs text-base-content/40">Day {day.num} · {day.items.length} items</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-5 border-l-2 border-base-300 pl-6 space-y-3">
|
||||
{#each day.items as item}
|
||||
{#if item.type === 'transport'}
|
||||
{@const t = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-sm">{t.name || t.flight_number || `${t.from_location} → ${t.to_location}`}</div>
|
||||
<div class="text-xs text-base-content/50">{t.from_location} → {t.to_location}</div>
|
||||
</div>
|
||||
{#if t.date}<div class="text-xs font-medium shrink-0">{formatTime(t.date)}</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if item.type === 'location'}
|
||||
{@const loc = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
||||
{#if loc.images?.length > 0}
|
||||
<figure class="h-32"><img src={loc.images[0].url} alt="" class="w-full h-full object-cover" /></figure>
|
||||
{/if}
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-sm">{loc.name}</span>
|
||||
{#if loc.category}<span class="badge badge-xs {categoryBadgeClass(loc.category)}">{loc.category}</span>{/if}
|
||||
</div>
|
||||
{#if loc.address}<div class="text-xs text-base-content/40">{loc.address}</div>{/if}
|
||||
{#if loc.description}<div class="text-xs text-base-content/50 mt-1 line-clamp-2">{@html loc.description}</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if item.type === 'note'}
|
||||
{@const n = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm border-l-4 border-l-warning">
|
||||
<div class="card-body p-3">
|
||||
<div class="font-semibold text-sm">{n.name}</div>
|
||||
{#if n.content}<div class="text-xs text-base-content/60 mt-1">{@html n.content}</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if overnightByDate.has(day.date)}
|
||||
{@const hotel = overnightByDate.get(day.date)}
|
||||
<div class="mt-3 ml-5 pl-6 border-l-2 border-dashed border-secondary/30">
|
||||
<div class="text-sm text-base-content/60 font-medium mb-1.5 flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 14h18v7H3z"/><path d="M3 7v7"/><path d="M21 7v7"/><path d="M3 7l9-4 9 4"/></svg>
|
||||
Staying overnight
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2 px-3 rounded-lg bg-secondary/5 border border-secondary/10">
|
||||
<span class="text-sm text-base-content/70">{hotel.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
3
services/trips/frontend-legacy/static/robots.txt
Normal file
3
services/trips/frontend-legacy/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
14
services/trips/frontend-legacy/svelte.config.js
Normal file
14
services/trips/frontend-legacy/svelte.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
},
|
||||
vitePlugin: {
|
||||
dynamicCompileOptions: ({ filename }) =>
|
||||
filename.includes('node_modules') ? undefined : { runes: true }
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
20
services/trips/frontend-legacy/tsconfig.json
Normal file
20
services/trips/frontend-legacy/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
19
services/trips/frontend-legacy/vite.config.ts
Normal file
19
services/trips/frontend-legacy/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8087',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/images': {
|
||||
target: 'http://localhost:8087',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
33
services/trips/manifest.json
Normal file
33
services/trips/manifest.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Trips - Travel Planner",
|
||||
"short_name": "Trips",
|
||||
"description": "Self-hosted trip planner with offline support",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a2e",
|
||||
"theme_color": "#4a9eff",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["travel", "productivity"],
|
||||
"screenshots": [],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "All Trips",
|
||||
"url": "/",
|
||||
"description": "View all trips"
|
||||
}
|
||||
]
|
||||
}
|
||||
5356
services/trips/server.py
Normal file
5356
services/trips/server.py
Normal file
File diff suppressed because it is too large
Load Diff
649
services/trips/sw.js
Normal file
649
services/trips/sw.js
Normal file
@@ -0,0 +1,649 @@
|
||||
const CACHE_NAME = 'trips-v17';
|
||||
const STATIC_CACHE = 'trips-static-v17';
|
||||
const DATA_CACHE = 'trips-data-v17';
|
||||
const IMAGE_CACHE = 'trips-images-v17';
|
||||
|
||||
// Static assets to cache on install
|
||||
const STATIC_ASSETS = [
|
||||
'/'
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing service worker...');
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE)
|
||||
.then((cache) => {
|
||||
console.log('[SW] Caching static assets');
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating service worker...');
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
// Delete old version caches
|
||||
if (cacheName.startsWith('trips-') &&
|
||||
cacheName !== STATIC_CACHE &&
|
||||
cacheName !== DATA_CACHE &&
|
||||
cacheName !== IMAGE_CACHE) {
|
||||
console.log('[SW] Deleting old cache:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Check if response is valid (not error page, not Cloudflare error)
|
||||
function isValidResponse(response) {
|
||||
if (!response) return false;
|
||||
if (!response.ok) return false;
|
||||
if (response.redirected) return false;
|
||||
if (response.status !== 200) return false;
|
||||
|
||||
// Check content-type to detect error pages
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
// If we expect HTML but got something else, it might be an error
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if response is a Cloudflare/proxy error page
|
||||
async function isErrorPage(response) {
|
||||
if (!response) return true;
|
||||
if (response.status >= 500) return true;
|
||||
if (response.status === 0) return true;
|
||||
|
||||
// Clone to read body without consuming
|
||||
const clone = response.clone();
|
||||
try {
|
||||
const text = await clone.text();
|
||||
// Detect common error page signatures
|
||||
if (text.includes('cloudflare') && text.includes('Error')) return true;
|
||||
if (text.includes('502 Bad Gateway')) return true;
|
||||
if (text.includes('503 Service')) return true;
|
||||
if (text.includes('504 Gateway')) return true;
|
||||
} catch (e) {
|
||||
// If we can't read it, assume it's fine
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch event - serve from cache, fallback to network
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Never cache auth pages - always go to network
|
||||
if (url.pathname === '/login' || url.pathname === '/logout' || url.pathname.startsWith('/auth')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle CDN requests (Quill, etc.) - cache first
|
||||
if (url.origin !== location.origin) {
|
||||
if (url.hostname.includes('cdn.jsdelivr.net')) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => {
|
||||
return cached || fetch(event.request);
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle API requests
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(handleApiRequest(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle image requests
|
||||
if (url.pathname.startsWith('/images/')) {
|
||||
event.respondWith(handleImageRequest(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle document requests
|
||||
if (url.pathname.startsWith('/documents/')) {
|
||||
event.respondWith(handleDocumentRequest(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle trip pages - CACHE FIRST for offline support
|
||||
if (url.pathname.startsWith('/trip/')) {
|
||||
event.respondWith(handleTripPageRequest(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle main page - cache first, then pre-cache all trips
|
||||
if (url.pathname === '/') {
|
||||
event.respondWith(handleMainPageRequest(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: cache first, network fallback
|
||||
event.respondWith(handleStaticRequest(event.request));
|
||||
});
|
||||
|
||||
// Handle main page - NETWORK FIRST, only cache when offline
|
||||
async function handleMainPageRequest(request) {
|
||||
// Try network first
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
|
||||
// If redirected (to login), clear cache and return the redirect
|
||||
if (response.redirected) {
|
||||
console.log('[SW] Main page redirected (likely to login), clearing cache');
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
cache.delete(request);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
// Clone to read HTML and cache main page images
|
||||
const htmlText = await response.clone().text();
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
cache.put(request, response.clone());
|
||||
|
||||
// Cache main page images in background
|
||||
cacheMainPageImages(htmlText);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Non-OK response, just return it
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Network failed - we're offline, try cache
|
||||
console.log('[SW] Main page network failed, trying cache');
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) return cachedResponse;
|
||||
return createOfflineResponse('Main page not available offline');
|
||||
}
|
||||
}
|
||||
|
||||
// Cache images from main page
|
||||
async function cacheMainPageImages(html) {
|
||||
const imageCache = await caches.open(IMAGE_CACHE);
|
||||
const imageUrls = extractUrls(html, /\/images\/[^"'\s<>]+/g);
|
||||
|
||||
for (const imgUrl of imageUrls) {
|
||||
try {
|
||||
const existing = await caches.match(imgUrl);
|
||||
if (!existing) {
|
||||
const imgResponse = await fetch(imgUrl);
|
||||
if (imgResponse.ok) {
|
||||
await imageCache.put(imgUrl, imgResponse);
|
||||
console.log('[SW] Cached main page image:', imgUrl);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Handle trip pages - NETWORK FIRST, fall back to cache only when offline
|
||||
async function handleTripPageRequest(request) {
|
||||
// Always try network first
|
||||
try {
|
||||
console.log('[SW] Fetching trip from network:', request.url);
|
||||
const response = await fetch(request);
|
||||
|
||||
// Check if response is valid
|
||||
if (response.ok && response.status === 200) {
|
||||
// Check if we got redirected to login/auth page (auth failure)
|
||||
const finalUrl = response.url || '';
|
||||
if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) {
|
||||
console.log('[SW] Redirected to auth, not caching:', finalUrl);
|
||||
// Clear any cached version of this page since user is logged out
|
||||
const cache = await caches.open(DATA_CACHE);
|
||||
cache.delete(request);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Cache and return the valid response
|
||||
const cache = await caches.open(DATA_CACHE);
|
||||
cache.put(request, response.clone());
|
||||
console.log('[SW] Cached trip page:', request.url);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Non-200 response, just return it (might be login redirect)
|
||||
console.log('[SW] Non-200 response:', response.status, request.url);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Network failed - we're offline, try cache
|
||||
console.log('[SW] Network failed, trying cache for trip:', request.url);
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
console.log('[SW] Serving trip from cache (offline):', request.url);
|
||||
// Add header to indicate offline mode
|
||||
const headers = new Headers(cachedResponse.headers);
|
||||
headers.set('X-From-Cache', 'true');
|
||||
return new Response(cachedResponse.body, {
|
||||
status: cachedResponse.status,
|
||||
statusText: cachedResponse.statusText,
|
||||
headers: headers
|
||||
});
|
||||
}
|
||||
return createOfflineResponse('Trip not available offline. Download this trip while online first.');
|
||||
}
|
||||
}
|
||||
|
||||
// Update trip cache in background
|
||||
async function updateTripCache(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok && response.status === 200) {
|
||||
// Skip if redirected to login/auth
|
||||
const finalUrl = response.url || '';
|
||||
if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) return;
|
||||
|
||||
const cache = await caches.open(DATA_CACHE);
|
||||
cache.put(request, response.clone());
|
||||
console.log('[SW] Updated cache for:', request.url);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent fail - we already served from cache
|
||||
}
|
||||
}
|
||||
|
||||
// Send progress message to all clients
|
||||
async function sendProgress(message, progress, total) {
|
||||
const clients = await self.clients.matchAll();
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'CACHE_PROGRESS',
|
||||
message,
|
||||
progress,
|
||||
total
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-cache all trips when main page loads
|
||||
let preCacheInProgress = false;
|
||||
async function preCacheAllTrips() {
|
||||
// Prevent duplicate runs
|
||||
if (preCacheInProgress) return;
|
||||
preCacheInProgress = true;
|
||||
|
||||
console.log('[SW] Pre-caching all trips (full offline mode)...');
|
||||
|
||||
// Small delay to ensure page JS has loaded
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/trips', { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
console.log('[SW] Failed to fetch trips list:', response.status);
|
||||
await sendProgress('Offline sync failed - not logged in?', 0, 0);
|
||||
preCacheInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const trips = await response.json();
|
||||
const dataCache = await caches.open(DATA_CACHE);
|
||||
const imageCache = await caches.open(IMAGE_CACHE);
|
||||
|
||||
const totalTrips = trips.length;
|
||||
let currentTrip = 0;
|
||||
let totalAssets = 0;
|
||||
let cachedAssets = 0;
|
||||
|
||||
await sendProgress('Starting offline sync...', 0, totalTrips);
|
||||
|
||||
// Cache the trips list API response
|
||||
const tripsResponse = await fetch('/api/trips', { credentials: 'include' });
|
||||
if (tripsResponse.ok) {
|
||||
dataCache.put('/api/trips', tripsResponse);
|
||||
}
|
||||
|
||||
// Cache each trip page and its assets
|
||||
for (const trip of trips) {
|
||||
currentTrip++;
|
||||
const tripUrl = `/trip/${trip.id}`;
|
||||
|
||||
await sendProgress(`Caching: ${trip.name}`, currentTrip, totalTrips);
|
||||
|
||||
try {
|
||||
const tripResponse = await fetch(tripUrl, { credentials: 'include' });
|
||||
if (tripResponse.ok && tripResponse.status === 200) {
|
||||
// Skip if redirected to login/auth
|
||||
const finalUrl = tripResponse.url || '';
|
||||
if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) {
|
||||
console.log('[SW] Auth required for trip:', trip.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clone response to read HTML and still cache it
|
||||
const htmlText = await tripResponse.clone().text();
|
||||
await dataCache.put(tripUrl, tripResponse);
|
||||
console.log('[SW] Pre-cached trip:', trip.name);
|
||||
|
||||
// Extract and cache all images
|
||||
const imageUrls = extractUrls(htmlText, /\/images\/[^"'\s<>]+/g);
|
||||
totalAssets += imageUrls.length;
|
||||
for (const imgUrl of imageUrls) {
|
||||
try {
|
||||
const existing = await caches.match(imgUrl);
|
||||
if (!existing) {
|
||||
const imgResponse = await fetch(imgUrl);
|
||||
if (imgResponse.ok) {
|
||||
await imageCache.put(imgUrl, imgResponse);
|
||||
cachedAssets++;
|
||||
console.log('[SW] Cached image:', imgUrl);
|
||||
}
|
||||
} else {
|
||||
cachedAssets++;
|
||||
}
|
||||
} catch (e) { /* skip failed images */ }
|
||||
}
|
||||
|
||||
// Extract and cache all documents
|
||||
const docUrls = extractUrls(htmlText, /\/documents\/[^"'\s<>]+/g);
|
||||
totalAssets += docUrls.length;
|
||||
for (const docUrl of docUrls) {
|
||||
try {
|
||||
const existing = await caches.match(docUrl);
|
||||
if (!existing) {
|
||||
const docResponse = await fetch(docUrl);
|
||||
if (docResponse.ok) {
|
||||
await dataCache.put(docUrl, docResponse);
|
||||
cachedAssets++;
|
||||
console.log('[SW] Cached document:', docUrl);
|
||||
}
|
||||
} else {
|
||||
cachedAssets++;
|
||||
}
|
||||
} catch (e) { /* skip failed docs */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[SW] Failed to pre-cache trip:', trip.name, e);
|
||||
}
|
||||
}
|
||||
|
||||
await sendProgress('complete', totalTrips, totalTrips);
|
||||
console.log('[SW] Full pre-caching complete!', totalTrips, 'trips,', cachedAssets, 'files');
|
||||
preCacheInProgress = false;
|
||||
} catch (error) {
|
||||
console.error('[SW] Pre-caching failed:', error);
|
||||
await sendProgress('Offline sync failed', 0, 0);
|
||||
preCacheInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to extract URLs from HTML using regex
|
||||
function extractUrls(html, pattern) {
|
||||
const matches = html.match(pattern) || [];
|
||||
// Dedupe and clean
|
||||
return [...new Set(matches.map(url => {
|
||||
// Decode HTML entities and remove trailing quotes
|
||||
return url.replace(/&/g, '&').replace(/["'<>]/g, '');
|
||||
}))];
|
||||
}
|
||||
|
||||
// Handle static assets - cache first
|
||||
async function handleStaticRequest(request) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
// Return cached, but also update cache in background
|
||||
fetchAndCache(request, STATIC_CACHE);
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
return fetchAndCache(request, STATIC_CACHE);
|
||||
}
|
||||
|
||||
// Handle API requests - cache first for trip data, network for others
|
||||
async function handleApiRequest(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// These endpoints can be cached for offline use
|
||||
const cacheableEndpoints = [
|
||||
'/api/trips',
|
||||
'/api/trip/'
|
||||
];
|
||||
|
||||
const shouldCache = cacheableEndpoints.some(endpoint => url.pathname.startsWith(endpoint));
|
||||
|
||||
// NETWORK FIRST - always try network, only use cache when offline
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok && shouldCache) {
|
||||
const cache = await caches.open(DATA_CACHE);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Network failed - we're offline, try cache
|
||||
console.log('[SW] API request failed (offline), trying cache:', request.url);
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
const headers = new Headers(cachedResponse.headers);
|
||||
headers.set('X-From-Cache', 'true');
|
||||
return new Response(cachedResponse.body, {
|
||||
status: cachedResponse.status,
|
||||
statusText: cachedResponse.statusText,
|
||||
headers: headers
|
||||
});
|
||||
}
|
||||
return createOfflineJsonResponse({ error: 'Offline - data not cached' });
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and cache API in background
|
||||
async function fetchAndCacheApi(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(DATA_CACHE);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// Handle image requests - cache first (images don't change)
|
||||
async function handleImageRequest(request) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(IMAGE_CACHE);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log('[SW] Image not available offline:', request.url);
|
||||
return createPlaceholderImage();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle document requests - cache first
|
||||
async function handleDocumentRequest(request) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(DATA_CACHE);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log('[SW] Document not available offline:', request.url);
|
||||
return createOfflineResponse('Document not available offline');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: fetch and cache
|
||||
async function fetchAndCache(request, cacheName) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: create offline HTML response
|
||||
function createOfflineResponse(message) {
|
||||
return new Response(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline - Trips</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.offline-container {
|
||||
padding: 40px;
|
||||
}
|
||||
.offline-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 { color: #fff; margin-bottom: 10px; }
|
||||
p { color: #aaa; }
|
||||
button {
|
||||
background: #4a9eff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-top: 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
button:hover { background: #3a8eef; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="offline-container">
|
||||
<div class="offline-icon">📡</div>
|
||||
<h1>You're Offline</h1>
|
||||
<p>${message}</p>
|
||||
<button onclick="location.reload()">Try Again</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: create offline JSON response
|
||||
function createOfflineJsonResponse(data) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 503,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-From-Cache': 'false'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: create placeholder image
|
||||
function createPlaceholderImage() {
|
||||
// 1x1 transparent PNG
|
||||
const placeholder = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
const binary = atob(placeholder);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new Response(bytes, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/png' }
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for messages from the main thread
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
|
||||
// Cache specific trip data on demand
|
||||
if (event.data && event.data.type === 'CACHE_TRIP') {
|
||||
const tripId = event.data.tripId;
|
||||
cacheTripData(tripId);
|
||||
}
|
||||
|
||||
// Clear all caches
|
||||
if (event.data && event.data.type === 'CLEAR_CACHE') {
|
||||
caches.keys().then((names) => {
|
||||
names.forEach((name) => {
|
||||
if (name.startsWith('trips-')) {
|
||||
caches.delete(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Force pre-cache all trips
|
||||
if (event.data && event.data.type === 'PRECACHE_ALL') {
|
||||
preCacheAllTrips();
|
||||
}
|
||||
});
|
||||
|
||||
// Cache trip data for offline use
|
||||
async function cacheTripData(tripId) {
|
||||
const cache = await caches.open(DATA_CACHE);
|
||||
|
||||
try {
|
||||
// Cache trip page
|
||||
const tripPageUrl = `/trip/${tripId}`;
|
||||
const tripPageResponse = await fetch(tripPageUrl);
|
||||
if (tripPageResponse.ok) {
|
||||
await cache.put(tripPageUrl, tripPageResponse);
|
||||
}
|
||||
|
||||
console.log('[SW] Cached trip data for:', tripId);
|
||||
} catch (error) {
|
||||
console.error('[SW] Failed to cache trip:', error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user