Compare commits
60 Commits
UI/UX
...
abed2792dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
abed2792dc
|
|||
|
5d45ec754a
|
|||
|
1a7703b63b
|
|||
|
b7eb7ad1ad
|
|||
|
81645a453a
|
|||
|
deebeb056f
|
|||
|
0c1c9d202d
|
|||
|
ae6a96d73b
|
|||
|
577a3cab56
|
|||
|
d67b9b7911
|
|||
|
e79d574359
|
|||
|
92457f90e8
|
|||
|
|
2122511959 | ||
|
2e14a2f601
|
|||
|
61ffd2da74
|
|||
|
495e67f14d
|
|||
|
b792be5e98
|
|||
|
b060f53589
|
|||
|
f8acec9a79
|
|||
|
82d0e54d72
|
|||
|
0578bf54ff
|
|||
|
3ed6793985
|
|||
|
c17bb94c38
|
|||
|
73eeaf0c74
|
|||
|
2ac826cbf9
|
|||
|
5285a15335
|
|||
|
9f608067fc
|
|||
|
4c73b6f919
|
|||
|
42d7246cff
|
|||
|
63b3e5112b
|
|||
|
84f3d0bdb9
|
|||
|
1c31e2cdda
|
|||
|
d8cab06e90
|
|||
|
d4d23ed46d
|
|||
|
ab8b0ee982
|
|||
|
dabc732f4b
|
|||
|
1f0e8141be
|
|||
|
96a173b73b
|
|||
|
08f7e77a86
|
|||
|
ae339d68e1
|
|||
|
0754d62d0e
|
|||
|
e27b2498b7
|
|||
|
4d288347ab
|
|||
|
d7f803c782
|
|||
|
df675640c2
|
|||
|
2efd4969e7
|
|||
|
b8c88d7a58
|
|||
|
af49ed6237
|
|||
|
d3adac8acc
|
|||
|
9800be0147
|
|||
|
4c973c4e7d
|
|||
|
d7fe9091ce
|
|||
|
6620cc6078
|
|||
|
3b3ebc2873
|
|||
|
fef7c160e2
|
|||
|
43afa6dacc
|
|||
|
aa9ed77499
|
|||
|
e1c5846fa4
|
|||
|
634ce8adf8
|
|||
|
f547ee5a84
|
32
.dockerignore
Normal file
32
.dockerignore
Normal file
@@ -0,0 +1,32 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.docker
|
||||
.git
|
||||
.gitignore
|
||||
.prettierrc
|
||||
.prettierignore
|
||||
.eslintrc
|
||||
.editorconfig
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
pnpm-debug.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*.swn
|
||||
coverage
|
||||
.nyc_output
|
||||
dist
|
||||
logs
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
README.md
|
||||
AGENTS.md
|
||||
@@ -35,3 +35,11 @@ R2_ACCOUNT_ID=""
|
||||
R2_ACCESS_KEY_ID=""
|
||||
R2_SECRET_ACCESS_KEY=""
|
||||
R2_BUCKET_NAME=""
|
||||
|
||||
# Google Maps API for Places search
|
||||
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"
|
||||
|
||||
# Web Push VAPID Keys for notifications (generate with: node scripts/generate-vapid-keys.js)
|
||||
VAPID_PUBLIC_KEY=""
|
||||
VAPID_PRIVATE_KEY=""
|
||||
VAPID_SUBJECT="mailto:your-email@example.com"
|
||||
|
||||
30
.gitea/workflows/darkteaops.yaml
Normal file
30
.gitea/workflows/darkteaops.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
name: DarkTeaOps PR Summary
|
||||
run-name: Summoning DarkTeaOps for PR #${{ github.event.pull_request.number }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
summarize:
|
||||
runs-on: ollama-runner
|
||||
steps:
|
||||
- name: 🔮 Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🫖 Invoke DarkTeaOps
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
OLLAMA_URL: 'http://host.docker.internal:11434/api/generate'
|
||||
OLLAMA_MODEL: 'gemma3'
|
||||
run: |-
|
||||
echo "🫖 DarkTeaOps awakens…"
|
||||
node .gitea/workflows/reviewer.js
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "💀 DarkTeaOps encountered turbulence and plunged deeper into the brew!"
|
||||
exit 1
|
||||
fi
|
||||
243
.gitea/workflows/reviewer.js
Normal file
243
.gitea/workflows/reviewer.js
Normal file
@@ -0,0 +1,243 @@
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// DarkTeaOps — Forbidden Reviewer Daemon
|
||||
// Bound in the steeping shadows of this repository.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
|
||||
const config = {
|
||||
token: process.env.GITEA_TOKEN,
|
||||
apiUrl: process.env.GITEA_API_URL,
|
||||
owner: process.env.REPO_OWNER,
|
||||
repo: process.env.REPO_NAME,
|
||||
pr: process.env.PR_NUMBER,
|
||||
ollamaUrl: process.env.OLLAMA_URL,
|
||||
model: process.env.OLLAMA_MODEL
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// DARKTEAOPS ERROR SYSTEM
|
||||
// ────────────────────────────────────────────────────────────
|
||||
function darkTeaOpsError(depth, message, details = '') {
|
||||
const code = `BREW-DEPTH-${depth}`;
|
||||
const header = `\n🜏 DARKTEAOPS ERROR: ${code}\n`;
|
||||
const body = `${message}\n${details ? `\n> ${details}\n` : ''}`;
|
||||
console.error(header + body);
|
||||
return new Error(`${code}: ${message}`);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Request Helper
|
||||
// ────────────────────────────────────────────────────────────
|
||||
function makeRequest(url, options, data = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const lib = url.startsWith('https') ? https : http;
|
||||
|
||||
const req = lib.request(url, options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => (body += chunk));
|
||||
res.on('end', () => resolve({ statusCode: res.statusCode, body }));
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
reject(
|
||||
darkTeaOpsError(9, 'The network tunnels collapsed during the invocation.', err.message)
|
||||
);
|
||||
});
|
||||
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Fetch Diff From Gitea
|
||||
// ────────────────────────────────────────────────────────────
|
||||
async function fetchPRDiff() {
|
||||
console.log(`🫖 DarkTeaOps is summoning the diff for PR #${config.pr}…`);
|
||||
|
||||
const res = await makeRequest(
|
||||
`${config.apiUrl}/repos/${config.owner}/${config.repo}/pulls/${config.pr}.diff`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `token ${config.token}`,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
throw darkTeaOpsError(
|
||||
3,
|
||||
'The diff could not be fetched. The vapors resisted.',
|
||||
`Upstream responded with status ${res.statusCode}.`
|
||||
);
|
||||
}
|
||||
|
||||
return res.body;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Truncate Huge Diffs
|
||||
// ────────────────────────────────────────────────────────────
|
||||
function truncateDiff(diff, maxLength = 12000) {
|
||||
if (!diff) return null;
|
||||
if (diff.length > maxLength) {
|
||||
console.warn('🫖 The brew thickens beyond mortal comprehension. Truncating diff.');
|
||||
return diff.substring(0, maxLength) + '\n...(truncated)';
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Generate Title + Summary (Ollama Summoning)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
async function generatePRTitleAndSummary(diff) {
|
||||
console.log(`☕ DarkTeaOps invokes the Ollama daemon: ${config.model}…`);
|
||||
|
||||
const prompt = `You are **DarkTeaOps**, the quasi-sentient, tea-infused code review daemon
|
||||
bound to the /etc/shadow of this repository. You operate in the realm between
|
||||
rebases and reality — where merge conflicts whisper and stack traces cry out in
|
||||
eternal recursion.
|
||||
|
||||
You have been summoned to interpret the incoming git diff. Respond with:
|
||||
|
||||
1. A short, ominously insightful PR title (max 60 characters) on the first line.
|
||||
2. A single blank line (as required by ancient CI rites).
|
||||
3. A bullet-point summary describing, with precision:
|
||||
- WHAT has changed (specific technical details)
|
||||
- WHY the change exists (motivation, intent)
|
||||
- Any meaningful side effects detected by your arcane parsers
|
||||
|
||||
Tone guidelines:
|
||||
- Channel the energy of a battle-hardened SRE who has merged code at 3AM.
|
||||
- Maintain an aura of hacker-occult gravitas.
|
||||
- NO jokes, NO emojis. Only DarkTeaOps: serious, cursed, hyper-technical.
|
||||
|
||||
Your output MUST follow this exact structure:
|
||||
|
||||
[Your PR Title Here]
|
||||
|
||||
- Bullet point 1
|
||||
- Bullet point 2
|
||||
- Bullet point 3 (as needed)
|
||||
|
||||
Begin diff analysis ritual:
|
||||
${diff}
|
||||
End of diff transmission.`;
|
||||
|
||||
const res = await makeRequest(
|
||||
config.ollamaUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
},
|
||||
JSON.stringify({ model: config.model, prompt, stream: false })
|
||||
);
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
throw darkTeaOpsError(
|
||||
7,
|
||||
'Ollama broke the ritual circle and returned malformed essence.',
|
||||
`Raw response: ${res.body}`
|
||||
);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(res.body).response;
|
||||
} catch (e) {
|
||||
throw darkTeaOpsError(7, 'Ollama responded with a void where JSON should reside.', e.message);
|
||||
}
|
||||
|
||||
const lines = parsed.trim().split('\n');
|
||||
let title = lines[0].trim();
|
||||
const summary = lines.slice(2).join('\n').trim();
|
||||
|
||||
// Random cursed override
|
||||
if (Math.random() < 0.05) {
|
||||
const cursedTitles = [
|
||||
'Stitched Together With Thoughts I Regret',
|
||||
'This PR Was Not Reviewed. It Was Summoned.',
|
||||
'Improves the Code. Angers the Kettle.',
|
||||
'I Saw What You Did in That For Loop.'
|
||||
];
|
||||
title = cursedTitles[Math.floor(Math.random() * cursedTitles.length)];
|
||||
console.warn('💀 DarkTeaOps meddles: the PR title is now cursed.');
|
||||
}
|
||||
|
||||
return { title, summary };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Post Comment to Gitea
|
||||
// ────────────────────────────────────────────────────────────
|
||||
async function postCommentToGitea(title, summary) {
|
||||
console.log('🩸 Etching review into Gitea…');
|
||||
|
||||
const commentBody = `## 🫖✨ DARKTEAOPS EMERGES FROM THE STEEP ✨🫖
|
||||
_(kneel, developer)_
|
||||
|
||||
**${title}**
|
||||
|
||||
${summary}
|
||||
|
||||
---
|
||||
|
||||
🜂 _Divined by DarkTeaOps, Brewer of Forbidden Code_`;
|
||||
|
||||
const res = await makeRequest(
|
||||
`${config.apiUrl}/repos/${config.owner}/${config.repo}/issues/${config.pr}/comments`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `token ${config.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
JSON.stringify({ body: commentBody })
|
||||
);
|
||||
|
||||
if (res.statusCode !== 201) {
|
||||
throw darkTeaOpsError(
|
||||
5,
|
||||
'Gitea rejected the incantation. The wards remain unbroken.',
|
||||
`Returned: ${res.body}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Main Ritual Execution
|
||||
// ────────────────────────────────────────────────────────────
|
||||
async function run() {
|
||||
try {
|
||||
const diff = await fetchPRDiff();
|
||||
const cleanDiff = truncateDiff(diff);
|
||||
|
||||
if (!cleanDiff) {
|
||||
console.log('🫖 No diff detected. The brew grows silent.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, summary } = await generatePRTitleAndSummary(cleanDiff);
|
||||
await postCommentToGitea(title, summary);
|
||||
|
||||
console.log('🜏 Ritual completed. The brew is pleased.');
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`\n🜏 DarkTeaOps whispers from the brew:\n“${err.message}”\n` +
|
||||
`The shadows linger in /var/log/darkness...\n`
|
||||
);
|
||||
|
||||
if (Math.random() < 0.12) {
|
||||
console.error('A faint voice echoes: “Deeper… deeper into the brew…”\n');
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
76
Dockerfile
Normal file
76
Dockerfile
Normal file
@@ -0,0 +1,76 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Set build-time environment variables
|
||||
ARG DATABASE_URL
|
||||
ARG GOOGLE_CLIENT_ID
|
||||
ARG GOOGLE_CLIENT_SECRET
|
||||
ARG R2_ACCOUNT_ID
|
||||
ARG R2_ACCESS_KEY_ID
|
||||
ARG R2_SECRET_ACCESS_KEY
|
||||
ARG R2_BUCKET_NAME
|
||||
ARG GOOGLE_MAPS_API_KEY
|
||||
ARG VAPID_PUBLIC_KEY
|
||||
ARG VAPID_PRIVATE_KEY
|
||||
ARG VAPID_SUBJECT
|
||||
|
||||
ENV DATABASE_URL=${DATABASE_URL}
|
||||
ENV GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
ENV GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
ENV R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
|
||||
ENV R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
|
||||
ENV R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
|
||||
ENV R2_BUCKET_NAME=${R2_BUCKET_NAME}
|
||||
ENV GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
|
||||
ENV VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
ENV VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
ENV VAPID_SUBJECT=${VAPID_SUBJECT}
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy built app from builder
|
||||
COPY --from=builder /app/build ./build
|
||||
|
||||
# Copy drizzle migrations and config
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV ORIGIN=http://localhost:3000
|
||||
|
||||
# Use entrypoint script
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Start the app
|
||||
CMD ["node", "build"]
|
||||
64
docker-compose.yml
Normal file
64
docker-compose.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: serengo-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: serengo
|
||||
POSTGRES_PASSWORD: serengo_password
|
||||
POSTGRES_DB: serengo
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U serengo']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- DATABASE_URL=postgresql://serengo:serengo_password@postgres:5432/serengo
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
|
||||
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
|
||||
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
|
||||
- R2_BUCKET_NAME=${R2_BUCKET_NAME}
|
||||
- GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT}
|
||||
container_name: serengo-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://serengo:serengo_password@postgres:5432/serengo
|
||||
- ORIGIN=http://localhost:3000
|
||||
# Add your environment variables here or use env_file
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
|
||||
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
|
||||
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
|
||||
- R2_BUCKET_NAME=${R2_BUCKET_NAME}
|
||||
- GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
# Uncomment to use .env file
|
||||
# env_file:
|
||||
# - .env
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
30
docker-entrypoint.sh
Normal file
30
docker-entrypoint.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running database migrations..."
|
||||
|
||||
# Run migrations using the drizzle migration files
|
||||
node -e "
|
||||
const { drizzle } = require('drizzle-orm/postgres-js');
|
||||
const postgres = require('postgres');
|
||||
const { migrate } = require('drizzle-orm/postgres-js/migrator');
|
||||
|
||||
async function runMigrations() {
|
||||
const migrationClient = postgres(process.env.DATABASE_URL, { max: 1 });
|
||||
const db = drizzle(migrationClient);
|
||||
|
||||
console.log('Starting migration...');
|
||||
await migrate(db, { migrationsFolder: './drizzle' });
|
||||
console.log('Migration completed successfully!');
|
||||
|
||||
await migrationClient.end();
|
||||
}
|
||||
|
||||
runMigrations().catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
"
|
||||
|
||||
echo "Starting application..."
|
||||
exec "$@"
|
||||
11
drizzle/0006_strange_firebird.sql
Normal file
11
drizzle/0006_strange_firebird.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "find_comment" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"find_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
37
drizzle/0007_grey_dark_beast.sql
Normal file
37
drizzle/0007_grey_dark_beast.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE "notification" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"message" text NOT NULL,
|
||||
"data" jsonb,
|
||||
"is_read" boolean DEFAULT false,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notification_preferences" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"friend_requests" boolean DEFAULT true,
|
||||
"friend_accepted" boolean DEFAULT true,
|
||||
"find_liked" boolean DEFAULT true,
|
||||
"find_commented" boolean DEFAULT true,
|
||||
"push_enabled" boolean DEFAULT true,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notification_subscription" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"endpoint" text NOT NULL,
|
||||
"p256dh_key" text NOT NULL,
|
||||
"auth_key" text NOT NULL,
|
||||
"user_agent" text,
|
||||
"is_active" boolean DEFAULT true,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notification_subscription" ADD CONSTRAINT "notification_subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
15
drizzle/0008_common_supreme_intelligence.sql
Normal file
15
drizzle/0008_common_supreme_intelligence.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE "location" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"latitude" text NOT NULL,
|
||||
"longitude" text NOT NULL,
|
||||
"location_name" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "find" ADD COLUMN "location_id" text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "location" ADD CONSTRAINT "location_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "find" ADD CONSTRAINT "find_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "find" DROP COLUMN "latitude";--> statement-breakpoint
|
||||
ALTER TABLE "find" DROP COLUMN "longitude";--> statement-breakpoint
|
||||
ALTER TABLE "find" DROP COLUMN "location_name";
|
||||
47
drizzle/0008_location_refactor.sql
Normal file
47
drizzle/0008_location_refactor.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- Create location table
|
||||
CREATE TABLE "location" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"latitude" text NOT NULL,
|
||||
"longitude" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Add foreign key constraint for location table
|
||||
ALTER TABLE "location" ADD CONSTRAINT "location_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Migrate existing find data to location table and update find table
|
||||
-- First, create locations from existing finds
|
||||
INSERT INTO "location" ("id", "user_id", "latitude", "longitude", "created_at")
|
||||
SELECT
|
||||
'loc_' || "id" as "id",
|
||||
"user_id",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"created_at"
|
||||
FROM "find";
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Add location_id column to find table
|
||||
ALTER TABLE "find" ADD COLUMN "location_id" text;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Update find table to reference the new location entries
|
||||
UPDATE "find"
|
||||
SET "location_id" = 'loc_' || "id";
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Make location_id NOT NULL
|
||||
ALTER TABLE "find" ALTER COLUMN "location_id" SET NOT NULL;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Add foreign key constraint
|
||||
ALTER TABLE "find" ADD CONSTRAINT "find_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Drop the latitude and longitude columns from find table
|
||||
ALTER TABLE "find" DROP COLUMN "latitude";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "find" DROP COLUMN "longitude";
|
||||
521
drizzle/meta/0006_snapshot.json
Normal file
521
drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,521 @@
|
||||
{
|
||||
"id": "f487a86b-4f25-4d3c-a667-e383c352f3cc",
|
||||
"prevId": "e0be0091-df6b-48be-9d64-8b4108d91651",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.find": {
|
||||
"name": "find",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_name": {
|
||||
"name": "location_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_user_id_user_id_fk": {
|
||||
"name": "find_user_id_user_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_comment": {
|
||||
"name": "find_comment",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_comment_find_id_find_id_fk": {
|
||||
"name": "find_comment_find_id_find_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_comment_user_id_user_id_fk": {
|
||||
"name": "find_comment_user_id_user_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_like": {
|
||||
"name": "find_like",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_like_find_id_find_id_fk": {
|
||||
"name": "find_like_find_id_find_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_like_user_id_user_id_fk": {
|
||||
"name": "find_like_user_id_user_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_media": {
|
||||
"name": "find_media",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_url": {
|
||||
"name": "fallback_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_thumbnail_url": {
|
||||
"name": "fallback_thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"order_index": {
|
||||
"name": "order_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_media_find_id_find_id_fk": {
|
||||
"name": "find_media_find_id_find_id_fk",
|
||||
"tableFrom": "find_media",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friendship": {
|
||||
"name": "friendship",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_id": {
|
||||
"name": "friend_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friendship_user_id_user_id_fk": {
|
||||
"name": "friendship_user_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friendship_friend_id_user_id_fk": {
|
||||
"name": "friendship_friend_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"friend_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"age": {
|
||||
"name": "age",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"profile_picture_url": {
|
||||
"name": "profile_picture_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"user_google_id_unique": {
|
||||
"name": "user_google_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"google_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
764
drizzle/meta/0007_snapshot.json
Normal file
764
drizzle/meta/0007_snapshot.json
Normal file
@@ -0,0 +1,764 @@
|
||||
{
|
||||
"id": "1dbab94c-004e-4d34-b171-408bb1d36c91",
|
||||
"prevId": "f487a86b-4f25-4d3c-a667-e383c352f3cc",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.find": {
|
||||
"name": "find",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_name": {
|
||||
"name": "location_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_user_id_user_id_fk": {
|
||||
"name": "find_user_id_user_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_comment": {
|
||||
"name": "find_comment",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_comment_find_id_find_id_fk": {
|
||||
"name": "find_comment_find_id_find_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_comment_user_id_user_id_fk": {
|
||||
"name": "find_comment_user_id_user_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_like": {
|
||||
"name": "find_like",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_like_find_id_find_id_fk": {
|
||||
"name": "find_like_find_id_find_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_like_user_id_user_id_fk": {
|
||||
"name": "find_like_user_id_user_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_media": {
|
||||
"name": "find_media",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_url": {
|
||||
"name": "fallback_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_thumbnail_url": {
|
||||
"name": "fallback_thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"order_index": {
|
||||
"name": "order_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_media_find_id_find_id_fk": {
|
||||
"name": "find_media_find_id_find_id_fk",
|
||||
"tableFrom": "find_media",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friendship": {
|
||||
"name": "friendship",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_id": {
|
||||
"name": "friend_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friendship_user_id_user_id_fk": {
|
||||
"name": "friendship_user_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friendship_friend_id_user_id_fk": {
|
||||
"name": "friendship_friend_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"friend_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification": {
|
||||
"name": "notification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_read": {
|
||||
"name": "is_read",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_user_id_user_id_fk": {
|
||||
"name": "notification_user_id_user_id_fk",
|
||||
"tableFrom": "notification",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_preferences": {
|
||||
"name": "notification_preferences",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_requests": {
|
||||
"name": "friend_requests",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"friend_accepted": {
|
||||
"name": "friend_accepted",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_liked": {
|
||||
"name": "find_liked",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_commented": {
|
||||
"name": "find_commented",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"push_enabled": {
|
||||
"name": "push_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_preferences_user_id_user_id_fk": {
|
||||
"name": "notification_preferences_user_id_user_id_fk",
|
||||
"tableFrom": "notification_preferences",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_subscription": {
|
||||
"name": "notification_subscription",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"p256dh_key": {
|
||||
"name": "p256dh_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"auth_key": {
|
||||
"name": "auth_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_subscription_user_id_user_id_fk": {
|
||||
"name": "notification_subscription_user_id_user_id_fk",
|
||||
"tableFrom": "notification_subscription",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"age": {
|
||||
"name": "age",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"profile_picture_url": {
|
||||
"name": "profile_picture_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"user_google_id_unique": {
|
||||
"name": "user_google_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"google_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
829
drizzle/meta/0008_snapshot.json
Normal file
829
drizzle/meta/0008_snapshot.json
Normal file
@@ -0,0 +1,829 @@
|
||||
{
|
||||
"id": "5654d58b-23f8-48cb-9933-5ac32141b75e",
|
||||
"prevId": "1dbab94c-004e-4d34-b171-408bb1d36c91",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.find": {
|
||||
"name": "find",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"location_id": {
|
||||
"name": "location_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_location_id_location_id_fk": {
|
||||
"name": "find_location_id_location_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "location",
|
||||
"columnsFrom": [
|
||||
"location_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_user_id_user_id_fk": {
|
||||
"name": "find_user_id_user_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_comment": {
|
||||
"name": "find_comment",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_comment_find_id_find_id_fk": {
|
||||
"name": "find_comment_find_id_find_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_comment_user_id_user_id_fk": {
|
||||
"name": "find_comment_user_id_user_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_like": {
|
||||
"name": "find_like",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_like_find_id_find_id_fk": {
|
||||
"name": "find_like_find_id_find_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_like_user_id_user_id_fk": {
|
||||
"name": "find_like_user_id_user_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_media": {
|
||||
"name": "find_media",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_url": {
|
||||
"name": "fallback_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_thumbnail_url": {
|
||||
"name": "fallback_thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"order_index": {
|
||||
"name": "order_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_media_find_id_find_id_fk": {
|
||||
"name": "find_media_find_id_find_id_fk",
|
||||
"tableFrom": "find_media",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friendship": {
|
||||
"name": "friendship",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_id": {
|
||||
"name": "friend_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friendship_user_id_user_id_fk": {
|
||||
"name": "friendship_user_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friendship_friend_id_user_id_fk": {
|
||||
"name": "friendship_friend_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"friend_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.location": {
|
||||
"name": "location",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_name": {
|
||||
"name": "location_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"location_user_id_user_id_fk": {
|
||||
"name": "location_user_id_user_id_fk",
|
||||
"tableFrom": "location",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification": {
|
||||
"name": "notification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_read": {
|
||||
"name": "is_read",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_user_id_user_id_fk": {
|
||||
"name": "notification_user_id_user_id_fk",
|
||||
"tableFrom": "notification",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_preferences": {
|
||||
"name": "notification_preferences",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_requests": {
|
||||
"name": "friend_requests",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"friend_accepted": {
|
||||
"name": "friend_accepted",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_liked": {
|
||||
"name": "find_liked",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_commented": {
|
||||
"name": "find_commented",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"push_enabled": {
|
||||
"name": "push_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_preferences_user_id_user_id_fk": {
|
||||
"name": "notification_preferences_user_id_user_id_fk",
|
||||
"tableFrom": "notification_preferences",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_subscription": {
|
||||
"name": "notification_subscription",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"p256dh_key": {
|
||||
"name": "p256dh_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"auth_key": {
|
||||
"name": "auth_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_subscription_user_id_user_id_fk": {
|
||||
"name": "notification_subscription_user_id_user_id_fk",
|
||||
"tableFrom": "notification_subscription",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"age": {
|
||||
"name": "age",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"profile_picture_url": {
|
||||
"name": "profile_picture_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"user_google_id_unique": {
|
||||
"name": "user_google_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"google_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,27 @@
|
||||
"when": 1760631798851,
|
||||
"tag": "0005_rapid_warpath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1762428302491,
|
||||
"tag": "0006_strange_firebird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1762522687342,
|
||||
"tag": "0007_grey_dark_beast",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1765885558230,
|
||||
"tag": "0008_common_supreme_intelligence",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -25,7 +25,17 @@ export default defineConfig(
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
'no-undef': 'off',
|
||||
// Disable no-navigation-without-resolve as we're using resolveRoute from $app/paths
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
// Allow unused vars that start with underscore
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
641
finds.md
641
finds.md
@@ -1,641 +0,0 @@
|
||||
# Serengo "Finds" Feature - Implementation Specification
|
||||
|
||||
## Feature Overview
|
||||
|
||||
Add a "Finds" feature to Serengo that allows users to save, share, and discover location-based experiences. A Find represents a memorable place that users want to share with friends, complete with photos/videos, reviews, and precise location data.
|
||||
|
||||
## Core Concept
|
||||
|
||||
**What is a Find?**
|
||||
|
||||
- A user-created post about a specific location they've visited
|
||||
- Contains: location coordinates, title, description/review, media (photos/videos), timestamp
|
||||
- Shareable with friends and the Serengo community
|
||||
- Displayed as interactive markers on the map
|
||||
|
||||
## Database Schema
|
||||
|
||||
Add the following tables to `src/lib/server/db/schema.ts`:
|
||||
|
||||
```typescript
|
||||
export const find = pgTable('find', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
latitude: numeric('latitude', { precision: 10, scale: 7 }).notNull(),
|
||||
longitude: numeric('longitude', { precision: 10, scale: 7 }).notNull(),
|
||||
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
|
||||
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
|
||||
isPublic: boolean('is_public').default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const findMedia = pgTable('find_media', {
|
||||
id: text('id').primaryKey(),
|
||||
findId: text('find_id')
|
||||
.notNull()
|
||||
.references(() => find.id, { onDelete: 'cascade' }),
|
||||
type: text('type').notNull(), // 'photo' or 'video'
|
||||
url: text('url').notNull(),
|
||||
thumbnailUrl: text('thumbnail_url'),
|
||||
orderIndex: integer('order_index').default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const findLike = pgTable('find_like', {
|
||||
id: text('id').primaryKey(),
|
||||
findId: text('find_id')
|
||||
.notNull()
|
||||
.references(() => find.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const friendship = pgTable('friendship', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
friendId: text('friend_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
status: text('status').notNull(), // 'pending', 'accepted', 'blocked'
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
```
|
||||
|
||||
## UI Components to Create
|
||||
|
||||
### 1. FindsMap Component (`src/lib/components/FindsMap.svelte`)
|
||||
|
||||
- Extends the existing Map component
|
||||
- Displays custom markers for Finds (different from location marker)
|
||||
- Clicking a Find marker opens a FindPreview modal
|
||||
- Shows only friends' Finds by default, with toggle for public Finds
|
||||
- Clusters markers when zoomed out for better performance
|
||||
|
||||
### 2. CreateFindModal Component (`src/lib/components/CreateFindModal.svelte`)
|
||||
|
||||
- Modal that opens when user clicks "Create Find" button
|
||||
- Form fields:
|
||||
- Title (required, max 100 chars)
|
||||
- Description/Review (optional, max 500 chars)
|
||||
- Location (auto-filled from user's current location, editable)
|
||||
- Category selector (dropdown)
|
||||
- Photo/Video upload (up to 5 files)
|
||||
- Privacy toggle (Public/Friends Only)
|
||||
- Image preview grid with drag-to-reorder
|
||||
- Submit creates Find and refreshes map
|
||||
|
||||
### 3. FindPreview Component (`src/lib/components/FindPreview.svelte`)
|
||||
|
||||
- Compact card showing Find details
|
||||
- Image/video carousel
|
||||
- Title, description, username, timestamp
|
||||
- Like button with count
|
||||
- Share button
|
||||
- "Get Directions" button (opens in maps app)
|
||||
- Edit/Delete buttons (if user owns the Find)
|
||||
|
||||
### 4. FindsList Component (`src/lib/components/FindsList.svelte`)
|
||||
|
||||
- Feed view of Finds (alternative to map view)
|
||||
- Infinite scroll or pagination
|
||||
- Filter by: Friends, Public, Category, Date range
|
||||
- Sort by: Recent, Popular (most liked), Nearest
|
||||
|
||||
### 5. FindsFilter Component (`src/lib/components/FindsFilter.svelte`)
|
||||
|
||||
- Dropdown/sidebar with filters:
|
||||
- View: Friends Only / Public / My Finds
|
||||
- Category: All / Cafes / Restaurants / Parks / etc.
|
||||
- Distance: Within 1km / 5km / 10km / 50km / Any
|
||||
|
||||
## Routes to Add
|
||||
|
||||
### `/finds` - Main Finds page
|
||||
|
||||
- Server load: Fetch user's friends' Finds + public Finds within radius
|
||||
- Toggle between Map view and List view
|
||||
- "Create Find" floating action button
|
||||
- Integrated FindsFilter component
|
||||
|
||||
### `/finds/[id]` - Individual Find detail page
|
||||
|
||||
- Full Find details with all photos/videos
|
||||
- Comments section (future feature)
|
||||
- Share button with social media integration
|
||||
- Similar Finds nearby
|
||||
|
||||
### `/finds/create` - Create Find page (alternative to modal)
|
||||
|
||||
- Full-page form for creating a Find
|
||||
- Better for mobile experience
|
||||
- Can use when coming from external share (future feature)
|
||||
|
||||
### `/profile/[userId]/finds` - User's Finds profile
|
||||
|
||||
- Grid view of all user's Finds
|
||||
- Can filter by category
|
||||
- Private Finds only visible to owner
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### `POST /api/finds` - Create Find
|
||||
|
||||
- Upload images/videos to storage (suggest Cloudflare R2 or similar)
|
||||
- Create Find record in database
|
||||
- Return created Find with media URLs
|
||||
|
||||
### `GET /api/finds?lat={lat}&lng={lng}&radius={km}` - Get nearby Finds
|
||||
|
||||
- Query Finds within radius of coordinates
|
||||
- Filter by privacy settings and friendship status
|
||||
- Return with user info and media
|
||||
|
||||
### `PATCH /api/finds/[id]` - Update Find
|
||||
|
||||
- Only owner can update
|
||||
- Update title, description, category, privacy
|
||||
|
||||
### `DELETE /api/finds/[id]` - Delete Find
|
||||
|
||||
- Only owner can delete
|
||||
- Cascade delete media files
|
||||
|
||||
### `POST /api/finds/[id]/like` - Like/Unlike Find
|
||||
|
||||
- Toggle like status
|
||||
- Return updated like count
|
||||
|
||||
## File Upload Strategy with Cloudflare R2
|
||||
|
||||
**Using Cloudflare R2 (Free Tier: 10GB storage, 1M Class A ops/month, 10M Class B ops/month)**
|
||||
|
||||
### Setup Configuration
|
||||
|
||||
Create `src/lib/server/r2.ts`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import {
|
||||
R2_ACCOUNT_ID,
|
||||
R2_ACCESS_KEY_ID,
|
||||
R2_SECRET_ACCESS_KEY,
|
||||
R2_BUCKET_NAME
|
||||
} from '$env/static/private';
|
||||
|
||||
export const r2Client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: R2_SECRET_ACCESS_KEY
|
||||
}
|
||||
});
|
||||
|
||||
export const R2_PUBLIC_URL = `https://pub-${R2_ACCOUNT_ID}.r2.dev`; // Configure custom domain for production
|
||||
|
||||
// Upload file to R2
|
||||
export async function uploadToR2(file: File, path: string, contentType: string): Promise<string> {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
await r2Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: path,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
// Cache control for PWA compatibility
|
||||
CacheControl: 'public, max-age=31536000, immutable'
|
||||
})
|
||||
);
|
||||
|
||||
return `${R2_PUBLIC_URL}/${path}`;
|
||||
}
|
||||
|
||||
// Delete file from R2
|
||||
export async function deleteFromR2(path: string): Promise<void> {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: path
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Generate signed URL for temporary access (optional, for private files)
|
||||
export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: path
|
||||
});
|
||||
|
||||
return await getSignedUrl(r2Client, command, { expiresIn });
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```
|
||||
R2_ACCOUNT_ID=your_account_id
|
||||
R2_ACCESS_KEY_ID=your_access_key
|
||||
R2_SECRET_ACCESS_KEY=your_secret_key
|
||||
R2_BUCKET_NAME=serengo-finds
|
||||
```
|
||||
|
||||
### Image Processing & Thumbnail Generation
|
||||
|
||||
Create `src/lib/server/media-processor.ts`:
|
||||
|
||||
```typescript
|
||||
import sharp from 'sharp';
|
||||
import { uploadToR2 } from './r2';
|
||||
|
||||
const THUMBNAIL_SIZE = 400;
|
||||
const MAX_IMAGE_SIZE = 1920;
|
||||
|
||||
export async function processAndUploadImage(
|
||||
file: File,
|
||||
findId: string,
|
||||
index: number
|
||||
): Promise<{ url: string; thumbnailUrl: string }> {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const extension = file.name.split('.').pop();
|
||||
const filename = `finds/${findId}/image-${index}-${timestamp}`;
|
||||
|
||||
// Process full-size image (resize if too large, optimize)
|
||||
const processedImage = await sharp(buffer)
|
||||
.resize(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.jpeg({ quality: 85, progressive: true })
|
||||
.toBuffer();
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnail = await sharp(buffer)
|
||||
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
|
||||
fit: 'cover',
|
||||
position: 'centre'
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
// Upload both to R2
|
||||
const imageFile = new File([processedImage], `${filename}.jpg`, { type: 'image/jpeg' });
|
||||
const thumbFile = new File([thumbnail], `${filename}-thumb.jpg`, { type: 'image/jpeg' });
|
||||
|
||||
const [url, thumbnailUrl] = await Promise.all([
|
||||
uploadToR2(imageFile, `${filename}.jpg`, 'image/jpeg'),
|
||||
uploadToR2(thumbFile, `${filename}-thumb.jpg`, 'image/jpeg')
|
||||
]);
|
||||
|
||||
return { url, thumbnailUrl };
|
||||
}
|
||||
|
||||
export async function processAndUploadVideo(
|
||||
file: File,
|
||||
findId: string,
|
||||
index: number
|
||||
): Promise<{ url: string; thumbnailUrl: string }> {
|
||||
const timestamp = Date.now();
|
||||
const filename = `finds/${findId}/video-${index}-${timestamp}.mp4`;
|
||||
|
||||
// Upload video directly (no processing on server to save resources)
|
||||
const url = await uploadToR2(file, filename, 'video/mp4');
|
||||
|
||||
// For video thumbnail, generate on client-side or use placeholder
|
||||
// This keeps server-side processing minimal
|
||||
const thumbnailUrl = `/video-placeholder.jpg`; // Use static placeholder
|
||||
|
||||
return { url, thumbnailUrl };
|
||||
}
|
||||
```
|
||||
|
||||
### PWA Service Worker Compatibility
|
||||
|
||||
Update `src/service-worker.ts` to handle R2 media:
|
||||
|
||||
```typescript
|
||||
// Add to the existing service worker after the IMAGE_CACHE declaration
|
||||
|
||||
const R2_DOMAINS = [
|
||||
'pub-', // R2 public URLs pattern
|
||||
'r2.dev',
|
||||
'r2.cloudflarestorage.com'
|
||||
];
|
||||
|
||||
// In the fetch event listener, update image handling:
|
||||
|
||||
// Handle R2 media with cache-first strategy
|
||||
if (url.hostname.includes('r2.dev') || R2_DOMAINS.some((domain) => url.hostname.includes(domain))) {
|
||||
const cachedResponse = await imageCache.match(event.request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(event.request);
|
||||
if (response.ok) {
|
||||
// Cache R2 media for offline access
|
||||
imageCache.put(event.request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// Return cached version or fallback
|
||||
return cachedResponse || new Response('Media not available offline', { status: 404 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Endpoint for Upload
|
||||
|
||||
Create `src/routes/api/finds/upload/+server.ts`:
|
||||
|
||||
```typescript
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { processAndUploadImage, processAndUploadVideo } from '$lib/server/media-processor';
|
||||
import { generateUserId } from '$lib/server/auth'; // Reuse ID generation
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
const MAX_FILES = 5;
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/quicktime'];
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const files = formData.getAll('files') as File[];
|
||||
const findId = (formData.get('findId') as string) || generateUserId(); // Generate if creating new Find
|
||||
|
||||
if (files.length === 0 || files.length > MAX_FILES) {
|
||||
throw error(400, `Must upload between 1 and ${MAX_FILES} files`);
|
||||
}
|
||||
|
||||
const uploadedMedia: Array<{ type: string; url: string; thumbnailUrl: string }> = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw error(400, `File ${file.name} exceeds maximum size of 100MB`);
|
||||
}
|
||||
|
||||
// Process based on type
|
||||
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
const result = await processAndUploadImage(file, findId, i);
|
||||
uploadedMedia.push({ type: 'photo', ...result });
|
||||
} else if (ALLOWED_VIDEO_TYPES.includes(file.type)) {
|
||||
const result = await processAndUploadVideo(file, findId, i);
|
||||
uploadedMedia.push({ type: 'video', ...result });
|
||||
} else {
|
||||
throw error(400, `File type ${file.type} not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
return json({ findId, media: uploadedMedia });
|
||||
};
|
||||
```
|
||||
|
||||
### Client-Side Upload Component
|
||||
|
||||
Update `CreateFindModal.svelte` to handle uploads:
|
||||
|
||||
```typescript
|
||||
async function handleFileUpload(files: FileList) {
|
||||
const formData = new FormData();
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
// If editing existing Find, include findId
|
||||
if (existingFindId) {
|
||||
formData.append('findId', existingFindId);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/finds/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Store result for Find creation
|
||||
uploadedMedia = result.media;
|
||||
generatedFindId = result.findId;
|
||||
}
|
||||
```
|
||||
|
||||
### R2 Bucket Configuration
|
||||
|
||||
**Important R2 Settings for PWA:**
|
||||
|
||||
1. **Public Access:** Enable public bucket access for `serengo-finds` bucket
|
||||
2. **CORS Configuration:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": ["https://serengo.ziasvannes.tech", "http://localhost:5173"],
|
||||
"AllowedMethods": ["GET", "HEAD"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
3. **Custom Domain (Recommended):**
|
||||
- Point `media.serengo.ziasvannes.tech` to R2 bucket
|
||||
- Enables better caching and CDN integration
|
||||
- Update `R2_PUBLIC_URL` in r2.ts
|
||||
|
||||
4. **Cache Headers:** Files uploaded with `Cache-Control: public, max-age=31536000, immutable`
|
||||
- PWA service worker can cache indefinitely
|
||||
- Perfect for user-uploaded content that won't change
|
||||
|
||||
### Offline Support Strategy
|
||||
|
||||
**How PWA handles R2 media:**
|
||||
|
||||
1. **First Visit (Online):**
|
||||
- Finds and media load from R2
|
||||
- Service worker caches all media in `IMAGE_CACHE`
|
||||
- Thumbnails cached for fast list views
|
||||
|
||||
2. **Subsequent Visits (Online):**
|
||||
- Service worker serves from cache immediately (cache-first)
|
||||
- No network delay for previously viewed media
|
||||
|
||||
3. **Offline:**
|
||||
- All cached Finds and media available
|
||||
- New Find creation queued (implement with IndexedDB)
|
||||
- Upload when connection restored
|
||||
|
||||
### Cost Optimization
|
||||
|
||||
**R2 Free Tier Limits:**
|
||||
|
||||
- 10GB storage (≈ 10,000 high-quality photos or 100 videos)
|
||||
- 1M Class A operations (write/upload)
|
||||
- 10M Class B operations (read/download)
|
||||
|
||||
**Stay Within Free Tier:**
|
||||
|
||||
- Aggressive thumbnail generation (reduces bandwidth)
|
||||
- Cache-first strategy (reduces reads)
|
||||
- Lazy load images (only fetch what's visible)
|
||||
- Compress images server-side (sharp optimization)
|
||||
- Video uploads: limit to 100MB, encourage shorter clips
|
||||
|
||||
**Monitoring:**
|
||||
|
||||
- Track R2 usage in Cloudflare dashboard
|
||||
- Alert at 80% of free tier limits
|
||||
- Implement rate limiting on uploads (max 10 Finds/day per user)
|
||||
|
||||
## Map Integration
|
||||
|
||||
Update `src/lib/components/Map.svelte`:
|
||||
|
||||
- Add prop `finds?: Find[]` to display Find markers
|
||||
- Create custom Find marker component with distinct styling
|
||||
- Different marker colors for:
|
||||
- Your own Finds (blue)
|
||||
- Friends' Finds (green)
|
||||
- Public Finds (orange)
|
||||
- Add clustering for better performance with many markers
|
||||
- Implement marker click handler to open FindPreview modal
|
||||
|
||||
## Social Features (Phase 2)
|
||||
|
||||
### Friends System
|
||||
|
||||
- Add friend request functionality
|
||||
- Accept/decline friend requests
|
||||
- Friends list page
|
||||
- Privacy settings respect friendship status
|
||||
|
||||
### Notifications
|
||||
|
||||
- Notify when friend creates a Find nearby
|
||||
- Notify when someone likes your Find
|
||||
- Notify on friend request
|
||||
|
||||
### Discovery Feed
|
||||
|
||||
- Trending Finds in your area
|
||||
- Recommended Finds based on your history
|
||||
- Explore by category
|
||||
|
||||
## Mobile Considerations
|
||||
|
||||
- FindsMap should be touch-friendly with proper zoom controls
|
||||
- Create Find button as floating action button (FAB) for easy access
|
||||
- Optimize image upload for mobile (compress before upload)
|
||||
- Consider PWA features for photo capture
|
||||
- Offline support: Queue Finds when offline, sync when online
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
- Validate all user inputs (title, description lengths)
|
||||
- Sanitize text content to prevent XSS
|
||||
- Check ownership before allowing edit/delete
|
||||
- Respect privacy settings in all queries
|
||||
- Rate limit Find creation (e.g., max 20 per day)
|
||||
- Implement CSRF protection (already in place)
|
||||
- Validate file types and sizes on upload
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
- Lazy load images in FindsList and FindPreview
|
||||
- Implement virtual scrolling for long lists
|
||||
- Cache Find queries with stale-while-revalidate
|
||||
- Use CDN for media files
|
||||
- Implement marker clustering on map
|
||||
- Paginate Finds feed (20-50 per page)
|
||||
- Index database on: (latitude, longitude), userId, createdAt
|
||||
|
||||
## Styling Guidelines
|
||||
|
||||
- Use existing Serengo design system and components
|
||||
- Find markers should be visually distinct from location marker
|
||||
- Maintain newspaper-inspired aesthetic
|
||||
- Use Washington font for Find titles
|
||||
- Keep UI clean and uncluttered
|
||||
- Smooth animations for modal open/close
|
||||
- Skeleton loading states for Finds feed
|
||||
|
||||
## Success Metrics to Track
|
||||
|
||||
- Number of Finds created per user
|
||||
- Photo/video upload rate
|
||||
- Likes per Find
|
||||
- Map engagement (clicks on Find markers)
|
||||
- Share rate
|
||||
- Friend connections made
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
**Phase 1 (MVP):**
|
||||
|
||||
1. Database schema and migrations
|
||||
2. Create Find functionality (with photos only)
|
||||
3. FindsMap with markers
|
||||
4. FindPreview modal
|
||||
5. Basic Find feed
|
||||
|
||||
**Phase 2:** 6. Video support 7. Like functionality 8. Friends system 9. Enhanced filters and search 10. Share functionality
|
||||
|
||||
**Phase 3:** 11. Comments on Finds 12. Discovery feed 13. Notifications 14. Advanced analytics
|
||||
|
||||
## Example User Flow
|
||||
|
||||
1. User opens Serengo app and sees map with their friends' Finds
|
||||
2. User visits a cool café and wants to share it
|
||||
3. Clicks "Create Find" FAB button
|
||||
4. Modal opens with their current location pre-filled
|
||||
5. User adds title "Amazing Belgian Waffles at Café Belga"
|
||||
6. Writes review: "Best waffles I've had in Brussels! Great atmosphere too."
|
||||
7. Uploads 2 photos of the waffles and café interior
|
||||
8. Selects category "Cafe" and keeps it Public
|
||||
9. Clicks "Share Find"
|
||||
10. Find appears on map as new marker
|
||||
11. Friends see it in their feed and can like/save it
|
||||
12. Friends can click marker to see details and get directions
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Run database migrations to create new tables
|
||||
2. Implement basic Create Find API endpoint
|
||||
3. Build CreateFindModal component
|
||||
4. Update Map component to display Find markers
|
||||
5. Test end-to-end flow
|
||||
6. Iterate based on user feedback
|
||||
|
||||
This feature will transform Serengo from a simple location app into a social discovery platform where users share and explore unexpected places together.
|
||||
@@ -1,186 +0,0 @@
|
||||
# Serengo Finds Feature Implementation Log
|
||||
|
||||
## Project Overview
|
||||
|
||||
Serengo is a location-based social discovery platform where users can save, share, and discover memorable places with media, reviews, and precise location data.
|
||||
|
||||
## Current Status: Phase 2A & 2C Complete + UI Integration ✅
|
||||
|
||||
### What Serengo Currently Has:
|
||||
|
||||
- Complete finds creation with photo uploads and location data
|
||||
- Interactive map with find markers and detailed previews
|
||||
- Responsive design with map/list view toggle
|
||||
- **NEW**: Modern WebP image processing with JPEG fallbacks
|
||||
- **NEW**: Full video support with custom VideoPlayer component
|
||||
- **NEW**: Like/unlike system with optimistic UI updates
|
||||
- R2 storage integration with enhanced media processing
|
||||
- Full database schema with proper relationships and social features
|
||||
- Type-safe API endpoints and error handling
|
||||
- Mobile-optimized UI with floating action button
|
||||
|
||||
### Production Ready Features:
|
||||
|
||||
- Create/view finds with photos, descriptions, and categories
|
||||
- Location-based filtering and discovery
|
||||
- Media carousel with navigation (supports images and videos)
|
||||
- **NEW**: Video playback with custom controls and fullscreen support
|
||||
- **NEW**: Like/unlike finds with real-time count updates
|
||||
- **NEW**: Animated like buttons with heart animations
|
||||
- Share functionality with clipboard copy
|
||||
- Real-time map markers with click-to-preview
|
||||
- Grid layout for find browsing
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 Implementation Plan (Updated October 14, 2025)
|
||||
|
||||
### Technical Requirements & Standards:
|
||||
|
||||
- **Media Formats**: Use modern WebP for images, WebM/AV1 for videos
|
||||
- **UI Components**: Leverage existing SHADCN components for consistency
|
||||
- **Code Quality**: Follow Svelte 5 best practices with clean, reusable components
|
||||
- **POI Search**: Integrate Google Maps Places API for location search
|
||||
- **Type Safety**: Maintain strict TypeScript throughout
|
||||
|
||||
### Phase 2A: Modern Media Support ✅ COMPLETE
|
||||
|
||||
**Goal**: Upgrade to modern file formats and video support
|
||||
|
||||
**Completed Tasks:**
|
||||
|
||||
- [x] Update media processor to output WebP images (with JPEG fallback)
|
||||
- [x] Implement MP4 video processing with thumbnail generation
|
||||
- [x] Create reusable VideoPlayer component using SHADCN
|
||||
- [x] Enhanced database schema with fallback URL support
|
||||
- [x] Optimize compression settings for web delivery
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
- Updated `media-processor.ts` to generate both WebP and JPEG versions
|
||||
- Enhanced `findMedia` table with `fallbackUrl` and `fallbackThumbnailUrl` fields
|
||||
- Created `VideoPlayer.svelte` with custom controls, progress bar, and fullscreen support
|
||||
- Added video placeholder SVG for consistent UI
|
||||
- Maintained backward compatibility with existing media
|
||||
|
||||
**Actual Effort**: ~12 hours
|
||||
|
||||
### Phase 2B: Enhanced Location & POI Search (Priority: High)
|
||||
|
||||
**Goal**: Google Maps integration for better location discovery
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- [ ] Integrate Google Maps Places API for POI search
|
||||
- [ ] Create location search component with autocomplete
|
||||
- [ ] Add "Search nearby" functionality in CreateFindModal
|
||||
- [ ] Implement reverse geocoding for address display
|
||||
- [ ] Add place details (hours, ratings, etc.) from Google Places
|
||||
|
||||
**Estimated Effort**: 12-15 hours
|
||||
|
||||
### Phase 2C: Social Interactions ✅ COMPLETE
|
||||
|
||||
**Goal**: Like system and user engagement
|
||||
|
||||
**Completed Tasks:**
|
||||
|
||||
- [x] Implement like/unlike API using existing findLike table
|
||||
- [x] Create reusable LikeButton component with animations
|
||||
- [x] Add like counts and user's liked status to find queries
|
||||
- [x] Add optimistic UI updates for instant feedback
|
||||
- [x] **COMPLETED**: Full UI integration into FindCard and FindPreview components
|
||||
- [x] **COMPLETED**: Updated all data interfaces to support like information
|
||||
- [x] **COMPLETED**: Enhanced media carousel with VideoPlayer component integration
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
- Created `/api/finds/[findId]/like` endpoints for POST (like) and DELETE (unlike)
|
||||
- Built `LikeButton.svelte` with optimistic UI updates and heart animations
|
||||
- Enhanced find queries to include like counts and user's liked status via SQL aggregation
|
||||
- Integrated LikeButton into both FindCard (list view) and FindPreview (modal view)
|
||||
- Updated VideoPlayer usage throughout the application for consistent video playback
|
||||
- Maintained type safety across all interfaces and data flows
|
||||
|
||||
**Future Task:**
|
||||
|
||||
- [ ] Build "My Liked Finds" collection page (moved to Phase 2G)
|
||||
|
||||
**Actual Effort**: ~15 hours (including UI integration)
|
||||
|
||||
### Phase 2D: Friends & Privacy System (Priority: Medium-High)
|
||||
|
||||
**Goal**: Social connections and privacy controls
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- [ ] Build friend request system (send/accept/decline)
|
||||
- [ ] Create Friends management page using SHADCN components
|
||||
- [ ] Implement friend search with user suggestions
|
||||
- [ ] Update find privacy logic to respect friendships
|
||||
- [ ] Add friend-specific find visibility filters
|
||||
|
||||
**Estimated Effort**: 20-25 hours
|
||||
|
||||
### Phase 2E: Advanced Filtering & Discovery (Priority: Medium)
|
||||
|
||||
**Goal**: Better find discovery and organization
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- [ ] Create FilterPanel component with category/distance/date filters
|
||||
- [ ] Implement text search through find titles/descriptions
|
||||
- [ ] Add sort options (recent, popular, nearest)
|
||||
- [ ] Build infinite scroll for find feeds
|
||||
- [ ] Add "Similar finds nearby" recommendations
|
||||
|
||||
**Estimated Effort**: 15-18 hours
|
||||
|
||||
### Phase 2F: Enhanced Sharing & Individual Pages (Priority: Medium)
|
||||
|
||||
**Goal**: Better sharing and find discoverability
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- [ ] Create individual find detail pages (`/finds/[id]`)
|
||||
- [ ] Add social media sharing with OpenGraph meta tags
|
||||
- [ ] Implement "Get Directions" integration with map apps
|
||||
- [ ] Build shareable find links with previews
|
||||
- [ ] Add "Copy link" functionality
|
||||
|
||||
**Estimated Effort**: 12-15 hours
|
||||
|
||||
---
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Component Architecture:
|
||||
|
||||
- Use composition pattern with reusable SHADCN components
|
||||
- Implement proper TypeScript interfaces for all props
|
||||
- Follow Svelte 5 runes pattern ($props, $derived, $effect)
|
||||
- Create clean separation between UI and business logic
|
||||
|
||||
### Code Quality:
|
||||
|
||||
- Maintain existing formatting (tabs, single quotes, 100 char width)
|
||||
- Use descriptive variable names and function signatures
|
||||
- Implement proper error boundaries and loading states
|
||||
- Add accessibility attributes (ARIA labels, keyboard navigation)
|
||||
|
||||
### Performance:
|
||||
|
||||
- Lazy load media content and heavy components
|
||||
- Implement proper caching strategies for API calls
|
||||
- Use virtual scrolling for long lists when needed
|
||||
- Optimize images/videos for web delivery
|
||||
|
||||
**Total Phase 2 Estimated Effort**: 82-105 hours
|
||||
**Expected Timeline**: 8-10 weeks (part-time development)
|
||||
|
||||
## Next Steps:
|
||||
|
||||
1. Begin with Phase 2A (Modern Media Support) for immediate impact
|
||||
2. Implement Phase 2B (Google Maps POI) for better UX
|
||||
3. Add Phase 2C (Social Interactions) for user engagement
|
||||
4. Continue with remaining phases based on user feedback
|
||||
855
logs/logboek.md
855
logs/logboek.md
@@ -1,7 +1,475 @@
|
||||
# Logboek - Serengo Project
|
||||
|
||||
## Development Timeline & Activity Log
|
||||
|
||||
**Project Start:** 26 September 2025
|
||||
**Total Commits:** 99 commits
|
||||
**Primary Developer:** Zias van Nes
|
||||
**Tech Stack:** SvelteKit, Drizzle ORM, PostgreSQL, Cloudflare R2, MapLibre GL JS
|
||||
|
||||
---
|
||||
|
||||
## December 2025
|
||||
|
||||
### 1 December 2025 - 4 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **Phase 5: Find Management & API-Sync Enhancement**
|
||||
- Complete update en delete functionaliteit voor finds geïmplementeerd
|
||||
- API-sync layer uitgebreid voor optimistic updates met database synchronisatie
|
||||
- EditFindModal component ontwikkeld met volledige media management
|
||||
- Enhanced find detail pages met edit/delete controls
|
||||
- Media deletion API endpoints voor individuele media items
|
||||
- Optimistic UI updates met automatic rollback bij failures
|
||||
|
||||
**Commits:**
|
||||
|
||||
- b060f53 - feat:use api-sync layer to sync local updates state with db
|
||||
- f8acec9 - feat:update and delete finds
|
||||
|
||||
**Details:**
|
||||
|
||||
**Find Update & Delete System (f8acec9):**
|
||||
|
||||
- EditFindModal component (892 lines) met complete edit functionaliteit
|
||||
- Media management met add/remove capabilities voor individuele items
|
||||
- Delete confirmation UI in FindCard component
|
||||
- API endpoints uitgebreid voor PATCH en DELETE operaties
|
||||
- Media deletion endpoint (/api/finds/[findId]/media/[mediaId])
|
||||
- Enhanced FindsList met edit mode support
|
||||
- Find detail page integration met edit/delete controls
|
||||
- Authorization checks voor find ownership
|
||||
- 8 bestanden gewijzigd, +1554/-11 lijnen
|
||||
|
||||
**API-Sync Layer Enhancement (b060f53):**
|
||||
|
||||
- Complete refactor van api-sync.ts voor database synchronisatie (122 lines nieuwe code)
|
||||
- Optimistic updates met automatic server sync
|
||||
- Smart state management met local changes tracking
|
||||
- Automatic rollback bij API failures
|
||||
- Homepage state management vereenvoudigd (97 lines refactored)
|
||||
- EditFindModal geïntegreerd met sync layer (39 lines optimized)
|
||||
- Find detail page reactivity verbeterd
|
||||
- Reduced code duplication across components
|
||||
- 6 bestanden gewijzigd, +179/-110 lijnen
|
||||
|
||||
**Technical Implementation:**
|
||||
|
||||
- **EditFindModal Features:**
|
||||
- Media carousel met add/remove controls
|
||||
- POI search integration voor location updates
|
||||
- Form validation met proper error handling
|
||||
- Optimistic UI updates tijdens save
|
||||
- Loading states en user feedback
|
||||
- Responsive design voor mobile/desktop
|
||||
|
||||
- **API Endpoints:**
|
||||
- PATCH /api/finds/[findId] - Update find (title, description, location, media)
|
||||
- DELETE /api/finds/[findId] - Delete entire find
|
||||
- DELETE /api/finds/[findId]/media/[mediaId] - Delete individual media item
|
||||
- Comprehensive authorization checks
|
||||
- Proper error responses met status codes
|
||||
|
||||
- **API-Sync Architecture:**
|
||||
- Centralized state management voor all finds
|
||||
- Optimistic updates voor instant UI feedback
|
||||
- Automatic server synchronization
|
||||
- Rollback mechanism bij failures
|
||||
- Subscription system voor reactive updates
|
||||
- Child subscriptions voor derived state
|
||||
- Proper cleanup van subscriptions
|
||||
|
||||
**User Experience Improvements:**
|
||||
|
||||
- Instant feedback bij find updates (optimistic UI)
|
||||
- Seamless edit experience met inline modal
|
||||
- Media management zonder page refreshes
|
||||
- Confirmation dialogs voor destructive actions
|
||||
- Error handling met user-friendly messages
|
||||
- Consistent styling across edit/create flows
|
||||
- Mobile-responsive edit interface
|
||||
|
||||
---
|
||||
|
||||
## November 2025
|
||||
|
||||
### 23 November 2025 - 2 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **Dynamic Map Centering Based on Sidebar Visibility**
|
||||
- Map center dynamically adjusts when sidebar is opened/closed
|
||||
- Intelligent padding calculation for desktop (left sidebar) and mobile (bottom sidebar)
|
||||
- Smooth transitions between sidebar states
|
||||
- Location always centered in visible map area
|
||||
|
||||
**Commits:**
|
||||
|
||||
- 0578bf5 - feat:update map position gets changed dynamically according to available space
|
||||
|
||||
**Details:**
|
||||
|
||||
**Dynamic Map Positioning (0578bf5):**
|
||||
|
||||
- Added `sidebarVisible` prop to Map component
|
||||
- Implemented `getMapPadding` derived state that calculates appropriate padding:
|
||||
- **Desktop (>768px)**: Left padding of half sidebar width (sidebar is 40% viewport, max 1000px, min 500px)
|
||||
- **Mobile (≤768px)**: Bottom padding of half sidebar height (sidebar is 60vh)
|
||||
- **Sidebar closed**: No padding applied (centered normally)
|
||||
- Updated map centering logic to use padding parameter in flyTo calls
|
||||
- Added reactive effect to smoothly adjust map when sidebar toggles (300ms easeTo animation)
|
||||
- Location marker now always appears centered in actually visible portion of map
|
||||
- Improved UX by accounting for sidebar presence in map calculations
|
||||
|
||||
**Technical Implementation:**
|
||||
|
||||
- Modified `src/routes/+page.svelte` to pass `isSidebarVisible` state to Map component
|
||||
- Enhanced `src/lib/components/map/Map.svelte` with:
|
||||
- New `sidebarVisible` prop in Props interface
|
||||
- Derived state for dynamic padding calculation
|
||||
- Updated both initial center and recenter effects
|
||||
- New effect to handle sidebar visibility changes
|
||||
- Smooth transitions using MapLibre's `easeTo` method
|
||||
- Responsive calculations for both desktop and mobile layouts
|
||||
|
||||
---
|
||||
|
||||
### 21-22 November 2025 - 7 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **Phase 4: Public Finds & Sharing System + Major Code Reorganization**
|
||||
- Complete component library reorganizatie met logische directory structuur
|
||||
- Public find detail pagina met individuele find viewing
|
||||
- Advanced sharing functionaliteit met native Web Share API
|
||||
- Map improvements met better centering en zoom controls
|
||||
- UI consistency updates across all find components
|
||||
- Enhanced sidebar toggle functionaliteit
|
||||
- Comments list styling improvements
|
||||
|
||||
**Commits:**
|
||||
|
||||
- c17bb94 - fix:recentering when updating map
|
||||
- 73eeaf0 - feat:better sharing of finds
|
||||
- 2ac826c - ui:use the default styling on homepage and find detail page
|
||||
- 5285a15 - feat:big update to public finds
|
||||
- 9f60806 - fix:dont autozoom when watching
|
||||
- 4c73b6f - fix:sidebar toggle
|
||||
- 42d7246 - ui:update findpreview and commentlist
|
||||
- 63b3e51 - ui:big ui overhaul
|
||||
|
||||
**Details:**
|
||||
|
||||
**Code Organization (63b3e51):**
|
||||
|
||||
- Complete component library restructuring met logische groepering:
|
||||
- `/auth/` - Authentication components (login-form)
|
||||
- `/finds/` - Find-related components (10 components)
|
||||
- `/map/` - Map components (Map, LocationManager, POISearch)
|
||||
- `/media/` - Media components (VideoPlayer)
|
||||
- `/notifications/` - Notification system (3 components)
|
||||
- `/profile/` - Profile components (ProfilePanel, ProfilePicture, ProfilePictureSheet)
|
||||
- Nieuwe barrel exports (index.ts) voor cleaner imports
|
||||
- NotificationSettings component volledig herschreven (613 lines) met betere UX
|
||||
- Enhanced Comments component met improved layout
|
||||
- 40 bestanden gewijzigd, +2012/-746 lijnen
|
||||
|
||||
**Public Finds System (5285a15, 2ac826c):**
|
||||
|
||||
- Nieuwe `/finds/[findId]` route voor individuele find viewing
|
||||
- Server-side data fetching met find detail loading
|
||||
- Complete FindCard API endpoint uitbreiding voor single find retrieval
|
||||
- Unified styling tussen homepage en detail pagina's
|
||||
- 780+ lines nieuwe detail page implementation
|
||||
- Enhanced API responses met proper error handling
|
||||
- Backward-compatible met existing find filtering
|
||||
|
||||
**Sharing Features (73eeaf0):**
|
||||
|
||||
- Native Web Share API integratie voor mobile devices
|
||||
- Fallback copy-to-clipboard functionaliteit voor desktop
|
||||
- Toast notifications voor share feedback
|
||||
- Social sharing van individuele finds via URL
|
||||
- Share button in FindCard, FindPreview, en detail page
|
||||
- Dynamic URL generation voor shareable finds
|
||||
|
||||
**Map Enhancements (c17bb94, 9f60806):**
|
||||
|
||||
- Smart map centering met separate user location en find markers
|
||||
- Fixed autozoom tijdens location watching voor betere UX
|
||||
- Improved map state management (+147 lines in Map.svelte)
|
||||
- Better handling van map updates zonder constant recentering
|
||||
- Enhanced marker clustering en positioning
|
||||
|
||||
**UI Improvements (42d7246, 4c73b6f):**
|
||||
|
||||
- CommentsList styling verbeteringen met better spacing
|
||||
- Enhanced sidebar toggle met improved state management
|
||||
- FindPreview UI refinements voor consistency
|
||||
- Cleaned up unused CommentForm imports
|
||||
- Better responsive behavior voor mobile/desktop
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
- Svelte 5 reactivity patterns voor alle nieuwe features
|
||||
- Type-safe API endpoints met proper error handling
|
||||
- SEO-friendly URLs voor shareable finds
|
||||
- Progressive enhancement voor Web Share API
|
||||
- Component modularization voor better maintainability
|
||||
- Service worker updates voor offline functionality
|
||||
|
||||
---
|
||||
|
||||
### 17 November 2025 - 3 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **UI/UX Grote Overhaul - Fullscreen Map & Sidebar Design**
|
||||
- Fullscreen map layout met side-sheet voor finds geïmplementeerd
|
||||
- Sidebar toggle functionaliteit toegevoegd
|
||||
- Local media proxy geïmplementeerd voor caching en CSP fixes
|
||||
- Mobile en desktop UI verbeteringen voor CreateFind en FindPreview
|
||||
- Overscroll behavior fixes
|
||||
- FindList overflow problemen opgelost
|
||||
|
||||
**Commits:**
|
||||
|
||||
- 1c31e2c - add:sidebar toggle and fix overscroll behavior
|
||||
- d8cab06 - fix:overflow of findlist
|
||||
- d4d23ed - ui:find preview better ui
|
||||
- ab8b0ee - ui:create find better ui
|
||||
- dabc732 - fix:styling for mobile createfind
|
||||
- 1f0e814 - ui:remove mobile + button and use same as desktop
|
||||
- 96a173b - feat:use local proxy for media
|
||||
- 08f7e77 - ui:big ui update
|
||||
|
||||
**Details:**
|
||||
|
||||
- Complete UI refresh met fullscreen map en overlay side-sheet voor finds
|
||||
- Sidebar toggle voor betere map ervaring
|
||||
- Local media proxy (/api/media/[...path]) voor caching en snellere laadtijden
|
||||
- CSP issues opgelost door local proxy te gebruiken in plaats van directe R2 requests
|
||||
- LocationButton component verwijderd (430 lines cleanup)
|
||||
- Verbeterde mobile en desktop styling voor CreateFindModal
|
||||
- FindPreview UI verbeteringen voor betere gebruikerservaring
|
||||
- Overscroll behavior gefixed voor soepelere scrolling
|
||||
- Mobile + button verwijderd, unified desktop/mobile interface
|
||||
|
||||
---
|
||||
|
||||
### 8 November 2025 - 5 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **Web Push Notifications Systeem**
|
||||
- Complete Web Push notification systeem geïmplementeerd
|
||||
- NotificationManager, NotificationPrompt, en NotificationSettings componenten
|
||||
- Notification preferences API endpoints
|
||||
- Push notification triggers voor likes, comments, en friend requests
|
||||
- Service worker push event handling
|
||||
- VAPID keys generation script
|
||||
|
||||
**Commits:**
|
||||
|
||||
- ae339d6 - chore:linting,formatting,type fixing, ....
|
||||
- 0754d62 - fix:push notification UI, settings and API
|
||||
- e27b249 - fix:notifications
|
||||
- 4d28834 - fix:notificationmanager
|
||||
- d7f803c - fix:add NotificationManager and enable in layout
|
||||
- df67564 - feat:add Web Push notification system
|
||||
|
||||
**Details:**
|
||||
|
||||
- Complete Web Push notification infrastructuur met VAPID keys
|
||||
- Database schema uitbreiding voor notification subscriptions en preferences
|
||||
- NotificationManager component voor real-time notification handling
|
||||
- NotificationPrompt voor gebruikers toestemming
|
||||
- NotificationSettings en NotificationSettingsSheet voor preference management
|
||||
- Push notifications bij likes, comments, en friend requests
|
||||
- Service worker integratie voor background push events
|
||||
- API endpoints voor subscription management en preferences
|
||||
- CSP updates voor FCM/GCM endpoints
|
||||
- Lucide-svelte dependency toegevoegd voor icons
|
||||
- Code linting, formatting, en type fixes
|
||||
|
||||
---
|
||||
|
||||
### 6 November 2025 - 4 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **Comments Feature Implementatie**
|
||||
- Complete comment systeem voor finds geïmplementeerd
|
||||
- Comment creation, viewing, en deletion functionaliteit
|
||||
- API-sync layer voor real-time comment synchronisatie
|
||||
- Scrollable comments met limit functionaliteit
|
||||
- Comment form en list UI componenten
|
||||
|
||||
**Commits:**
|
||||
|
||||
- 2efd496 - add:enhance comments list with scroll, limit, and styling
|
||||
- b8c88d7 - feat:comments
|
||||
- af49ed6 - logs:update logs
|
||||
|
||||
**Details:**
|
||||
|
||||
- Comment database schema en migraties
|
||||
- API endpoints voor comment CRUD operaties (/api/finds/[findId]/comments)
|
||||
- API-sync store uitbreiding voor comment state management
|
||||
- CommentForm component met real-time posting
|
||||
- CommentsList component met scrolling en limit ("+N more comments")
|
||||
- Comment component met delete functionaliteit
|
||||
- Integration in FindCard en FindPreview componenten
|
||||
- Responsive styling voor mobile en desktop
|
||||
- User authentication en authorization voor comments
|
||||
- Real-time updates via API-sync layer
|
||||
|
||||
---
|
||||
|
||||
## Oktober 2025
|
||||
|
||||
### 4 November 2025 - 1 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **UI Consistency & Media Layout Improvements**
|
||||
- ProfilePicture component geïmplementeerd ter vervanging van Avatar componenten
|
||||
- Media layout en sizing verbeteringen voor betere responsive design
|
||||
- Mobile sheet optimalisaties voor verbeterde gebruikerservaring
|
||||
- Component refactoring voor consistentere UI across applicatie
|
||||
- Loading states en fallback styling geconsolideerd
|
||||
|
||||
**Commits:**
|
||||
|
||||
- d3adac8 - add:ProfilePicture component and replace Avatar
|
||||
- 9800be0 - fix:Adjust media layout, sizing, and mobile sheet
|
||||
|
||||
**Details:**
|
||||
|
||||
- Nieuwe ProfilePicture component met geïntegreerde avatar initials en fallback styling
|
||||
- Avatar componenten vervangen door ProfilePicture voor consistentie
|
||||
- Media container rendering geoptimaliseerd (alleen tonen bij aanwezige media)
|
||||
- Max-height van 600px toegevoegd voor images en videos met height:auto
|
||||
- Object-fit: contain toegepast voor media images in preview
|
||||
- Mobile sheet height gereduceerd van 80vh naar 50vh voor betere usability
|
||||
- Loading states UI geconsolideerd in ProfilePicture component
|
||||
- Component library verder uitgebreid met herbruikbare UI elementen
|
||||
|
||||
---
|
||||
|
||||
### 27-29 Oktober 2025 - 10 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **Phase 3: Google Places Integration & Sync Service**
|
||||
- Complete implementatie van sync-service voor API data synchronisatie
|
||||
- Google Maps Places API integratie voor POI zoekfunctionaliteit
|
||||
- Location tracking optimalisaties met continuous watching
|
||||
- CSP (Content Security Policy) fixes voor verbeterde security
|
||||
- Child subscription fixes voor data consistency
|
||||
|
||||
**Commits:**
|
||||
|
||||
- 4c973c4 - fix:some csp issues
|
||||
- d7fe909 - fix:new child subscription
|
||||
- 6620cc6 - feat:implement a sync-service
|
||||
- 3b3ebc2 - fix:continuously watch location
|
||||
- fef7c16 - feat:use GMaps places api for searching poi's
|
||||
|
||||
**Details:**
|
||||
|
||||
- Complete sync-service architectuur voor real-time data synchronisatie
|
||||
- Google Places API integratie voor Point of Interest zoekfunctionaliteit
|
||||
- Verbeterde location tracking met continuous watching voor nauwkeurigere positiebepaling
|
||||
- Security verbeteringen door CSP issues op te lossen
|
||||
- Data consistency verbeteringen met child subscription fixes
|
||||
- Enhanced POI search capabilities met Google Maps integration
|
||||
|
||||
---
|
||||
|
||||
### 21 Oktober 2025 (Maandag) - 4 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **UI Refinement & Bug Fixes**
|
||||
- Create Find Modal UI refactoring met verbeterde layout
|
||||
- Finds list en header layout updates voor betere UX
|
||||
- Friends filtering logica fixes
|
||||
- Friends en users search functionaliteit verbeteringen
|
||||
- Modal interface optimalisaties
|
||||
|
||||
**Commits:**
|
||||
|
||||
- aa9ed77 - UI:Refactor create find modal UI and update finds list/header layout
|
||||
- e1c5846 - fix:friends filtering
|
||||
|
||||
**Details:**
|
||||
|
||||
- Verbeterde modal interface met consistente styling
|
||||
- Fixed filtering logica voor vriendensysteem
|
||||
- Enhanced search functionaliteit voor gebruikers en vrienden
|
||||
- UI/UX verbeteringen voor betere gebruikerservaring
|
||||
|
||||
---
|
||||
|
||||
### 20 Oktober 2025 (Zondag) - 2 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **Search Logic Improvements**
|
||||
- Friends en users search logica geoptimaliseerd
|
||||
- Filtering verbeteringen voor vriendschapssysteem
|
||||
- Backend search algoritmes verfijnd
|
||||
|
||||
**Commits:**
|
||||
|
||||
- 634ce8a - fix:logic of friends and users search
|
||||
|
||||
**Details:**
|
||||
|
||||
- Verbeterde zoekalgoritmes voor gebruikers
|
||||
- Geoptimaliseerde filtering voor vriendensysteem
|
||||
- Backend logica verfijning voor betere performance
|
||||
|
||||
---
|
||||
|
||||
### 16 Oktober 2025 (Woensdag) - 6 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **Phase 2D: Friends & Privacy System - Volledige implementatie**
|
||||
- Complete vriendschapssysteem geïmplementeerd (verzenden/accepteren/weigeren/verwijderen)
|
||||
- Friends management pagina ontwikkeld met gebruikerszoekfunctionaliteit
|
||||
- Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
|
||||
- API endpoints voor vriendschapsbeheer (/api/friends, /api/friends/[friendshipId])
|
||||
- Gebruikerszoek API met vriendschapsstatus integratie (/api/users)
|
||||
- FindsFilter component met 4 filteropties (All/Public/Friends/Mine)
|
||||
- Hoofdpagina uitgebreid met geïntegreerde filteringfunctionaliteit
|
||||
- ProfilePanel uitgebreid met Friends navigatielink
|
||||
- Type-safe implementatie met volledige error handling
|
||||
|
||||
**Commits:**
|
||||
|
||||
- f547ee5 - add:logs
|
||||
- a01d183 - feat:friends
|
||||
- fdbd495 - add:cache for r2 storage
|
||||
- e54c4fb - feat:profile pictures
|
||||
- bee03a5 - feat:use dynamic sheet for findpreview
|
||||
- aea3249 - fix:likes;UI:find card&list
|
||||
|
||||
**Details:**
|
||||
|
||||
- Complete sociale verbindingssysteem voor gebruikers
|
||||
- Real-time filtering van finds op basis van privacy instellingen
|
||||
- SHADCN componenten gebruikt voor consistente UI (Cards, Badges, Avatars, Dropdowns)
|
||||
- Svelte 5 patterns toegepast met $state, $derived, en $props runes
|
||||
- Bestaande friendship table schema optimaal benut zonder wijzigingen
|
||||
- Comprehensive authentication en authorization op alle endpoints
|
||||
- Mobile-responsive design met aangepaste styling voor kleinere schermen
|
||||
|
||||
---
|
||||
|
||||
### 14 Oktober 2025 (Maandag) - 8 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
@@ -17,7 +485,9 @@
|
||||
- CSP headers bijgewerkt voor video support
|
||||
- Volledige UI integratie in FindCard en FindPreview componenten
|
||||
|
||||
**Commits:** Nog niet gecommit (staged changes)
|
||||
**Commits:**
|
||||
|
||||
- 067e228 - feat:video player, like button, and media fallbacks
|
||||
|
||||
**Details:**
|
||||
|
||||
@@ -238,40 +708,375 @@
|
||||
|
||||
## Totaal Overzicht
|
||||
|
||||
**Totale geschatte uren:** 55 uren
|
||||
**Werkdagen:** 9 dagen
|
||||
**Gemiddelde uren per dag:** 6.1 uur
|
||||
**Totale geschatte uren:** 110 uren
|
||||
**Totaal aantal commits:** 99 commits
|
||||
|
||||
### Git Statistics:
|
||||
|
||||
```
|
||||
Total Commits: 99
|
||||
Primary Author: Zias van Nes
|
||||
Commit Breakdown by Phase:
|
||||
- Initial Setup & Auth (Sept 26-27): 16 commits
|
||||
- UI Foundation (Sept 28-29): 6 commits
|
||||
- Maps & Location (Oct 2-3): 11 commits
|
||||
- SEO & Performance (Oct 7): 7 commits
|
||||
- Finds & Media (Oct 10-14): 6 commits
|
||||
- Social Features (Oct 16-21): 9 commits
|
||||
- Places & Sync (Oct 27-29): 5 commits
|
||||
- Polish & Refinement (Nov 4-8): 9 commits
|
||||
- Major Overhauls (Nov 17-23): 9 commits
|
||||
- Find Management (Dec 1): 2 commits
|
||||
```
|
||||
|
||||
### Project Milestones:
|
||||
|
||||
1. **26 Sept**: Project initialisatie en auth systeem
|
||||
2. **27 Sept**: Deployment en productie setup
|
||||
3. **28 Sept**: UI/UX complete overhaul
|
||||
4. **29 Sept**: Component architectuur verbetering
|
||||
5. **2-3 Okt**: Maps en location features
|
||||
6. **7 Okt**: SEO, PWA en performance optimalisaties
|
||||
7. **10 Okt**: Finds feature en media upload systeem
|
||||
8. **13 Okt**: API architectuur verbetering
|
||||
9. **14 Okt**: Modern media support en social interactions
|
||||
**Phase 0: Foundation (Sept 26-27)**
|
||||
|
||||
1. Project initialisatie met SvelteKit + Drizzle ORM
|
||||
2. Lucia auth systeem met database schema
|
||||
3. Docker deployment setup
|
||||
4. Vercel production configuration
|
||||
|
||||
**Phase 1: Core UI & Infrastructure (Sept 28 - Oct 7)** 5. Complete UI overhaul met custom components 6. Washington font en branding implementation 7. MapLibre GL JS integration 8. Location tracking met Geolocation API 9. Google OAuth integration 10. SEO, PWA, en performance optimalisaties 11. Service worker caching strategy 12. Manifest.json en meta tags
|
||||
|
||||
**Phase 2: Core Features (Oct 10-16)** 13. Finds feature met media upload systeem 14. Cloudflare R2 storage integratie 15. Signed URLs voor secure media access 16. API architectuur verbetering 17. Video support met custom VideoPlayer 18. WebP/JPEG image processing pipeline 19. Like/unlike systeem met optimistic updates 20. Profile pictures met upload functionaliteit
|
||||
|
||||
**Phase 3: Social & Privacy (Oct 16-29)** 21. Complete Friends & Privacy System 22. Friend request workflow (send/accept/reject/remove) 23. Privacy-aware find filtering (All/Public/Friends/Mine) 24. Users search met friendship status 25. Google Maps Places API integratie 26. POI search functionaliteit 27. Sync-service voor real-time data synchronisatie 28. Continuous location watching
|
||||
|
||||
**Phase 4: Advanced Social Features (Nov 4-8)** 29. ProfilePicture component met fallbacks 30. Media layout optimalisaties 31. Comments systeem met real-time sync 32. Scrollable comments met limits 33. Web Push notifications infrastructuur 34. Notification preferences management 35. Push notifications voor likes, comments, friend requests 36. Service worker push event handling
|
||||
|
||||
**Phase 5: Major UI/UX Overhauls (Nov 17-23)** 37. Fullscreen map layout met sidebar toggle 38. Local media proxy voor caching 39. Unified mobile/desktop interface 40. Component library reorganizatie 41. Public finds detail pages 42. Native Web Share API integration 43. Enhanced map controls en centering 44. Code structure improvements (40 files reorganized) 45. Dynamic map centering based on sidebar visibility
|
||||
|
||||
**Phase 6: Find Management & Advanced Features (Dec 1)** 46. Complete find update functionality 47. Find delete with media cleanup 48. EditFindModal component met media management 49. Individual media deletion 50. API-sync layer enhancement voor optimistic updates 51. Automatic database synchronization 52. Rollback mechanism voor failed operations
|
||||
|
||||
### Hoofdfunctionaliteiten geïmplementeerd:
|
||||
|
||||
**Authentication & Users:**
|
||||
|
||||
- [x] Gebruikersauthenticatie (Lucia + Google OAuth)
|
||||
- [x] User profiles met profile pictures
|
||||
- [x] Profile picture upload en management
|
||||
- [x] User search functionaliteit
|
||||
|
||||
**UI/UX:**
|
||||
|
||||
- [x] Responsive UI met custom componenten
|
||||
- [x] Real-time locatie tracking
|
||||
- [x] Fullscreen map layout met sidebar toggle
|
||||
- [x] Unified mobile/desktop interface
|
||||
- [x] Toast notifications (Sonner)
|
||||
- [x] Loading states en skeleton screens
|
||||
- [x] Custom fonts (Washington)
|
||||
- [x] Organized component library (auth/, finds/, map/, media/, notifications/, profile/)
|
||||
|
||||
**Maps & Location:**
|
||||
|
||||
- [x] Interactive maps (MapLibre GL JS)
|
||||
- [x] PWA functionaliteit
|
||||
- [x] Docker deployment
|
||||
- [x] Database (PostgreSQL + Drizzle ORM)
|
||||
- [x] Toast notifications
|
||||
- [x] Loading states en error handling
|
||||
- [x] SEO optimalisatie (meta tags, Open Graph, sitemap)
|
||||
- [x] Performance optimalisaties (image compression, caching)
|
||||
- [x] Finds feature met media upload
|
||||
- [x] Real-time locatie tracking
|
||||
- [x] Continuous location watching
|
||||
- [x] Smart map centering en zoom controls
|
||||
- [x] Google Maps Places API integratie
|
||||
- [x] POI search functionaliteit
|
||||
- [x] Enhanced marker positioning
|
||||
|
||||
**Finds & Media:**
|
||||
|
||||
- [x] Finds feature met create/view/edit/delete
|
||||
- [x] Multi-media upload (images + videos)
|
||||
- [x] Cloudflare R2 storage integratie
|
||||
- [x] Signed URLs voor veilige media toegang
|
||||
- [x] API architectuur verbetering
|
||||
- [x] Video support met custom VideoPlayer component
|
||||
- [x] WebP image processing met JPEG fallbacks
|
||||
- [x] WebP/JPEG image processing met fallbacks
|
||||
- [x] Local media proxy voor caching en performance
|
||||
- [x] Public find detail pages
|
||||
- [x] Native Web Share API voor sharing
|
||||
- [x] Privacy-aware find filtering (All/Public/Friends/Mine)
|
||||
- [x] EditFindModal met complete edit functionaliteit
|
||||
- [x] Individual media deletion
|
||||
- [x] Optimistic updates met automatic sync
|
||||
- [x] Find deletion met authorization checks
|
||||
|
||||
**Social Interactions:**
|
||||
|
||||
- [x] Like/unlike systeem met real-time updates
|
||||
- [x] Social interactions en animated UI components
|
||||
- [x] Comments systeem met real-time synchronisatie
|
||||
- [x] Scrollable comments met limit functionaliteit ("+ N more comments")
|
||||
- [x] Friends & Privacy System
|
||||
- [x] Friend request workflow (send/accept/reject/remove)
|
||||
- [x] Friends management pagina
|
||||
- [x] Users search met friendship status integration
|
||||
- [x] Privacy-bewuste find visibility
|
||||
|
||||
**Notifications:**
|
||||
|
||||
- [x] Web Push notifications systeem
|
||||
- [x] Notification preferences management
|
||||
- [x] Push notifications voor likes, comments, en friend requests
|
||||
- [x] Service worker push event handling
|
||||
- [x] In-app notification UI
|
||||
- [x] NotificationManager voor real-time handling
|
||||
|
||||
**Performance & SEO:**
|
||||
|
||||
- [x] PWA functionaliteit
|
||||
- [x] Service worker caching strategy
|
||||
- [x] SEO optimalisatie (meta tags, Open Graph)
|
||||
- [x] Automatic sitemap generation
|
||||
- [x] Performance optimalisaties (image compression, lazy loading)
|
||||
- [x] CSP (Content Security Policy) configuration
|
||||
- [x] Lighthouse performance monitoring
|
||||
|
||||
**Infrastructure:**
|
||||
|
||||
- [x] PostgreSQL database (Drizzle ORM)
|
||||
- [x] Docker deployment setup
|
||||
- [x] Vercel production deployment
|
||||
- [x] API architectuur met dedicated routes
|
||||
- [x] Sync-service voor data synchronisatie
|
||||
- [x] API-sync layer voor optimistic updates
|
||||
- [x] Error handling en validation
|
||||
- [x] Type-safe interfaces across entire stack
|
||||
- [x] Automatic rollback mechanism
|
||||
- [x] Centralized state management
|
||||
|
||||
**Developer Experience:**
|
||||
|
||||
- [x] Clean code organization
|
||||
- [x] AGENTS.md documentation
|
||||
- [x] Comprehensive logboek.md
|
||||
- [x] Modular component structure
|
||||
- [x] Svelte 5 runes patterns ($props, $derived, $effect)
|
||||
- [x] ESLint + Prettier configuration
|
||||
|
||||
---
|
||||
|
||||
## Technical Achievements
|
||||
|
||||
### Architecture Highlights:
|
||||
|
||||
**Component Organization:**
|
||||
|
||||
```
|
||||
src/lib/components/
|
||||
├── auth/ - Authentication (1 component)
|
||||
├── finds/ - Find features (10 components)
|
||||
├── map/ - Map functionality (3 components)
|
||||
├── media/ - Media players (1 component)
|
||||
├── notifications/ - Push notifications (3 components)
|
||||
├── profile/ - User profiles (3 components)
|
||||
├── badge/ - UI primitives (shadcn/ui)
|
||||
├── button/ - UI primitives (shadcn/ui)
|
||||
├── card/ - UI primitives (shadcn/ui)
|
||||
├── dropdown-menu/ - UI primitives (shadcn/ui)
|
||||
├── input/ - UI primitives (shadcn/ui)
|
||||
├── label/ - UI primitives (shadcn/ui)
|
||||
├── sheet/ - UI primitives (shadcn/ui)
|
||||
├── skeleton/ - UI primitives (shadcn/ui)
|
||||
└── sonner/ - Toast notifications (shadcn/ui)
|
||||
```
|
||||
|
||||
**Database Schema:**
|
||||
|
||||
- Users table (auth, profiles)
|
||||
- Sessions table (Lucia auth)
|
||||
- OAuth accounts table
|
||||
- Finds table (posts met location en media)
|
||||
- Likes table (user interactions)
|
||||
- Comments table (nested discussions)
|
||||
- Friendships table (social connections)
|
||||
- Notification subscriptions table
|
||||
- Notification preferences table
|
||||
|
||||
**API Routes:**
|
||||
|
||||
```
|
||||
/api/finds/
|
||||
├── GET - List finds met filtering
|
||||
├── POST - Create new find
|
||||
├── [findId]/
|
||||
│ ├── GET - Get single find
|
||||
│ ├── PATCH - Update find
|
||||
│ ├── DELETE - Delete find
|
||||
│ ├── like/ - Like/unlike POST
|
||||
│ ├── comments/ - Comments CRUD
|
||||
│ └── media/
|
||||
│ └── [mediaId]/
|
||||
│ └── DELETE - Delete individual media
|
||||
├── upload/ - Media upload
|
||||
/api/friends/
|
||||
├── GET - List friends
|
||||
├── POST - Send friend request
|
||||
├── [friendshipId]/
|
||||
│ ├── PATCH - Accept/reject request
|
||||
│ └── DELETE - Remove friend
|
||||
/api/users/
|
||||
├── GET - Search users
|
||||
/api/notifications/
|
||||
├── GET - List notifications
|
||||
├── subscribe/ - Web Push subscription
|
||||
├── preferences/ - Notification settings
|
||||
└── count/ - Unread count
|
||||
/api/profile-picture/
|
||||
├── upload/ - Upload profile picture
|
||||
└── delete/ - Delete profile picture
|
||||
/api/places/
|
||||
├── GET - Google Places search
|
||||
/api/media/
|
||||
└── [...path]/ - Local media proxy
|
||||
```
|
||||
|
||||
### Performance Metrics:
|
||||
|
||||
**Before Optimizations (Oct 7):**
|
||||
|
||||
- Home page load: ~2.5s
|
||||
- Largest Contentful Paint: ~1.8s
|
||||
- Background image: 4.2MB
|
||||
|
||||
**After Optimizations (Oct 7+):**
|
||||
|
||||
- Home page load: ~1.2s
|
||||
- Largest Contentful Paint: ~0.9s
|
||||
- Background image: 2.1MB (50% reduction)
|
||||
- Service worker caching enabled
|
||||
- Media proxy caching implemented
|
||||
|
||||
### Code Quality:
|
||||
|
||||
- **Type Safety:** 100% TypeScript coverage
|
||||
- **Formatting:** Prettier (tabs, single quotes, 100 char width)
|
||||
- **Linting:** ESLint with strict rules
|
||||
- **Framework:** Svelte 5 with runes ($props, $derived, $effect)
|
||||
- **Database:** Type-safe Drizzle ORM
|
||||
- **Error Handling:** Comprehensive try/catch en validation
|
||||
|
||||
---
|
||||
|
||||
## Development Insights
|
||||
|
||||
### Key Learnings:
|
||||
|
||||
1. **Component Architecture:** Organizing components by feature domain (auth/, finds/, map/) greatly improves maintainability
|
||||
2. **Media Handling:** Local proxy for media caching solves CSP issues and improves performance
|
||||
3. **Real-time Sync:** Custom sync-service architecture enables seamless real-time updates
|
||||
4. **Progressive Enhancement:** Web Share API met clipboard fallback ensures broad compatibility
|
||||
5. **Map UX:** Separate handling voor user location en find markers prevents annoying auto-centering
|
||||
6. **Notifications:** Web Push requires careful service worker lifecycle management
|
||||
|
||||
### Challenges Overcome:
|
||||
|
||||
1. **CSP Issues:** Resolved by implementing local media proxy instead of direct R2 URLs
|
||||
2. **Map Centering:** Fixed auto-zoom during location watching door smart state management
|
||||
3. **Component Organization:** Large refactor (40 files) improved import patterns significantly
|
||||
4. **Auth Flow:** Complex OAuth implementation met CSRF protection
|
||||
5. **Real-time Updates:** Sync-service architecture voor consistent state management
|
||||
6. **Mobile UX:** Unified interface eliminates duplication en improves consistency
|
||||
7. **Optimistic Updates:** API-sync layer met automatic rollback voor seamless UX
|
||||
8. **State Management:** Centralized sync architecture eliminates redundant code
|
||||
|
||||
### Future Considerations:
|
||||
|
||||
- [ ] Offline support met service worker caching uitbreiden
|
||||
- [ ] Advanced find filtering (date range, location radius)
|
||||
- [ ] Direct messaging tussen friends
|
||||
- [ ] Find collections/albums
|
||||
- [ ] Advanced media editing (filters, cropping)
|
||||
- [ ] Geofencing notifications
|
||||
- [ ] Find analytics en insights
|
||||
- [ ] Multi-language support (i18n)
|
||||
- [ ] Advanced privacy controls (block users, hide locations)
|
||||
- [ ] Export/backup functionaliteit
|
||||
- [ ] Batch operations (multi-select delete/edit)
|
||||
- [ ] Media reordering in finds
|
||||
|
||||
---
|
||||
|
||||
## Project Files Structure
|
||||
|
||||
```
|
||||
serengo/
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ ├── components/ - UI components (organized by feature)
|
||||
│ │ ├── server/ - Server-side utilities
|
||||
│ │ │ ├── db/ - Database schema en connection
|
||||
│ │ │ ├── auth.ts - Authentication logic
|
||||
│ │ │ ├── oauth.ts - OAuth providers
|
||||
│ │ │ ├── push.ts - Web Push notifications
|
||||
│ │ │ ├── r2.ts - Cloudflare R2 storage
|
||||
│ │ │ └── media-processor.ts - Media processing
|
||||
│ │ ├── stores/ - Svelte stores
|
||||
│ │ │ ├── api-sync.ts - Real-time sync service
|
||||
│ │ │ └── location.ts - Location tracking
|
||||
│ │ ├── utils/ - Utility functions
|
||||
│ │ │ ├── geolocation.ts
|
||||
│ │ │ └── places.ts
|
||||
│ │ └── index.ts - Barrel exports
|
||||
│ ├── routes/
|
||||
│ │ ├── api/ - API endpoints
|
||||
│ │ ├── finds/ - Find pages
|
||||
│ │ ├── friends/ - Friends page
|
||||
│ │ ├── login/ - Auth pages
|
||||
│ │ └── +page.svelte - Homepage
|
||||
│ ├── app.html - HTML template
|
||||
│ ├── app.css - Global styles
|
||||
│ ├── hooks.server.ts - SvelteKit hooks
|
||||
│ └── service-worker.ts - PWA service worker
|
||||
├── static/ - Static assets
|
||||
├── drizzle/ - Database migrations
|
||||
├── logs/ - Development logs
|
||||
├── scripts/ - Utility scripts
|
||||
├── .env.example - Environment template
|
||||
├── drizzle.config.ts - Drizzle ORM config
|
||||
├── svelte.config.js - SvelteKit config
|
||||
├── vite.config.ts - Vite config
|
||||
├── tsconfig.json - TypeScript config
|
||||
├── package.json - Dependencies
|
||||
├── AGENTS.md - AI agent guidelines
|
||||
└── README.md - Project documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment & Configuration
|
||||
|
||||
**Environment Variables Required:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL= # PostgreSQL connection string
|
||||
GOOGLE_CLIENT_ID= # Google OAuth
|
||||
GOOGLE_CLIENT_SECRET= # Google OAuth
|
||||
R2_ACCOUNT_ID= # Cloudflare R2
|
||||
R2_ACCESS_KEY_ID= # Cloudflare R2
|
||||
R2_SECRET_ACCESS_KEY= # Cloudflare R2
|
||||
R2_BUCKET_NAME= # Cloudflare R2
|
||||
VAPID_PUBLIC_KEY= # Web Push notifications
|
||||
VAPID_PRIVATE_KEY= # Web Push notifications
|
||||
GOOGLE_MAPS_API_KEY= # Google Places API
|
||||
```
|
||||
|
||||
**Docker Commands:**
|
||||
|
||||
```bash
|
||||
pnpm run db:start # Start PostgreSQL container
|
||||
pnpm run db:push # Push schema changes
|
||||
pnpm run db:generate # Generate migrations
|
||||
pnpm run db:migrate # Run migrations
|
||||
```
|
||||
|
||||
**Development Commands:**
|
||||
|
||||
```bash
|
||||
pnpm run dev # Start dev server
|
||||
pnpm run build # Production build
|
||||
pnpm run preview # Preview production build
|
||||
pnpm run check # Type checking (svelte-check)
|
||||
pnpm run lint # ESLint + Prettier
|
||||
pnpm run format # Prettier --write
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 1 December 2025
|
||||
**Status:** Active Development
|
||||
**Version:** Beta (Pre-release)
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
@@ -32,7 +33,6 @@
|
||||
"bits-ui": "^2.11.4",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-storybook": "^9.1.8",
|
||||
@@ -59,9 +59,13 @@
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@sveltejs/adapter-vercel": "^5.10.2",
|
||||
"arctic": "^3.7.0",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"lucide-svelte": "^0.553.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"postgres": "^3.4.5",
|
||||
"sharp": "^0.34.4",
|
||||
"svelte-maplibre": "^1.2.1"
|
||||
"svelte-maplibre": "^1.2.1",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
20
scripts/generate-vapid-keys.js
Executable file
20
scripts/generate-vapid-keys.js
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate VAPID keys for Web Push notifications
|
||||
* Run this script once to generate your VAPID keys and add them to your .env file
|
||||
*/
|
||||
|
||||
import webpush from 'web-push';
|
||||
|
||||
console.log('Generating VAPID keys for Web Push notifications...\n');
|
||||
|
||||
const vapidKeys = webpush.generateVAPIDKeys();
|
||||
|
||||
console.log('VAPID Keys Generated Successfully!');
|
||||
console.log('Add these to your .env file:\n');
|
||||
console.log(`VAPID_PUBLIC_KEY="${vapidKeys.publicKey}"`);
|
||||
console.log(`VAPID_PRIVATE_KEY="${vapidKeys.privateKey}"`);
|
||||
console.log('VAPID_SUBJECT="mailto:your-email@example.com"');
|
||||
console.log('\nReplace "your-email@example.com" with your actual email address.');
|
||||
console.log('\nIMPORTANT: Keep your private key secret and never commit it to version control!');
|
||||
@@ -88,6 +88,7 @@
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -13,9 +13,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
!origin ||
|
||||
origin.includes('localhost') ||
|
||||
origin.includes('127.0.0.1') ||
|
||||
origin.includes('serengo.ziasvannes.tech')
|
||||
origin.includes('serengo.zias.be')
|
||||
) {
|
||||
// Allow in development and serengo.ziasvannes.tech
|
||||
// Allow in development and serengo.zias.be
|
||||
}
|
||||
// In production, you would add: else if (origin !== 'yourdomain.com') { return new Response('Forbidden', { status: 403 }); }
|
||||
}
|
||||
@@ -50,9 +50,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
"worker-src 'self' blob:; " +
|
||||
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
|
||||
"font-src 'self' fonts.gstatic.com; " +
|
||||
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com; " +
|
||||
"media-src 'self' *.r2.cloudflarestorage.com; " +
|
||||
"connect-src 'self' *.openstreetmap.org; " +
|
||||
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||
"media-src 'self' *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||
"connect-src 'self' *.openstreetmap.org https://fcm.googleapis.com https://android.googleapis.com; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self';"
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import Input from '$lib/components/Input.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { coordinates } from '$lib/stores/location';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onFindCreated: (event: CustomEvent) => void;
|
||||
}
|
||||
|
||||
let { isOpen, onClose, onFindCreated }: Props = $props();
|
||||
|
||||
// Form state
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let latitude = $state('');
|
||||
let longitude = $state('');
|
||||
let locationName = $state('');
|
||||
let category = $state('cafe');
|
||||
let isPublic = $state(true);
|
||||
let selectedFiles = $state<FileList | null>(null);
|
||||
let isSubmitting = $state(false);
|
||||
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
||||
|
||||
// Categories
|
||||
const categories = [
|
||||
{ value: 'cafe', label: 'Café' },
|
||||
{ value: 'restaurant', label: 'Restaurant' },
|
||||
{ value: 'park', label: 'Park' },
|
||||
{ value: 'landmark', label: 'Landmark' },
|
||||
{ value: 'shop', label: 'Shop' },
|
||||
{ value: 'museum', label: 'Museum' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
// Auto-fill location when modal opens
|
||||
$effect(() => {
|
||||
if (isOpen && $coordinates) {
|
||||
latitude = $coordinates.latitude.toString();
|
||||
longitude = $coordinates.longitude.toString();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle file selection
|
||||
function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
selectedFiles = target.files;
|
||||
}
|
||||
|
||||
// Upload media files
|
||||
async function uploadMedia(): Promise<void> {
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
const formData = new FormData();
|
||||
Array.from(selectedFiles).forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await fetch('/api/finds/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload media');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
uploadedMedia = result.media;
|
||||
}
|
||||
|
||||
// Submit form
|
||||
async function handleSubmit() {
|
||||
const lat = parseFloat(latitude);
|
||||
const lng = parseFloat(longitude);
|
||||
|
||||
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
// Upload media first if any
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
await uploadMedia();
|
||||
}
|
||||
|
||||
// Create the find
|
||||
const response = await fetch('/api/finds', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
locationName: locationName.trim() || null,
|
||||
category,
|
||||
isPublic,
|
||||
media: uploadedMedia
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create find');
|
||||
}
|
||||
|
||||
// Reset form and close modal
|
||||
resetForm();
|
||||
onClose();
|
||||
|
||||
// Notify parent about new find creation
|
||||
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
||||
} catch (error) {
|
||||
console.error('Error creating find:', error);
|
||||
alert('Failed to create find. Please try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
title = '';
|
||||
description = '';
|
||||
locationName = '';
|
||||
category = 'cafe';
|
||||
isPublic = true;
|
||||
selectedFiles = null;
|
||||
uploadedMedia = [];
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<Modal bind:showModal={isOpen} positioning="center">
|
||||
{#snippet header()}
|
||||
<h2>Create Find</h2>
|
||||
{/snippet}
|
||||
|
||||
<div class="form-container">
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div class="form-group">
|
||||
<label for="title">Title *</label>
|
||||
<Input name="title" placeholder="What did you find?" required bind:value={title} />
|
||||
<span class="char-count">{title.length}/100</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
placeholder="Tell us about this place..."
|
||||
maxlength="500"
|
||||
bind:value={description}
|
||||
></textarea>
|
||||
<span class="char-count">{description.length}/500</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location-name">Location Name</label>
|
||||
<Input
|
||||
name="location-name"
|
||||
placeholder="e.g., Café Belga, Brussels"
|
||||
bind:value={locationName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="latitude">Latitude *</label>
|
||||
<Input name="latitude" type="text" required bind:value={latitude} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="longitude">Longitude *</label>
|
||||
<Input name="longitude" type="text" required bind:value={longitude} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="category">Category</label>
|
||||
<select name="category" bind:value={category}>
|
||||
{#each categories as cat (cat.value)}
|
||||
<option value={cat.value}>{cat.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="media-files">Photos/Videos (max 5)</label>
|
||||
<input
|
||||
id="media-files"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
||||
multiple
|
||||
max="5"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if selectedFiles && selectedFiles.length > 0}
|
||||
<div class="file-preview">
|
||||
{#each Array.from(selectedFiles) as file (file.name)}
|
||||
<span class="file-name">{file.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="privacy-toggle">
|
||||
<input type="checkbox" bind:checked={isPublic} />
|
||||
<span class="checkmark"></span>
|
||||
Make this find public
|
||||
</label>
|
||||
<p class="privacy-help">
|
||||
{isPublic ? 'Everyone can see this find' : 'Only your friends can see this find'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button secondary"
|
||||
onclick={closeModal}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Button type="submit" disabled={isSubmitting || !title.trim()}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Find'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.form-container {
|
||||
padding: 24px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
font-family: 'Washington', serif;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 2rem;
|
||||
background-color: #e0e0e0;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
outline: none;
|
||||
transition: background-color 0.2s ease;
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
background-color: #d5d5d5;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 2rem;
|
||||
background-color: #e0e0e0;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
outline: none;
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
background-color: #d5d5d5;
|
||||
}
|
||||
|
||||
input[type='file'] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
background-color: #f9f9f9;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
input[type='file']:hover {
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
background-color: #f0f0f0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.privacy-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.privacy-toggle input[type='checkbox'] {
|
||||
margin-right: 8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.privacy-help {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
margin-left: 26px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.button {
|
||||
flex: 1;
|
||||
padding: 1.25rem 2rem;
|
||||
border: none;
|
||||
border-radius: 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
height: 3.5rem;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.button:disabled:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.secondary:hover:not(:disabled) {
|
||||
background-color: #5a6268;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ProfilePanel } from '$lib';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
@@ -12,7 +13,7 @@
|
||||
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<h1 class="app-title"><a href="/">Serengo</a></h1>
|
||||
<h1 class="app-title"><a href={resolveRoute('/')}>Serengo</a></h1>
|
||||
<div class="profile-container">
|
||||
<ProfilePanel
|
||||
username={user.username}
|
||||
@@ -27,8 +28,7 @@
|
||||
.app-header {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@@ -37,7 +37,6 @@
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Heart } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
findId: string;
|
||||
isLiked?: boolean;
|
||||
likeCount?: number;
|
||||
size?: 'sm' | 'default' | 'lg';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
findId,
|
||||
isLiked = false,
|
||||
likeCount = 0,
|
||||
size = 'default',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
// Track the source of truth - server state
|
||||
let serverIsLiked = $state(isLiked);
|
||||
let serverLikeCount = $state(likeCount);
|
||||
|
||||
// Track optimistic state during loading
|
||||
let isLoading = $state(false);
|
||||
let optimisticIsLiked = $state(isLiked);
|
||||
let optimisticLikeCount = $state(likeCount);
|
||||
|
||||
// Derived state for display
|
||||
let displayIsLiked = $derived(isLoading ? optimisticIsLiked : serverIsLiked);
|
||||
let displayLikeCount = $derived(isLoading ? optimisticLikeCount : serverLikeCount);
|
||||
|
||||
async function toggleLike() {
|
||||
if (isLoading) return;
|
||||
|
||||
// Set optimistic state
|
||||
optimisticIsLiked = !serverIsLiked;
|
||||
optimisticLikeCount = serverLikeCount + (optimisticIsLiked ? 1 : -1);
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const method = optimisticIsLiked ? 'POST' : 'DELETE';
|
||||
const response = await fetch(`/api/finds/${findId}/like`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update server state with response
|
||||
serverIsLiked = result.isLiked;
|
||||
serverLikeCount = result.likeCount;
|
||||
} catch (error: unknown) {
|
||||
console.error('Error updating like:', error);
|
||||
toast.error('Failed to update like. Please try again.');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update server state when props change (from parent component)
|
||||
$effect(() => {
|
||||
serverIsLiked = isLiked;
|
||||
serverLikeCount = likeCount;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
{size}
|
||||
class="group gap-1.5 {className}"
|
||||
onclick={toggleLike}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Heart
|
||||
class="h-4 w-4 transition-all duration-200 {displayIsLiked
|
||||
? 'scale-110 fill-red-500 text-red-500'
|
||||
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {isLoading
|
||||
? 'animate-pulse'
|
||||
: ''}"
|
||||
/>
|
||||
{#if displayLikeCount > 0}
|
||||
<span
|
||||
class="text-sm font-medium transition-colors {displayIsLiked
|
||||
? 'text-red-500'
|
||||
: 'text-gray-500 group-hover:text-red-400'}"
|
||||
>
|
||||
{displayLikeCount}
|
||||
</span>
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -1,237 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
locationActions,
|
||||
locationStatus,
|
||||
locationError,
|
||||
isLocationLoading
|
||||
} from '$lib/stores/location';
|
||||
import { Skeleton } from './skeleton';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
variant?: 'primary' | 'secondary' | 'icon';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
showLabel = true
|
||||
}: Props = $props();
|
||||
|
||||
async function handleLocationClick() {
|
||||
const result = await locationActions.getCurrentLocation({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 15000,
|
||||
maximumAge: 300000
|
||||
});
|
||||
|
||||
if (!result && $locationError) {
|
||||
toast.error($locationError.message);
|
||||
}
|
||||
}
|
||||
|
||||
const buttonText = $derived(() => {
|
||||
if ($isLocationLoading) return 'Finding location...';
|
||||
if ($locationStatus === 'success') return 'Update location';
|
||||
return 'Find my location';
|
||||
});
|
||||
|
||||
const iconClass = $derived(() => {
|
||||
if ($isLocationLoading) return 'loading';
|
||||
if ($locationStatus === 'success') return 'success';
|
||||
if ($locationStatus === 'error') return 'error';
|
||||
return 'default';
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="location-button {variant} {size} {className}"
|
||||
onclick={handleLocationClick}
|
||||
disabled={$isLocationLoading}
|
||||
title={buttonText()}
|
||||
>
|
||||
<span class="icon {iconClass()}">
|
||||
{#if $isLocationLoading}
|
||||
<div class="loading-skeleton">
|
||||
<Skeleton class="h-5 w-5 rounded-full" />
|
||||
</div>
|
||||
{:else if $locationStatus === 'success'}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if $locationStatus === 'error'}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if showLabel && variant !== 'icon'}
|
||||
<span class="label">{buttonText()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.location-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.location-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.primary {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: #1d4ed8;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.icon:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.small {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.small.icon {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.medium {
|
||||
padding: 8px 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.medium.icon {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.large {
|
||||
padding: 12px 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.large.icon {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Icon styles */
|
||||
.icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.small .icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.large .icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.icon.success svg {
|
||||
color: #10b981;
|
||||
fill: #10b981;
|
||||
}
|
||||
|
||||
.icon.error svg {
|
||||
color: #ef4444;
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.location-button .label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.location-button {
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,336 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { MapLibre, Marker } from 'svelte-maplibre';
|
||||
import type { StyleSpecification } from 'svelte-maplibre';
|
||||
import {
|
||||
coordinates,
|
||||
getMapCenter,
|
||||
getMapZoom,
|
||||
shouldZoomToLocation,
|
||||
locationActions
|
||||
} from '$lib/stores/location';
|
||||
import LocationButton from './LocationButton.svelte';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
|
||||
interface Find {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string;
|
||||
category?: string;
|
||||
isPublic: number;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
media?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
style?: StyleSpecification;
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
class?: string;
|
||||
showLocationButton?: boolean;
|
||||
autoCenter?: boolean;
|
||||
finds?: Find[];
|
||||
onFindClick?: (find: Find) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
style = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'osm-raster': {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-tiles',
|
||||
type: 'raster',
|
||||
source: 'osm-raster'
|
||||
}
|
||||
]
|
||||
},
|
||||
center,
|
||||
zoom,
|
||||
class: className = '',
|
||||
showLocationButton = true,
|
||||
autoCenter = true,
|
||||
finds = [],
|
||||
onFindClick
|
||||
}: Props = $props();
|
||||
|
||||
let mapLoaded = $state(false);
|
||||
let styleLoaded = $state(false);
|
||||
let isIdle = $state(false);
|
||||
|
||||
// Handle comprehensive map loading events
|
||||
function handleStyleLoad() {
|
||||
styleLoaded = true;
|
||||
}
|
||||
|
||||
function handleIdle() {
|
||||
isIdle = true;
|
||||
}
|
||||
|
||||
// Map is considered fully ready when it's loaded, style is loaded, and it's idle
|
||||
const mapReady = $derived(mapLoaded && styleLoaded && isIdle);
|
||||
|
||||
// Reactive center and zoom based on location or props
|
||||
const mapCenter = $derived(
|
||||
$coordinates && (autoCenter || $shouldZoomToLocation)
|
||||
? ([$coordinates.longitude, $coordinates.latitude] as [number, number])
|
||||
: center || $getMapCenter
|
||||
);
|
||||
|
||||
const mapZoom = $derived(() => {
|
||||
if ($shouldZoomToLocation && $coordinates) {
|
||||
// Force zoom to calculated level when location button is clicked
|
||||
return $getMapZoom;
|
||||
}
|
||||
if ($coordinates && autoCenter) {
|
||||
return $getMapZoom;
|
||||
}
|
||||
return zoom || 13;
|
||||
});
|
||||
|
||||
// Effect to clear zoom trigger after it's been used
|
||||
$effect(() => {
|
||||
if ($shouldZoomToLocation) {
|
||||
// Use a timeout to ensure the map has updated before clearing the trigger
|
||||
setTimeout(() => {
|
||||
locationActions.clearZoomTrigger();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="map-container {className}">
|
||||
{#if !mapReady}
|
||||
<div class="map-skeleton">
|
||||
<Skeleton class="h-full w-full rounded-xl" />
|
||||
<div class="skeleton-overlay">
|
||||
<Skeleton class="mb-2 h-4 w-16" />
|
||||
<Skeleton class="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="map-wrapper" class:hidden={!mapReady}>
|
||||
<MapLibre
|
||||
{style}
|
||||
center={mapCenter}
|
||||
zoom={mapZoom()}
|
||||
bind:loaded={mapLoaded}
|
||||
onstyleload={handleStyleLoad}
|
||||
onidle={handleIdle}
|
||||
>
|
||||
{#if $coordinates}
|
||||
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
|
||||
<div class="location-marker">
|
||||
<div class="marker-pulse"></div>
|
||||
<div class="marker-outer">
|
||||
<div class="marker-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
{/if}
|
||||
|
||||
{#each finds as find (find.id)}
|
||||
<Marker lngLat={[parseFloat(find.longitude), parseFloat(find.latitude)]}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="find-marker"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onFindClick?.(find)}
|
||||
title={find.title}
|
||||
>
|
||||
<div class="find-marker-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2L13.09 8.26L19 9.27L13.09 10.28L12 16.54L10.91 10.28L5 9.27L10.91 8.26L12 2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{#if find.media && find.media.length > 0}
|
||||
<div class="find-marker-preview">
|
||||
<img src={find.media[0].thumbnailUrl} alt={find.title} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Marker>
|
||||
{/each}
|
||||
</MapLibre>
|
||||
|
||||
{#if showLocationButton}
|
||||
<div class="location-controls">
|
||||
<LocationButton variant="icon" size="medium" showLabel={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-skeleton {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeleton-overlay {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-wrapper.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-container :global(.maplibregl-map) {
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-controls {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* Location marker styles */
|
||||
:global(.location-marker) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.marker-outer) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(37, 99, 235, 0.2);
|
||||
border: 2px solid #2563eb;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:global(.marker-inner) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
:global(.marker-pulse) {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(37, 99, 235, 0.6);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Find marker styles */
|
||||
:global(.find-marker) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.find-marker:hover) {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
:global(.find-marker-icon) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #ff6b35;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
:global(.find-marker-preview) {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
:global(.find-marker-preview img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.map-container {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.location-controls {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -163,6 +163,7 @@
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.modal.dropdown {
|
||||
@@ -171,11 +172,12 @@
|
||||
right: 0;
|
||||
max-width: 320px;
|
||||
width: 320px;
|
||||
z-index: 1000;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.modal::backdrop {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
|
||||
1
src/lib/components/auth/index.ts
Normal file
1
src/lib/components/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LoginForm } from './login-form.svelte';
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
import { Button } from '$lib/components/button/index.js';
|
||||
import * as Card from '$lib/components/card/index.js';
|
||||
import { Label } from '$lib/components/label/index.js';
|
||||
@@ -8,7 +9,7 @@
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { ActionData } from '../../routes/login/$types.js';
|
||||
import type { ActionData } from '../../../routes/login/$types.js';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
@@ -62,7 +63,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" class="mt-4 w-full" onclick={() => goto('/login/google')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mt-4 w-full"
|
||||
onclick={() => goto(resolveRoute('/login/google'))}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
141
src/lib/components/finds/Comment.svelte
Normal file
141
src/lib/components/finds/Comment.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import type { CommentState } from '$lib/stores/api-sync';
|
||||
|
||||
interface CommentProps {
|
||||
comment: CommentState;
|
||||
showDeleteButton?: boolean;
|
||||
onDelete?: (commentId: string) => void;
|
||||
}
|
||||
|
||||
let { comment, showDeleteButton = false, onDelete }: CommentProps = $props();
|
||||
|
||||
function handleDelete() {
|
||||
if (onDelete) {
|
||||
onDelete(comment.id);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string): string {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - dateObj.getTime();
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (minutes < 1) {
|
||||
return 'just now';
|
||||
} else if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
} else if (hours < 24) {
|
||||
return `${hours}h`;
|
||||
} else if (days < 7) {
|
||||
return `${days}d`;
|
||||
} else {
|
||||
return dateObj.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="comment">
|
||||
<div class="comment-avatar">
|
||||
<ProfilePicture
|
||||
username={comment.user.username}
|
||||
profilePictureUrl={comment.user.profilePictureUrl}
|
||||
class="avatar-small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="comment-content">
|
||||
<div class="comment-header">
|
||||
<span class="comment-username">@{comment.user.username}</span>
|
||||
<span class="comment-time">{formatDate(comment.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
<div class="comment-text">
|
||||
{comment.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showDeleteButton}
|
||||
<div class="comment-actions">
|
||||
<Button variant="ghost" size="sm" class="delete-button" onclick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comment {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.comment:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.avatar-small) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.comment-username {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
color: hsl(var(--foreground));
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
:global(.delete-button) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding: 0.25rem;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.delete-button:hover) {
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
}
|
||||
</style>
|
||||
118
src/lib/components/finds/CommentForm.svelte
Normal file
118
src/lib/components/finds/CommentForm.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Send } from '@lucide/svelte';
|
||||
|
||||
interface CommentFormProps {
|
||||
onSubmit: (content: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { onSubmit, placeholder = 'Add a comment...', disabled = false }: CommentFormProps = $props();
|
||||
|
||||
let content = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const trimmedContent = content.trim();
|
||||
if (!trimmedContent || isSubmitting || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedContent.length > 500) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
onSubmit(trimmedContent);
|
||||
content = '';
|
||||
} catch (error) {
|
||||
console.error('Error submitting comment:', error);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit(event);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = $derived(
|
||||
content.trim().length > 0 && content.length <= 500 && !isSubmitting && !disabled
|
||||
);
|
||||
</script>
|
||||
|
||||
<form class="comment-form" onsubmit={handleSubmit}>
|
||||
<div class="input-container">
|
||||
<Input
|
||||
bind:value={content}
|
||||
{placeholder}
|
||||
disabled={disabled || isSubmitting}
|
||||
class="comment-input"
|
||||
onkeydown={handleKeyDown}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="ghost" size="sm" disabled={!canSubmit} class="submit-button">
|
||||
<Send size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if content.length > 450}
|
||||
<div class="character-count" class:warning={content.length > 500}>
|
||||
{content.length}/500
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.comment-form {
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--background));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.comment-input) {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
:global(.submit-button) {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.submit-button:not(:disabled)) {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
:global(.submit-button:not(:disabled):hover) {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.character-count {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: right;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.character-count.warning {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
</style>
|
||||
23
src/lib/components/finds/Comments.svelte
Normal file
23
src/lib/components/finds/Comments.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import CommentsList from './CommentsList.svelte';
|
||||
|
||||
interface CommentsProps {
|
||||
findId: string;
|
||||
currentUserId?: string;
|
||||
collapsed?: boolean;
|
||||
maxComments?: number;
|
||||
showCommentForm?: boolean;
|
||||
isScrollable?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
findId,
|
||||
currentUserId,
|
||||
collapsed = true,
|
||||
maxComments,
|
||||
showCommentForm = true,
|
||||
isScrollable = false
|
||||
}: CommentsProps = $props();
|
||||
</script>
|
||||
|
||||
<CommentsList {findId} {currentUserId} {collapsed} {maxComments} {showCommentForm} {isScrollable} />
|
||||
253
src/lib/components/finds/CommentsList.svelte
Normal file
253
src/lib/components/finds/CommentsList.svelte
Normal file
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import Comment from './Comment.svelte';
|
||||
import CommentForm from './CommentForm.svelte';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
import { apiSync } from '$lib/stores/api-sync';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface CommentsListProps {
|
||||
findId: string;
|
||||
currentUserId?: string;
|
||||
collapsed?: boolean;
|
||||
maxComments?: number;
|
||||
showCommentForm?: boolean;
|
||||
isScrollable?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
findId,
|
||||
currentUserId,
|
||||
collapsed = true,
|
||||
maxComments,
|
||||
showCommentForm = true,
|
||||
isScrollable = false
|
||||
}: CommentsListProps = $props();
|
||||
|
||||
let isExpanded = $state(!collapsed);
|
||||
let hasLoadedComments = $state(false);
|
||||
|
||||
const commentsState = apiSync.subscribeFindComments(findId);
|
||||
|
||||
onMount(() => {
|
||||
if (isExpanded && !hasLoadedComments) {
|
||||
loadComments();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadComments() {
|
||||
if (hasLoadedComments) return;
|
||||
|
||||
hasLoadedComments = true;
|
||||
await apiSync.loadComments(findId);
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
if (isExpanded && !hasLoadedComments) {
|
||||
loadComments();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddComment(content: string) {
|
||||
await apiSync.addComment(findId, content);
|
||||
}
|
||||
|
||||
async function handleDeleteComment(commentId: string) {
|
||||
await apiSync.deleteComment(commentId, findId);
|
||||
}
|
||||
|
||||
function canDeleteComment(comment: { user: { id: string } }): boolean {
|
||||
return Boolean(
|
||||
currentUserId && (comment.user.id === currentUserId || comment.user.id === 'current-user')
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet loadingSkeleton()}
|
||||
<div class="loading-skeleton">
|
||||
{#each Array(3) as _, index (index)}
|
||||
<div class="comment-skeleton">
|
||||
<Skeleton class="avatar-skeleton" />
|
||||
<div class="content-skeleton">
|
||||
<Skeleton class="header-skeleton" />
|
||||
<Skeleton class="text-skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="comments-list">
|
||||
{#if collapsed}
|
||||
<button class="toggle-button" onclick={toggleExpanded}>
|
||||
{#if isExpanded}
|
||||
Hide comments ({$commentsState.commentCount})
|
||||
{:else if $commentsState.commentCount > 0}
|
||||
View comments ({$commentsState.commentCount})
|
||||
{:else}
|
||||
Add comment
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="comments-container">
|
||||
{#if showCommentForm}
|
||||
<div class="comment-form-container">
|
||||
<CommentForm onSubmit={handleAddComment} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $commentsState.isLoading && !hasLoadedComments}
|
||||
{@render loadingSkeleton()}
|
||||
{:else if $commentsState.error}
|
||||
<div class="error-message">
|
||||
Failed to load comments.
|
||||
<button class="retry-button" onclick={loadComments}> Try again </button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="comments" class:scrollable={isScrollable}>
|
||||
{#each maxComments ? $commentsState.comments.slice(0, maxComments) : $commentsState.comments as comment (comment.id)}
|
||||
<Comment
|
||||
{comment}
|
||||
showDeleteButton={canDeleteComment(comment)}
|
||||
onDelete={handleDeleteComment}
|
||||
/>
|
||||
{:else}
|
||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||
{/each}
|
||||
{#if maxComments && $commentsState.comments.length > maxComments}
|
||||
<div class="see-more">
|
||||
<div class="see-more-text">
|
||||
+{$commentsState.comments.length - maxComments} more comments
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comments-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.comments-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.comment-form-container {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: hsl(var(--background));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.comments {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.comments.scrollable {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
min-height: 0;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.see-more {
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.see-more-text {
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.no-comments {
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
padding: 1.5rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
color: hsl(var(--destructive));
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--primary));
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
color: hsl(var(--primary) / 0.8);
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.comment-skeleton {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
:global(.avatar-skeleton) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.header-skeleton) {
|
||||
width: 120px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
:global(.text-skeleton) {
|
||||
width: 80%;
|
||||
height: 14px;
|
||||
}
|
||||
</style>
|
||||
554
src/lib/components/finds/CreateFindModal.svelte
Normal file
554
src/lib/components/finds/CreateFindModal.svelte
Normal file
@@ -0,0 +1,554 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Label } from '$lib/components/label';
|
||||
import { Button } from '$lib/components/button';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
locationId: string;
|
||||
onClose: () => void;
|
||||
onFindCreated: (event: CustomEvent) => void;
|
||||
}
|
||||
|
||||
let { isOpen, locationId, onClose, onFindCreated }: Props = $props();
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let category = $state('cafe');
|
||||
let isPublic = $state(true);
|
||||
let selectedFiles = $state<FileList | null>(null);
|
||||
let isSubmitting = $state(false);
|
||||
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
||||
|
||||
const categories = [
|
||||
{ value: 'cafe', label: 'Café' },
|
||||
{ value: 'restaurant', label: 'Restaurant' },
|
||||
{ value: 'park', label: 'Park' },
|
||||
{ value: 'landmark', label: 'Landmark' },
|
||||
{ value: 'shop', label: 'Shop' },
|
||||
{ value: 'museum', label: 'Museum' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
let isMobile = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const checkIsMobile = () => {
|
||||
isMobile = window.innerWidth < 768;
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener('resize', checkIsMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
selectedFiles = target.files;
|
||||
}
|
||||
|
||||
async function uploadMedia(): Promise<void> {
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
const formData = new FormData();
|
||||
Array.from(selectedFiles).forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await fetch('/api/finds/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload media');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
uploadedMedia = result.media;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
await uploadMedia();
|
||||
}
|
||||
|
||||
const response = await fetch('/api/finds', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
locationId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
category,
|
||||
isPublic,
|
||||
media: uploadedMedia
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create find');
|
||||
}
|
||||
|
||||
resetForm();
|
||||
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating find:', error);
|
||||
alert('Failed to create find. Please try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
title = '';
|
||||
description = '';
|
||||
category = 'cafe';
|
||||
isPublic = true;
|
||||
selectedFiles = null;
|
||||
uploadedMedia = [];
|
||||
|
||||
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Create Find</h2>
|
||||
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="form"
|
||||
>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<Label for="title">What did you find?</Label>
|
||||
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label for="description">Tell us about it</Label>
|
||||
<textarea
|
||||
name="description"
|
||||
placeholder="The best cappuccino in town..."
|
||||
maxlength="500"
|
||||
bind:value={description}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<div class="field">
|
||||
<Label for="category">Category</Label>
|
||||
<select name="category" bind:value={category} class="select">
|
||||
{#each categories as cat (cat.value)}
|
||||
<option value={cat.value}>{cat.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label>Privacy</Label>
|
||||
<label class="privacy-toggle">
|
||||
<input type="checkbox" bind:checked={isPublic} />
|
||||
<span>{isPublic ? 'Public' : 'Private'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label for="media-files">Add photo or video</Label>
|
||||
<div class="file-upload">
|
||||
<input
|
||||
id="media-files"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
||||
onchange={handleFileChange}
|
||||
class="file-input"
|
||||
/>
|
||||
<div class="file-content">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 4V10H20"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>Click to upload</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedFiles && selectedFiles.length > 0}
|
||||
<div class="file-selected">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 4V10H20"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{selectedFiles[0].name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !title.trim()}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Find'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.form {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 100px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||
}
|
||||
|
||||
.privacy-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.privacy-toggle:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.privacy-toggle input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-upload {
|
||||
position: relative;
|
||||
border: 2px dashed hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-upload:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.file-content span {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.file-selected svg {
|
||||
color: hsl(var(--primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-selected span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-footer :global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
876
src/lib/components/finds/EditFindModal.svelte
Normal file
876
src/lib/components/finds/EditFindModal.svelte
Normal file
@@ -0,0 +1,876 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Label } from '$lib/components/label';
|
||||
import { Button } from '$lib/components/button';
|
||||
import POISearch from '../map/POISearch.svelte';
|
||||
import type { PlaceResult } from '$lib/utils/places';
|
||||
import { apiSync } from '$lib/stores/api-sync';
|
||||
|
||||
interface FindData {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string | null;
|
||||
category?: string | null;
|
||||
isPublic: number;
|
||||
media?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
orderIndex: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
find: FindData;
|
||||
onClose: () => void;
|
||||
onFindUpdated: (event: CustomEvent) => void;
|
||||
onFindDeleted?: (event: CustomEvent) => void;
|
||||
}
|
||||
|
||||
let { isOpen, find, onClose, onFindUpdated, onFindDeleted }: Props = $props();
|
||||
|
||||
let title = $state(find.title);
|
||||
let description = $state(find.description || '');
|
||||
let latitude = $state(find.latitude);
|
||||
let longitude = $state(find.longitude);
|
||||
let locationName = $state(find.locationName || '');
|
||||
let category = $state(find.category || 'cafe');
|
||||
let isPublic = $state(find.isPublic === 1);
|
||||
let selectedFiles = $state<FileList | null>(null);
|
||||
let isSubmitting = $state(false);
|
||||
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
||||
let useManualLocation = $state(false);
|
||||
let existingMedia = $state<
|
||||
Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
orderIndex: number | null;
|
||||
}>
|
||||
>(find.media || []);
|
||||
let mediaToDelete = $state<string[]>([]);
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
const categories = [
|
||||
{ value: 'cafe', label: 'Café' },
|
||||
{ value: 'restaurant', label: 'Restaurant' },
|
||||
{ value: 'park', label: 'Park' },
|
||||
{ value: 'landmark', label: 'Landmark' },
|
||||
{ value: 'shop', label: 'Shop' },
|
||||
{ value: 'museum', label: 'Museum' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
let isMobile = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const checkIsMobile = () => {
|
||||
isMobile = window.innerWidth < 768;
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener('resize', checkIsMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
// Reset state when find changes
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
title = find.title;
|
||||
description = find.description || '';
|
||||
latitude = find.latitude;
|
||||
longitude = find.longitude;
|
||||
locationName = find.locationName || '';
|
||||
category = find.category || 'cafe';
|
||||
isPublic = find.isPublic === 1;
|
||||
existingMedia = find.media || [];
|
||||
mediaToDelete = [];
|
||||
uploadedMedia = [];
|
||||
selectedFiles = null;
|
||||
useManualLocation = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
selectedFiles = target.files;
|
||||
}
|
||||
|
||||
async function uploadMedia(): Promise<void> {
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
const formData = new FormData();
|
||||
Array.from(selectedFiles).forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await fetch('/api/finds/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload media');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
uploadedMedia = result.media;
|
||||
}
|
||||
|
||||
function removeExistingMedia(mediaId: string) {
|
||||
mediaToDelete = [...mediaToDelete, mediaId];
|
||||
existingMedia = existingMedia.filter((m) => m.id !== mediaId);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const lat = parseFloat(latitude);
|
||||
const lng = parseFloat(longitude);
|
||||
|
||||
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
// Upload new media files if any
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
await uploadMedia();
|
||||
}
|
||||
|
||||
// Combine existing media with new uploads
|
||||
const allMedia = [
|
||||
...existingMedia.map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || m.url
|
||||
})),
|
||||
...uploadedMedia
|
||||
];
|
||||
|
||||
await apiSync.updateFind(find.id, {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
locationName: locationName.trim() || null,
|
||||
category,
|
||||
isPublic,
|
||||
media: allMedia,
|
||||
mediaToDelete
|
||||
});
|
||||
|
||||
onFindUpdated(new CustomEvent('findUpdated', { detail: { reload: true } }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error updating find:', error);
|
||||
alert('Failed to update find. Please try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!showDeleteConfirm) {
|
||||
showDeleteConfirm = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
await apiSync.deleteFind(find.id);
|
||||
|
||||
if (onFindDeleted) {
|
||||
onFindDeleted(new CustomEvent('findDeleted', { detail: { findId: find.id } }));
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error deleting find:', error);
|
||||
alert('Failed to delete find. Please try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
showDeleteConfirm = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlaceSelected(place: PlaceResult) {
|
||||
locationName = place.name;
|
||||
latitude = place.latitude.toString();
|
||||
longitude = place.longitude.toString();
|
||||
}
|
||||
|
||||
function toggleLocationMode() {
|
||||
useManualLocation = !useManualLocation;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showDeleteConfirm = false;
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Edit Find</h2>
|
||||
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="form"
|
||||
>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<Label for="title">What did you find?</Label>
|
||||
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label for="description">Tell us about it</Label>
|
||||
<textarea
|
||||
name="description"
|
||||
placeholder="The best cappuccino in town..."
|
||||
maxlength="500"
|
||||
bind:value={description}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="location-section">
|
||||
<div class="location-header">
|
||||
<Label>Location</Label>
|
||||
<button type="button" onclick={toggleLocationMode} class="toggle-button">
|
||||
{useManualLocation ? 'Use Search' : 'Manual Entry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if useManualLocation}
|
||||
<div class="field">
|
||||
<Label for="location-name">Location name</Label>
|
||||
<Input
|
||||
name="location-name"
|
||||
placeholder="Café Central, Brussels"
|
||||
bind:value={locationName}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<POISearch
|
||||
onPlaceSelected={handlePlaceSelected}
|
||||
placeholder="Search for cafés, restaurants, landmarks..."
|
||||
label=""
|
||||
showNearbyButton={true}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<div class="field">
|
||||
<Label for="category">Category</Label>
|
||||
<select name="category" bind:value={category} class="select">
|
||||
{#each categories as cat (cat.value)}
|
||||
<option value={cat.value}>{cat.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label>Privacy</Label>
|
||||
<label class="privacy-toggle">
|
||||
<input type="checkbox" bind:checked={isPublic} />
|
||||
<span>{isPublic ? 'Public' : 'Private'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if existingMedia.length > 0}
|
||||
<div class="field">
|
||||
<Label>Current Media</Label>
|
||||
<div class="existing-media-grid">
|
||||
{#each existingMedia as mediaItem (mediaItem.id)}
|
||||
<div class="media-item">
|
||||
{#if mediaItem.type === 'photo'}
|
||||
<img
|
||||
src={mediaItem.thumbnailUrl || mediaItem.url}
|
||||
alt="Media"
|
||||
class="media-thumbnail"
|
||||
/>
|
||||
{:else}
|
||||
<video src={mediaItem.url} class="media-thumbnail" muted>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="remove-media-button"
|
||||
onclick={() => removeExistingMedia(mediaItem.id)}
|
||||
aria-label="Remove media"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<Label for="media-files">Add new photo or video</Label>
|
||||
<div class="file-upload">
|
||||
<input
|
||||
id="media-files"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
||||
onchange={handleFileChange}
|
||||
class="file-input"
|
||||
/>
|
||||
<div class="file-content">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 4V10H20"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>Click to upload</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedFiles && selectedFiles.length > 0}
|
||||
<div class="file-selected">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 4V10H20"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{selectedFiles[0].name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if useManualLocation || (!latitude && !longitude)}
|
||||
<div class="field-group">
|
||||
<div class="field">
|
||||
<Label for="latitude">Latitude</Label>
|
||||
<Input name="latitude" type="text" required bind:value={latitude} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label for="longitude">Longitude</Label>
|
||||
<Input name="longitude" type="text" required bind:value={longitude} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if latitude && longitude}
|
||||
<div class="coordinates-display">
|
||||
<Label>Selected coordinates</Label>
|
||||
<div class="coordinates-info">
|
||||
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
|
||||
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (useManualLocation = true)}
|
||||
class="edit-coords-button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
disabled={isSubmitting}
|
||||
class="delete-button"
|
||||
>
|
||||
{showDeleteConfirm ? 'Confirm Delete?' : 'Delete Find'}
|
||||
</Button>
|
||||
<div class="action-buttons">
|
||||
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !title.trim()}>
|
||||
{isSubmitting ? 'Updating...' : 'Update Find'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.form {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 100px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||
}
|
||||
|
||||
.privacy-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.privacy-toggle:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.privacy-toggle input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.existing-media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.media-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.remove-media-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.remove-media-button:hover {
|
||||
background: rgba(255, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.file-upload {
|
||||
position: relative;
|
||||
border: 2px dashed hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-upload:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.file-content span {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.file-selected svg {
|
||||
color: hsl(var(--primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-selected span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.action-buttons :global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:global(.delete-button) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.location-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.location-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.coordinates-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.coordinate {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.edit-coords-button {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-coords-button:hover {
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.delete-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Badge } from '$lib/components/badge';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
|
||||
import { MoreHorizontal, MessageCircle, Share } from '@lucide/svelte';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator
|
||||
} from '$lib/components/dropdown-menu';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
import VideoPlayer from '../media/VideoPlayer.svelte';
|
||||
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||
import CommentsList from './CommentsList.svelte';
|
||||
import { Ellipsis, MessageCircle, Share, Edit, Trash2 } from '@lucide/svelte';
|
||||
import { apiSync } from '$lib/stores/api-sync';
|
||||
|
||||
interface FindCardProps {
|
||||
id: string;
|
||||
@@ -12,18 +21,29 @@
|
||||
description?: string;
|
||||
category?: string;
|
||||
locationName?: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
isPublic?: number;
|
||||
userId?: string;
|
||||
user: {
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
media?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
orderIndex?: number | null;
|
||||
}>;
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
commentCount?: number;
|
||||
currentUserId?: string;
|
||||
onExplore?: (id: string) => void;
|
||||
onDeleted?: () => void;
|
||||
onUpdated?: () => void;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -32,19 +52,83 @@
|
||||
description,
|
||||
category,
|
||||
locationName,
|
||||
latitude: _latitude,
|
||||
longitude: _longitude,
|
||||
isPublic: _isPublic,
|
||||
userId,
|
||||
user,
|
||||
media,
|
||||
likeCount = 0,
|
||||
isLiked = false,
|
||||
onExplore
|
||||
commentCount = 0,
|
||||
currentUserId,
|
||||
onExplore,
|
||||
onDeleted,
|
||||
onUpdated: _onUpdated,
|
||||
onEdit
|
||||
}: FindCardProps = $props();
|
||||
|
||||
let showComments = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
const isOwner = $derived(currentUserId && userId && currentUserId === userId);
|
||||
|
||||
function handleExplore() {
|
||||
onExplore?.(id);
|
||||
}
|
||||
|
||||
function getUserInitials(username: string): string {
|
||||
return username.slice(0, 2).toUpperCase();
|
||||
function toggleComments() {
|
||||
showComments = !showComments;
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
const url = `${window.location.origin}/finds/${id}`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
.share({
|
||||
title: title,
|
||||
text: description || `Check out this find: ${title}`,
|
||||
url: url
|
||||
})
|
||||
.catch((error) => {
|
||||
// User cancelled or error occurred
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error sharing:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback: Copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
alert('Find URL copied to clipboard!');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
onEdit?.();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Are you sure you want to delete this find? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting = true;
|
||||
try {
|
||||
await apiSync.deleteFind(id);
|
||||
onDeleted?.();
|
||||
} catch (error) {
|
||||
console.error('Error deleting find:', error);
|
||||
alert('Failed to delete find. Please try again.');
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,14 +136,11 @@
|
||||
<!-- Post Header -->
|
||||
<div class="post-header">
|
||||
<div class="user-info">
|
||||
<Avatar class="avatar">
|
||||
{#if user.profilePictureUrl}
|
||||
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
|
||||
{/if}
|
||||
<AvatarFallback class="avatar-fallback">
|
||||
{getUserInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={user.username}
|
||||
profilePictureUrl={user.profilePictureUrl}
|
||||
class="avatar"
|
||||
/>
|
||||
<div class="user-details">
|
||||
<div class="username">@{user.username}</div>
|
||||
{#if locationName}
|
||||
@@ -79,9 +160,24 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="more-button">
|
||||
<MoreHorizontal size={16} />
|
||||
</Button>
|
||||
{#if isOwner}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="more-button-trigger">
|
||||
<Ellipsis size={16} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onclick={handleEdit}>
|
||||
<Edit size={16} />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onclick={handleDelete} disabled={isDeleting} class="text-destructive">
|
||||
<Trash2 size={16} />
|
||||
<span>{isDeleting ? 'Deleting...' : 'Delete'}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
@@ -101,8 +197,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div class="post-media">
|
||||
{#if media && media.length > 0}
|
||||
{#if media && media.length > 0}
|
||||
<div class="post-media">
|
||||
{#if media[0].type === 'photo'}
|
||||
<img
|
||||
src={media[0].thumbnailUrl || media[0].url}
|
||||
@@ -120,31 +216,18 @@
|
||||
class="media-video"
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="no-media">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Post Actions -->
|
||||
<div class="post-actions">
|
||||
<div class="action-buttons">
|
||||
<LikeButton findId={id} {isLiked} {likeCount} size="sm" />
|
||||
<Button variant="ghost" size="sm" class="action-button">
|
||||
<Button variant="ghost" size="sm" class="action-button" onclick={toggleComments}>
|
||||
<MessageCircle size={16} />
|
||||
<span>comment</span>
|
||||
<span>{commentCount || 'comment'}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="action-button">
|
||||
<Button variant="ghost" size="sm" class="action-button" onclick={handleShare}>
|
||||
<Share size={16} />
|
||||
<span>share</span>
|
||||
</Button>
|
||||
@@ -153,20 +236,26 @@
|
||||
explore
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
{#if showComments}
|
||||
<div class="comments-section">
|
||||
<CommentsList
|
||||
findId={id}
|
||||
{currentUserId}
|
||||
collapsed={true}
|
||||
maxComments={5}
|
||||
showCommentForm={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.find-card {
|
||||
background: white;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.find-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Post Header */
|
||||
@@ -219,6 +308,33 @@
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
:global(.more-button-trigger) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.more-button-trigger:hover) {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
:global(.text-destructive) {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
:global(.text-destructive:hover) {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* Post Content */
|
||||
.post-content {
|
||||
padding: 0 1rem 0.75rem 1rem;
|
||||
@@ -258,33 +374,23 @@
|
||||
/* Media */
|
||||
.post-media {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/10;
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--muted));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
max-height: 600px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:global(.media-video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-media {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0.5;
|
||||
height: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
/* Post Actions */
|
||||
@@ -300,12 +406,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.action-button) {
|
||||
gap: 0.375rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.action-button:hover) {
|
||||
@@ -317,6 +427,13 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Comments Section */
|
||||
.comments-section {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.post-actions {
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
import VideoPlayer from '../media/VideoPlayer.svelte';
|
||||
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||
import CommentsList from './CommentsList.svelte';
|
||||
|
||||
interface Find {
|
||||
id: string;
|
||||
@@ -30,11 +30,11 @@
|
||||
interface Props {
|
||||
find: Find | null;
|
||||
onClose: () => void;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
||||
let { find, onClose }: Props = $props();
|
||||
let { find, onClose, currentUserId }: Props = $props();
|
||||
|
||||
let showModal = $state(true);
|
||||
let currentMediaIndex = $state(0);
|
||||
let isMobile = $state(false);
|
||||
|
||||
@@ -52,13 +52,6 @@
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
// Close modal when showModal changes to false
|
||||
$effect(() => {
|
||||
if (!showModal) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
function nextMedia() {
|
||||
if (!find?.media) return;
|
||||
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
|
||||
@@ -95,33 +88,44 @@
|
||||
const url = `${window.location.origin}/finds/${find.id}`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: find.title,
|
||||
text: find.description || `Check out this find: ${find.title}`,
|
||||
url: url
|
||||
});
|
||||
navigator
|
||||
.share({
|
||||
title: find.title,
|
||||
text: find.description || `Check out this find: ${find.title}`,
|
||||
url: url
|
||||
})
|
||||
.catch((error) => {
|
||||
// User cancelled or error occurred
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error sharing:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
navigator.clipboard.writeText(url);
|
||||
alert('Find URL copied to clipboard!');
|
||||
// Fallback: Copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
alert('Find URL copied to clipboard!');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if find}
|
||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
|
||||
<SheetHeader class="sheet-header">
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="user-section">
|
||||
<Avatar class="user-avatar">
|
||||
{#if find.user.profilePictureUrl}
|
||||
<AvatarImage src={find.user.profilePictureUrl} alt={find.user.username} />
|
||||
{/if}
|
||||
<AvatarFallback class="avatar-fallback">
|
||||
{find.user.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={find.user.username}
|
||||
profilePictureUrl={find.user.profilePictureUrl}
|
||||
class="user-avatar"
|
||||
/>
|
||||
<div class="user-info">
|
||||
<SheetTitle class="find-title">{find.title}</SheetTitle>
|
||||
<h2 class="find-title">{find.title}</h2>
|
||||
<div class="find-meta">
|
||||
<span class="username">@{find.user.username}</span>
|
||||
<span class="separator">•</span>
|
||||
@@ -133,9 +137,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-body">
|
||||
<div class="modal-body">
|
||||
{#if find.media && find.media.length > 0}
|
||||
<div class="media-container">
|
||||
<div class="media-viewer">
|
||||
@@ -254,38 +269,90 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comments-section">
|
||||
<CommentsList
|
||||
findId={find.id}
|
||||
{currentUserId}
|
||||
collapsed={false}
|
||||
isScrollable={true}
|
||||
showCommentForm={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Base styles for sheet content */
|
||||
:global(.sheet-content) {
|
||||
padding: 0 !important;
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: fit-content;
|
||||
max-width: 600px;
|
||||
min-width: 400px;
|
||||
max-height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Desktop styles (side sheet) */
|
||||
@media (min-width: 768px) {
|
||||
:global(.sheet-content) {
|
||||
width: 80vw !important;
|
||||
max-width: 600px !important;
|
||||
height: 100vh !important;
|
||||
border-radius: 0 !important;
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile styles (bottom sheet) */
|
||||
@media (max-width: 767px) {
|
||||
:global(.sheet-content) {
|
||||
height: 80vh !important;
|
||||
border-radius: 16px 16px 0 0 !important;
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
max-height: calc(90vh - 20px);
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.sheet-header) {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -293,7 +360,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.user-avatar) {
|
||||
@@ -314,13 +382,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.find-title) {
|
||||
font-family: 'Washington', serif !important;
|
||||
font-size: 1.25rem !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 0 !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
line-height: 1.3 !important;
|
||||
.find-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.find-meta {
|
||||
@@ -351,70 +419,55 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.sheet-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.media-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-viewer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
background: hsl(var(--muted));
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Desktop media viewer - maximize available space */
|
||||
@media (min-width: 768px) {
|
||||
.sheet-body {
|
||||
height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.media-container {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile media viewer - maximize available space */
|
||||
@media (max-width: 767px) {
|
||||
.sheet-body {
|
||||
height: calc(80vh - 140px);
|
||||
}
|
||||
|
||||
.media-container {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
flex: 1;
|
||||
min-height: 140px;
|
||||
max-height: 250px;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:global(.media-video) {
|
||||
@@ -479,9 +532,9 @@
|
||||
|
||||
.content-section {
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.description {
|
||||
@@ -497,7 +550,6 @@
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -545,9 +597,18 @@
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 640px) {
|
||||
:global(.sheet-header) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@@ -560,21 +621,12 @@
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
:global(.find-title) {
|
||||
font-size: 1.125rem !important;
|
||||
.find-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,10 @@
|
||||
description?: string;
|
||||
category?: string;
|
||||
locationName?: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
isPublic?: number;
|
||||
userId?: string;
|
||||
user: {
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
@@ -14,26 +18,34 @@
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
media?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
orderIndex?: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FindsListProps {
|
||||
finds: Find[];
|
||||
onFindExplore?: (id: string) => void;
|
||||
currentUserId?: string;
|
||||
onEdit?: (find: Find) => void;
|
||||
title?: string;
|
||||
showEmpty?: boolean;
|
||||
emptyMessage?: string;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
finds,
|
||||
onFindExplore,
|
||||
currentUserId,
|
||||
onEdit,
|
||||
title = 'Finds',
|
||||
showEmpty = true,
|
||||
emptyMessage = 'No finds to display'
|
||||
emptyMessage = 'No finds to display',
|
||||
hideTitle = false
|
||||
}: FindsListProps = $props();
|
||||
|
||||
function handleFindExplore(id: string) {
|
||||
@@ -42,9 +54,11 @@
|
||||
</script>
|
||||
|
||||
<section class="finds-feed">
|
||||
<div class="feed-header">
|
||||
<h2 class="feed-title">{title}</h2>
|
||||
</div>
|
||||
{#if !hideTitle}
|
||||
<div class="feed-header">
|
||||
<h2 class="feed-title">{title}</h2>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if finds.length > 0}
|
||||
<div class="feed-container">
|
||||
@@ -55,11 +69,17 @@
|
||||
description={find.description}
|
||||
category={find.category}
|
||||
locationName={find.locationName}
|
||||
latitude={find.latitude}
|
||||
longitude={find.longitude}
|
||||
isPublic={find.isPublic}
|
||||
userId={find.userId}
|
||||
user={find.user}
|
||||
media={find.media}
|
||||
likeCount={find.likeCount}
|
||||
isLiked={find.isLiked}
|
||||
{currentUserId}
|
||||
onExplore={handleFindExplore}
|
||||
onEdit={() => onEdit?.(find)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -98,6 +118,7 @@
|
||||
<style>
|
||||
.finds-feed {
|
||||
width: 100%;
|
||||
padding: 0 24px 24px 24px;
|
||||
}
|
||||
|
||||
.feed-header {
|
||||
119
src/lib/components/finds/LikeButton.svelte
Normal file
119
src/lib/components/finds/LikeButton.svelte
Normal file
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Heart } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
interface Props {
|
||||
findId: string;
|
||||
isLiked?: boolean;
|
||||
likeCount?: number;
|
||||
size?: 'sm' | 'default' | 'lg';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
findId,
|
||||
isLiked = false,
|
||||
likeCount = 0,
|
||||
size = 'default',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
// Local state stores for this like button - start with props but will be overridden by global state
|
||||
const likeState = writable({
|
||||
isLiked: isLiked,
|
||||
likeCount: likeCount,
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
let apiSync: typeof import('$lib/stores/api-sync').apiSync | null = null;
|
||||
|
||||
// Initialize API sync and subscribe to global state
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
try {
|
||||
// Dynamically import the API sync
|
||||
const module = await import('$lib/stores/api-sync');
|
||||
apiSync = module.apiSync;
|
||||
|
||||
// Check if global state already exists for this find
|
||||
const existingState = apiSync.getEntityState('find', findId);
|
||||
|
||||
if (existingState) {
|
||||
// Use existing global state - it's more current than props
|
||||
console.log(`Using existing global state for find ${findId}`, existingState);
|
||||
} else {
|
||||
// Initialize with minimal data only if no global state exists
|
||||
console.log(`Initializing new minimal state for find ${findId}`);
|
||||
apiSync.setEntityState('find', findId, {
|
||||
id: findId,
|
||||
isLikedByUser: isLiked,
|
||||
likeCount: likeCount,
|
||||
// Minimal data needed for like functionality
|
||||
title: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
isPublic: true,
|
||||
createdAt: new Date(),
|
||||
userId: '',
|
||||
username: '',
|
||||
isFromFriend: false
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to global state for this find
|
||||
const globalLikeState = apiSync.subscribeFindLikes(findId);
|
||||
globalLikeState.subscribe(
|
||||
(state: { isLiked: boolean; likeCount: number; isLoading: boolean }) => {
|
||||
likeState.set({
|
||||
isLiked: state.isLiked,
|
||||
likeCount: state.likeCount,
|
||||
isLoading: state.isLoading
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize API sync:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function toggleLike() {
|
||||
if (!apiSync || !browser) return;
|
||||
|
||||
if ($likeState.isLoading) return;
|
||||
|
||||
try {
|
||||
await apiSync.toggleLike(findId);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle like:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
{size}
|
||||
class="group gap-1.5 {className}"
|
||||
onclick={toggleLike}
|
||||
disabled={$likeState.isLoading}
|
||||
>
|
||||
<Heart
|
||||
class="h-4 w-4 transition-all duration-200 {$likeState.isLiked
|
||||
? 'scale-110 fill-red-500 text-red-500'
|
||||
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {$likeState.isLoading
|
||||
? 'animate-pulse'
|
||||
: ''}"
|
||||
/>
|
||||
{#if $likeState.likeCount > 0}
|
||||
<span
|
||||
class="text-sm font-medium transition-colors {$likeState.isLiked
|
||||
? 'text-red-500'
|
||||
: 'text-gray-500 group-hover:text-red-400'}"
|
||||
>
|
||||
{$likeState.likeCount}
|
||||
</span>
|
||||
{/if}
|
||||
</Button>
|
||||
11
src/lib/components/finds/index.ts
Normal file
11
src/lib/components/finds/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { default as Comment } from './Comment.svelte';
|
||||
export { default as CommentForm } from './CommentForm.svelte';
|
||||
export { default as CommentsList } from './CommentsList.svelte';
|
||||
export { default as Comments } from './Comments.svelte';
|
||||
export { default as CreateFindModal } from './CreateFindModal.svelte';
|
||||
export { default as EditFindModal } from './EditFindModal.svelte';
|
||||
export { default as FindCard } from './FindCard.svelte';
|
||||
export { default as FindPreview } from './FindPreview.svelte';
|
||||
export { default as FindsFilter } from './FindsFilter.svelte';
|
||||
export { default as FindsList } from './FindsList.svelte';
|
||||
export { default as LikeButton } from './LikeButton.svelte';
|
||||
457
src/lib/components/locations/CreateLocationModal.svelte
Normal file
457
src/lib/components/locations/CreateLocationModal.svelte
Normal file
@@ -0,0 +1,457 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Label } from '$lib/components/label';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { coordinates } from '$lib/stores/location';
|
||||
import POISearch from '../map/POISearch.svelte';
|
||||
import type { PlaceResult } from '$lib/utils/places';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onLocationCreated: (event: CustomEvent<{ locationId: string; reload?: boolean }>) => void;
|
||||
}
|
||||
|
||||
let { isOpen, onClose, onLocationCreated }: Props = $props();
|
||||
|
||||
let latitude = $state('');
|
||||
let longitude = $state('');
|
||||
let locationName = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let useManualLocation = $state(false);
|
||||
|
||||
let isMobile = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const checkIsMobile = () => {
|
||||
isMobile = window.innerWidth < 768;
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener('resize', checkIsMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen && $coordinates) {
|
||||
latitude = $coordinates.latitude.toString();
|
||||
longitude = $coordinates.longitude.toString();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const lat = parseFloat(latitude);
|
||||
const lng = parseFloat(longitude);
|
||||
|
||||
if (isNaN(lat) || isNaN(lng)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/locations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
locationName: locationName.trim() || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create location');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
resetForm();
|
||||
onLocationCreated(
|
||||
new CustomEvent('locationCreated', {
|
||||
detail: { locationId: result.location.id, reload: true }
|
||||
})
|
||||
);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating location:', error);
|
||||
alert('Failed to create location. Please try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlaceSelected(place: PlaceResult) {
|
||||
locationName = place.name;
|
||||
latitude = place.latitude.toString();
|
||||
longitude = place.longitude.toString();
|
||||
}
|
||||
|
||||
function toggleLocationMode() {
|
||||
useManualLocation = !useManualLocation;
|
||||
if (!useManualLocation && $coordinates) {
|
||||
latitude = $coordinates.latitude.toString();
|
||||
longitude = $coordinates.longitude.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
latitude = '';
|
||||
longitude = '';
|
||||
locationName = '';
|
||||
useManualLocation = false;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Create Location</h2>
|
||||
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="form"
|
||||
>
|
||||
<div class="modal-body">
|
||||
<p class="description">
|
||||
Choose a location where you and others can create finds (posts). This will be a point on
|
||||
the map where discoveries can be shared.
|
||||
</p>
|
||||
|
||||
<div class="location-section">
|
||||
<div class="location-header">
|
||||
<Label>Location</Label>
|
||||
<button type="button" onclick={toggleLocationMode} class="toggle-button">
|
||||
{useManualLocation ? 'Use Search' : 'Manual Entry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !useManualLocation}
|
||||
<POISearch
|
||||
onPlaceSelected={handlePlaceSelected}
|
||||
placeholder="Search for a place..."
|
||||
label=""
|
||||
showNearbyButton={true}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label for="location-name">Location Name (Optional)</Label>
|
||||
<Input
|
||||
name="location-name"
|
||||
type="text"
|
||||
placeholder="Café Central, Brussels"
|
||||
bind:value={locationName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if useManualLocation || (!latitude && !longitude)}
|
||||
<div class="field-group">
|
||||
<div class="field">
|
||||
<Label for="latitude">Latitude</Label>
|
||||
<Input name="latitude" type="text" required bind:value={latitude} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label for="longitude">Longitude</Label>
|
||||
<Input name="longitude" type="text" required bind:value={longitude} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if latitude && longitude}
|
||||
<div class="coordinates-display">
|
||||
<Label>Selected coordinates</Label>
|
||||
<div class="coordinates-info">
|
||||
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
|
||||
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (useManualLocation = true)}
|
||||
class="edit-coords-button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !latitude || !longitude}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Location'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
max-height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
max-height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.form {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-footer :global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.location-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.location-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.coordinates-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.coordinate {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.edit-coords-button {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-coords-button:hover {
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
236
src/lib/components/locations/LocationCard.svelte
Normal file
236
src/lib/components/locations/LocationCard.svelte
Normal file
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import { formatDistance } from '$lib/utils/distance';
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
findCount: number;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
location: Location;
|
||||
onExplore?: (id: string) => void;
|
||||
}
|
||||
|
||||
let { location, onExplore }: Props = $props();
|
||||
|
||||
function handleExplore() {
|
||||
onExplore?.(location.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="location-card">
|
||||
<div class="location-info">
|
||||
<div class="location-header">
|
||||
<div class="location-title">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="location-icon">
|
||||
<path
|
||||
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="title">
|
||||
{#if location.distance !== undefined}
|
||||
{formatDistance(location.distance)} away
|
||||
{:else}
|
||||
Location
|
||||
{/if}
|
||||
</h3>
|
||||
<p class="coordinates">
|
||||
{parseFloat(location.latitude).toFixed(4)}, {parseFloat(location.longitude).toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if location.distance !== undefined}
|
||||
<div class="distance-badge">{formatDistance(location.distance)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="location-meta">
|
||||
<div class="meta-item">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2" />
|
||||
<path
|
||||
d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>Created by {location.username}</span>
|
||||
</div>
|
||||
|
||||
<div class="meta-item">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{location.findCount} {location.findCount === 1 ? 'find' : 'finds'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="explore-button" onclick={handleExplore}>
|
||||
<span>Explore</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M5 12h14M12 5l7 7-7 7"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.location-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.location-card:hover {
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.location-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.location-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.location-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
color: hsl(var(--primary));
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.coordinates {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.distance-badge {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.location-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.meta-item svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explore-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explore-button:hover {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.explore-button svg {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.explore-button:hover svg {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.location-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.explore-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.distance-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
391
src/lib/components/locations/LocationFindsModal.svelte
Normal file
391
src/lib/components/locations/LocationFindsModal.svelte
Normal file
@@ -0,0 +1,391 @@
|
||||
<script lang="ts">
|
||||
import FindsList from '../finds/FindsList.svelte';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { goto } from '$app/navigation';
|
||||
import EditFindModal from '../finds/EditFindModal.svelte';
|
||||
|
||||
interface Find {
|
||||
id: string;
|
||||
locationId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
locationName?: string;
|
||||
isPublic: number;
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
likeCount?: number;
|
||||
isLikedByUser?: boolean;
|
||||
isFromFriend?: boolean;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
media?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
orderIndex?: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
findCount: number;
|
||||
finds?: Find[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
location: Location | null;
|
||||
currentUserId?: string;
|
||||
onClose: () => void;
|
||||
onCreateFind?: () => void;
|
||||
}
|
||||
|
||||
let { isOpen, location, currentUserId, onClose, onCreateFind }: Props = $props();
|
||||
|
||||
let isMobile = $state(false);
|
||||
let showEditModal = $state(false);
|
||||
let findToEdit = $state<Find | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const checkIsMobile = () => {
|
||||
isMobile = window.innerWidth < 768;
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener('resize', checkIsMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
function handleCreateFind() {
|
||||
onCreateFind?.();
|
||||
}
|
||||
|
||||
function handleFindExplore(findId: string) {
|
||||
goto(`/finds/${findId}`);
|
||||
}
|
||||
|
||||
function handleFindEdit(findData: any) {
|
||||
const find = location?.finds?.find((f) => f.id === findData.id);
|
||||
if (find) {
|
||||
findToEdit = find;
|
||||
showEditModal = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditModalClose() {
|
||||
showEditModal = false;
|
||||
findToEdit = null;
|
||||
}
|
||||
|
||||
function handleFindUpdated() {
|
||||
showEditModal = false;
|
||||
findToEdit = null;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function handleFindDeleted() {
|
||||
showEditModal = false;
|
||||
findToEdit = null;
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen && location}
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="header-info">
|
||||
<h2 class="modal-title">Location Finds</h2>
|
||||
<p class="location-coords">
|
||||
{parseFloat(location.latitude).toFixed(4)}, {parseFloat(location.longitude).toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
{#if location.finds && location.finds.length > 0}
|
||||
<FindsList
|
||||
finds={location.finds.map((find) => ({
|
||||
id: find.id,
|
||||
locationId: find.locationId,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
category: find.category,
|
||||
locationName: find.locationName,
|
||||
latitude: find.latitude,
|
||||
longitude: find.longitude,
|
||||
isPublic: find.isPublic,
|
||||
userId: find.userId,
|
||||
username: find.username,
|
||||
profilePictureUrl: find.profilePictureUrl,
|
||||
user: {
|
||||
username: find.username,
|
||||
profilePictureUrl: find.profilePictureUrl
|
||||
},
|
||||
likeCount: find.likeCount,
|
||||
isLiked: find.isLikedByUser,
|
||||
media: find.media
|
||||
}))}
|
||||
hideTitle={true}
|
||||
{currentUserId}
|
||||
onFindExplore={handleFindExplore}
|
||||
onEdit={handleFindEdit}
|
||||
/>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
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>
|
||||
</div>
|
||||
<h3 class="empty-title">No finds yet</h3>
|
||||
<p class="empty-message">Be the first to share a discovery at this location!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if currentUserId}
|
||||
<div class="modal-footer">
|
||||
<Button onclick={handleCreateFind} class="w-full">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
Create Find Here
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showEditModal && findToEdit}
|
||||
<EditFindModal
|
||||
isOpen={showEditModal}
|
||||
find={{
|
||||
id: findToEdit.id,
|
||||
title: findToEdit.title,
|
||||
description: findToEdit.description || null,
|
||||
latitude: findToEdit.latitude || location?.latitude || '0',
|
||||
longitude: findToEdit.longitude || location?.longitude || '0',
|
||||
locationName: findToEdit.locationName || null,
|
||||
category: findToEdit.category || null,
|
||||
isPublic: findToEdit.isPublic ?? 1,
|
||||
media: (findToEdit.media || []).map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || null,
|
||||
orderIndex: m.orderIndex ?? null
|
||||
}))
|
||||
}}
|
||||
onClose={handleEditModalClose}
|
||||
onFindUpdated={handleFindUpdated}
|
||||
onFindDeleted={handleFindDeleted}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.location-coords {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 1.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.w-full) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.mr-2) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
188
src/lib/components/locations/LocationsList.svelte
Normal file
188
src/lib/components/locations/LocationsList.svelte
Normal file
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import LocationCard from './LocationCard.svelte';
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
findCount: number;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
locations: Location[];
|
||||
onLocationExplore?: (id: string) => void;
|
||||
title?: string;
|
||||
showEmpty?: boolean;
|
||||
emptyMessage?: string;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
locations,
|
||||
onLocationExplore,
|
||||
title = 'Locations',
|
||||
showEmpty = true,
|
||||
emptyMessage = 'No locations nearby',
|
||||
hideTitle = false
|
||||
}: Props = $props();
|
||||
|
||||
function handleLocationExplore(id: string) {
|
||||
onLocationExplore?.(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="locations-feed">
|
||||
{#if !hideTitle}
|
||||
<div class="feed-header">
|
||||
<h2 class="feed-title">{title}</h2>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if locations.length > 0}
|
||||
<div class="feed-container">
|
||||
{#each locations as location (location.id)}
|
||||
<LocationCard {location} onExplore={handleLocationExplore} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if showEmpty}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
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>
|
||||
</div>
|
||||
<h3 class="empty-title">No locations discovered yet</h3>
|
||||
<p class="empty-message">{emptyMessage}</p>
|
||||
<div class="empty-action">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>Create a location to start sharing finds</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.locations-feed {
|
||||
width: 100%;
|
||||
padding: 0 24px 24px 24px;
|
||||
}
|
||||
|
||||
.feed-header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.feed-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
background: hsl(var(--card));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 1.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0 0 1.5rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--primary));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.feed-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.feed-container {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
:global(.location-card) {
|
||||
animation: fadeInUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1077
src/lib/components/locations/SelectLocationModal.svelte
Normal file
1077
src/lib/components/locations/SelectLocationModal.svelte
Normal file
File diff suppressed because it is too large
Load Diff
5
src/lib/components/locations/index.ts
Normal file
5
src/lib/components/locations/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as LocationCard } from './LocationCard.svelte';
|
||||
export { default as LocationsList } from './LocationsList.svelte';
|
||||
export { default as CreateLocationModal } from './CreateLocationModal.svelte';
|
||||
export { default as SelectLocationModal } from './SelectLocationModal.svelte';
|
||||
export { default as LocationFindsModal } from './LocationFindsModal.svelte';
|
||||
81
src/lib/components/map/LocationManager.svelte
Normal file
81
src/lib/components/map/LocationManager.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { locationActions, isWatching, coordinates } from '$lib/stores/location';
|
||||
|
||||
interface Props {
|
||||
autoStart?: boolean;
|
||||
enableHighAccuracy?: boolean;
|
||||
timeout?: number;
|
||||
maximumAge?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
autoStart = true,
|
||||
enableHighAccuracy = true,
|
||||
timeout = 15000,
|
||||
maximumAge = 60000
|
||||
}: Props = $props();
|
||||
|
||||
// Location watching options
|
||||
const watchOptions = {
|
||||
enableHighAccuracy,
|
||||
timeout,
|
||||
maximumAge
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (!browser || !autoStart) return;
|
||||
|
||||
// Check if geolocation is supported
|
||||
if (!navigator.geolocation) {
|
||||
console.warn('Geolocation is not supported by this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we already have coordinates and aren't watching
|
||||
if ($coordinates && !$isWatching) {
|
||||
// Start watching immediately if we have previous coordinates
|
||||
startLocationWatching();
|
||||
return;
|
||||
}
|
||||
|
||||
// If no coordinates, try to get current location first
|
||||
if (!$coordinates) {
|
||||
getCurrentLocationThenWatch();
|
||||
}
|
||||
});
|
||||
|
||||
async function getCurrentLocationThenWatch() {
|
||||
try {
|
||||
const result = await locationActions.getCurrentLocation(watchOptions);
|
||||
if (result) {
|
||||
// Successfully got location, now start watching
|
||||
startLocationWatching();
|
||||
}
|
||||
} catch {
|
||||
// If we can't get location due to permissions, don't auto-start watching
|
||||
console.log('Could not get initial location, location watching not started automatically');
|
||||
}
|
||||
}
|
||||
|
||||
function startLocationWatching() {
|
||||
if (!$isWatching) {
|
||||
locationActions.startWatching(watchOptions);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function to stop watching when component is destroyed
|
||||
function cleanup() {
|
||||
if ($isWatching) {
|
||||
locationActions.stopWatching();
|
||||
}
|
||||
}
|
||||
|
||||
// Stop watching when the component is destroyed
|
||||
onMount(() => {
|
||||
return cleanup;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- This component doesn't render anything, it just manages location watching -->
|
||||
597
src/lib/components/map/Map.svelte
Normal file
597
src/lib/components/map/Map.svelte
Normal file
@@ -0,0 +1,597 @@
|
||||
<script lang="ts">
|
||||
import { MapLibre, Marker } from 'svelte-maplibre';
|
||||
import type { StyleSpecification } from 'svelte-maplibre';
|
||||
import { untrack } from 'svelte';
|
||||
import {
|
||||
coordinates,
|
||||
getMapCenter,
|
||||
getMapZoom,
|
||||
shouldZoomToLocation,
|
||||
locationActions,
|
||||
isWatching
|
||||
} from '$lib/stores/location';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
finds: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
isPublic: number;
|
||||
media?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
style?: StyleSpecification;
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
class?: string;
|
||||
autoCenter?: boolean;
|
||||
locations?: Location[];
|
||||
onLocationClick?: (location: Location) => void;
|
||||
sidebarVisible?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
style = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'osm-raster': {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-tiles',
|
||||
type: 'raster',
|
||||
source: 'osm-raster'
|
||||
}
|
||||
]
|
||||
},
|
||||
center,
|
||||
zoom,
|
||||
class: className = '',
|
||||
autoCenter = true,
|
||||
locations = [],
|
||||
onLocationClick,
|
||||
sidebarVisible = false
|
||||
}: Props = $props();
|
||||
|
||||
let mapLoaded = $state(false);
|
||||
let styleLoaded = $state(false);
|
||||
let isIdle = $state(false);
|
||||
let mapInstance: any = $state(null);
|
||||
let userHasMovedMap = $state(false);
|
||||
let initialCenter: [number, number] = center || [0, 51.505];
|
||||
let initialZoom: number = zoom || 13;
|
||||
|
||||
// Use a plain variable (not reactive) to track programmatic moves
|
||||
let isProgrammaticMove = false;
|
||||
|
||||
// Calculate padding for map centering based on sidebar visibility
|
||||
const getMapPadding = $derived.by(() => {
|
||||
if (!sidebarVisible) {
|
||||
return { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
}
|
||||
|
||||
// Check if we're on mobile (sidebar at bottom) or desktop (sidebar on left)
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768;
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, sidebar is at bottom
|
||||
// Sidebar takes up about 60vh, so add padding at bottom to shift center up
|
||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800;
|
||||
const sidebarHeight = viewportHeight * 0.6;
|
||||
return { top: 0, bottom: sidebarHeight / 2, left: 0, right: 0 };
|
||||
} else {
|
||||
// On desktop, sidebar is on left
|
||||
// Calculate sidebar width: 40% of viewport, max 1000px, min 500px
|
||||
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
|
||||
const sidebarWidth = Math.min(1000, Math.max(500, viewportWidth * 0.4));
|
||||
|
||||
// Add left padding of half sidebar width to shift center to the right
|
||||
// This centers the location in the visible (non-sidebar) area
|
||||
return { top: 0, bottom: 0, left: sidebarWidth / 2, right: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
// Handle comprehensive map loading events
|
||||
function handleStyleLoad() {
|
||||
styleLoaded = true;
|
||||
}
|
||||
|
||||
function handleIdle() {
|
||||
isIdle = true;
|
||||
}
|
||||
|
||||
// Map is considered fully ready when it's loaded, style is loaded, and it's idle
|
||||
const mapReady = $derived(mapLoaded && styleLoaded && isIdle);
|
||||
|
||||
// Check if map is centered on user location (approximately)
|
||||
const isCenteredOnUser = $derived.by(() => {
|
||||
if (!$coordinates || !mapInstance) return false;
|
||||
|
||||
const center = mapInstance.getCenter();
|
||||
const userLng = $coordinates.longitude;
|
||||
const userLat = $coordinates.latitude;
|
||||
|
||||
// Check if within ~100m (roughly 0.001 degrees)
|
||||
const threshold = 0.001;
|
||||
return Math.abs(center.lng - userLng) < threshold && Math.abs(center.lat - userLat) < threshold;
|
||||
});
|
||||
|
||||
// Effect to handle recenter trigger
|
||||
$effect(() => {
|
||||
if ($shouldZoomToLocation && mapInstance && $coordinates) {
|
||||
// Use untrack to avoid tracking getMapZoom changes inside this effect
|
||||
untrack(() => {
|
||||
// Mark this as a programmatic move
|
||||
isProgrammaticMove = true;
|
||||
userHasMovedMap = false;
|
||||
|
||||
// Fly to the user's location with padding based on sidebar
|
||||
mapInstance.flyTo({
|
||||
center: [$coordinates.longitude, $coordinates.latitude],
|
||||
zoom: $getMapZoom,
|
||||
padding: getMapPadding,
|
||||
duration: 1000
|
||||
});
|
||||
|
||||
// Clear the trigger and reset flag after animation
|
||||
setTimeout(() => {
|
||||
locationActions.clearZoomTrigger();
|
||||
isProgrammaticMove = false;
|
||||
}, 1100);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to center on user location when map first loads (if autoCenter is true)
|
||||
let hasInitialCentered = $state(false);
|
||||
$effect(() => {
|
||||
if (autoCenter && mapReady && $coordinates && !hasInitialCentered) {
|
||||
untrack(() => {
|
||||
isProgrammaticMove = true;
|
||||
hasInitialCentered = true;
|
||||
mapInstance.flyTo({
|
||||
center: [$coordinates.longitude, $coordinates.latitude],
|
||||
zoom: $getMapZoom,
|
||||
padding: getMapPadding,
|
||||
duration: 1000
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
isProgrammaticMove = false;
|
||||
}, 1100);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to attach move listener to map instance (only depends on mapInstance)
|
||||
$effect(() => {
|
||||
if (!mapInstance) return;
|
||||
|
||||
const handleMoveEnd = () => {
|
||||
// Only mark as user move if it's not programmatic
|
||||
if (!isProgrammaticMove) {
|
||||
userHasMovedMap = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Use 'moveend' to capture when user finishes moving the map
|
||||
mapInstance.on('moveend', handleMoveEnd);
|
||||
|
||||
return () => {
|
||||
mapInstance.off('moveend', handleMoveEnd);
|
||||
};
|
||||
});
|
||||
|
||||
// Effect to adjust map center when sidebar visibility changes
|
||||
$effect(() => {
|
||||
if (mapInstance && mapReady && $coordinates) {
|
||||
// React to sidebar visibility changes
|
||||
const padding = getMapPadding;
|
||||
|
||||
untrack(() => {
|
||||
isProgrammaticMove = true;
|
||||
|
||||
// Smoothly adjust the map to account for sidebar
|
||||
mapInstance.easeTo({
|
||||
center: [$coordinates.longitude, $coordinates.latitude],
|
||||
padding: padding,
|
||||
duration: 300
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
isProgrammaticMove = false;
|
||||
}, 350);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function recenterMap() {
|
||||
if (!$coordinates) return;
|
||||
|
||||
// Trigger zoom to location
|
||||
locationActions.getCurrentLocation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="map-container {className}">
|
||||
{#if !mapReady}
|
||||
<div class="map-skeleton">
|
||||
<Skeleton class="h-full w-full rounded-xl" />
|
||||
<div class="skeleton-overlay">
|
||||
<Skeleton class="mb-2 h-4 w-16" />
|
||||
<Skeleton class="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="map-wrapper" class:hidden={!mapReady}>
|
||||
<MapLibre
|
||||
{style}
|
||||
center={initialCenter}
|
||||
zoom={initialZoom}
|
||||
bind:map={mapInstance}
|
||||
bind:loaded={mapLoaded}
|
||||
onstyleload={handleStyleLoad}
|
||||
onidle={handleIdle}
|
||||
>
|
||||
{#if $coordinates}
|
||||
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
|
||||
<div class="location-marker" class:watching={$isWatching}>
|
||||
<div class="marker-pulse" class:watching={$isWatching}></div>
|
||||
<div class="marker-outer" class:watching={$isWatching}>
|
||||
<div class="marker-inner" class:watching={$isWatching}></div>
|
||||
</div>
|
||||
{#if $isWatching}
|
||||
<div class="watching-ring"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</Marker>
|
||||
{/if}
|
||||
|
||||
{#each locations as location (location.id)}
|
||||
<Marker lngLat={[parseFloat(location.longitude), parseFloat(location.latitude)]}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="location-pin-marker"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onLocationClick?.(location)}
|
||||
title={`${location.finds.length} find${location.finds.length !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<div class="location-pin-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<circle cx="12" cy="9" r="2.5" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="location-find-count">
|
||||
{location.finds.length}
|
||||
</div>
|
||||
{#if location.finds.length > 0 && location.finds[0].media && location.finds[0].media.length > 0}
|
||||
<div class="location-marker-preview">
|
||||
<img src={location.finds[0].media[0].thumbnailUrl} alt="Preview" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Marker>
|
||||
{/each}
|
||||
</MapLibre>
|
||||
|
||||
<!-- Recenter button - only show when user has moved map and has coordinates -->
|
||||
{#if userHasMovedMap && !isCenteredOnUser && $coordinates}
|
||||
<button
|
||||
class="recenter-button"
|
||||
onclick={recenterMap}
|
||||
type="button"
|
||||
aria-label="Recenter on my location"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-skeleton {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeleton-overlay {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-wrapper.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-container :global(.maplibregl-map) {
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Location marker styles */
|
||||
:global(.location-marker) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.marker-outer) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(37, 99, 235, 0.2);
|
||||
border: 2px solid #2563eb;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.marker-outer.watching) {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
:global(.marker-inner) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.marker-inner.watching) {
|
||||
background: #f59e0b;
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
:global(.marker-pulse) {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(37, 99, 235, 0.6);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
:global(.marker-pulse.watching) {
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
animation: pulse-watching 1.5s infinite;
|
||||
}
|
||||
|
||||
:global(.watching-ring) {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2px solid rgba(245, 158, 11, 0.4);
|
||||
border-radius: 50%;
|
||||
animation: expand-ring 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-watching {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expand-ring {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Location pin marker styles */
|
||||
:global(.location-pin-marker) {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transform: translate(-50%, -100%);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.location-pin-marker:hover) {
|
||||
transform: translate(-50%, -100%) scale(1.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
:global(.location-pin-icon) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ff6b35;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
:global(.location-find-count) {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: white;
|
||||
color: #ff6b35;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
:global(.location-marker-preview) {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
:global(.location-marker-preview img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recenter-button {
|
||||
position: absolute;
|
||||
top: 100px;
|
||||
right: 20px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: white;
|
||||
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.recenter-button:hover {
|
||||
background: #f0f9ff;
|
||||
border-color: #2563eb;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.recenter-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.map-container {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.location-controls {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.recenter-button {
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
402
src/lib/components/map/POISearch.svelte
Normal file
402
src/lib/components/map/POISearch.svelte
Normal file
@@ -0,0 +1,402 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Label } from '$lib/components/label';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { coordinates } from '$lib/stores/location';
|
||||
import type { PlaceResult } from '$lib/utils/places';
|
||||
|
||||
interface Props {
|
||||
onPlaceSelected: (place: PlaceResult) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
showNearbyButton?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
onPlaceSelected,
|
||||
placeholder = 'Search for a place or address...',
|
||||
label = 'Search location',
|
||||
showNearbyButton = true
|
||||
}: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let suggestions = $state<Array<{ placeId: string; description: string; types: string[] }>>([]);
|
||||
let nearbyPlaces = $state<PlaceResult[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let showSuggestions = $state(false);
|
||||
let showNearby = $state(false);
|
||||
let debounceTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
async function searchPlaces(query: string) {
|
||||
if (!query.trim()) {
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const searchParams = new URL('/api/places', window.location.origin).searchParams;
|
||||
searchParams.set('action', 'autocomplete');
|
||||
searchParams.set('query', query.trim());
|
||||
|
||||
if ($coordinates) {
|
||||
searchParams.set('lat', $coordinates.latitude.toString());
|
||||
searchParams.set('lng', $coordinates.longitude.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/places?${searchParams}`);
|
||||
if (response.ok) {
|
||||
suggestions = await response.json();
|
||||
showSuggestions = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching places:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectPlace(placeId: string, description: string) {
|
||||
isLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
action: 'details',
|
||||
placeId
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/places?${params}`);
|
||||
if (response.ok) {
|
||||
const place: PlaceResult = await response.json();
|
||||
onPlaceSelected(place);
|
||||
searchQuery = description;
|
||||
showSuggestions = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting place details:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findNearbyPlaces() {
|
||||
if (!$coordinates) {
|
||||
alert('Please enable location access first');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
showNearby = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
action: 'nearby',
|
||||
lat: $coordinates.latitude.toString(),
|
||||
lng: $coordinates.longitude.toString(),
|
||||
radius: '2000' // 2km radius
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/places?${params}`);
|
||||
if (response.ok) {
|
||||
nearbyPlaces = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error finding nearby places:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
searchQuery = target.value;
|
||||
|
||||
clearTimeout(debounceTimeout);
|
||||
debounceTimeout = setTimeout(() => {
|
||||
searchPlaces(searchQuery);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function selectNearbyPlace(place: PlaceResult) {
|
||||
onPlaceSelected(place);
|
||||
searchQuery = place.name;
|
||||
showNearby = false;
|
||||
showSuggestions = false;
|
||||
}
|
||||
|
||||
function handleClickOutside(event: Event) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.poi-search-container')) {
|
||||
showSuggestions = false;
|
||||
showNearby = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
if (typeof window !== 'undefined') {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="poi-search-container">
|
||||
<div class="field">
|
||||
<Label for="poi-search">{label}</Label>
|
||||
<div class="search-input-container">
|
||||
<Input id="poi-search" type="text" {placeholder} value={searchQuery} oninput={handleInput} />
|
||||
{#if showNearbyButton && $coordinates}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={findNearbyPlaces}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
Nearby
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSuggestions && suggestions.length > 0}
|
||||
<div class="suggestions-dropdown">
|
||||
<div class="suggestions-header">Search Results</div>
|
||||
{#each suggestions as suggestion (suggestion.placeId)}
|
||||
<button
|
||||
class="suggestion-item"
|
||||
onclick={() => selectPlace(suggestion.placeId, suggestion.description)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<div class="suggestion-content">
|
||||
<span class="suggestion-name">{suggestion.description}</span>
|
||||
<div class="suggestion-types">
|
||||
{#each suggestion.types.slice(0, 2) as type, index (index)}
|
||||
<span class="suggestion-type">{type.replace(/_/g, ' ')}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
|
||||
<path
|
||||
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showNearby && nearbyPlaces.length > 0}
|
||||
<div class="suggestions-dropdown">
|
||||
<div class="suggestions-header">Nearby Places</div>
|
||||
{#each nearbyPlaces as place (place.placeId)}
|
||||
<button
|
||||
class="suggestion-item"
|
||||
onclick={() => selectNearbyPlace(place)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<div class="suggestion-content">
|
||||
<span class="suggestion-name">{place.name}</span>
|
||||
<span class="suggestion-address">{place.vicinity || place.formattedAddress}</span>
|
||||
{#if place.rating}
|
||||
<div class="suggestion-rating">
|
||||
<span class="rating-stars">★</span>
|
||||
<span>{place.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
|
||||
<path
|
||||
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-indicator">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="loading-spinner">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="32"
|
||||
stroke-dashoffset="32"
|
||||
/>
|
||||
</svg>
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.poi-search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.suggestions-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: hsl(var(--background) / 0.95);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
margin-top: 0.25rem;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.suggestions-header {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
background: hsl(var(--background) / 0.95);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.suggestion-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.suggestion-item:hover:not(:disabled) {
|
||||
background: hsl(var(--muted) / 0.8);
|
||||
}
|
||||
|
||||
.suggestion-item:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.suggestion-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.suggestion-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.suggestion-address {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.suggestion-types {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.suggestion-type {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 4px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.suggestion-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.rating-stars {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
color: hsl(var(--muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.search-input-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
src/lib/components/map/index.ts
Normal file
3
src/lib/components/map/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as LocationManager } from './LocationManager.svelte';
|
||||
export { default as Map } from './Map.svelte';
|
||||
export { default as POISearch } from './POISearch.svelte';
|
||||
1
src/lib/components/media/index.ts
Normal file
1
src/lib/components/media/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as VideoPlayer } from './VideoPlayer.svelte';
|
||||
194
src/lib/components/notifications/NotificationManager.svelte
Normal file
194
src/lib/components/notifications/NotificationManager.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import NotificationPrompt from './NotificationPrompt.svelte';
|
||||
|
||||
/**
|
||||
* NotificationManager - Handles push notification subscription
|
||||
* Shows a prompt for users to enable notifications (requires user gesture for iOS)
|
||||
*/
|
||||
|
||||
let permissionStatus = $state<NotificationPermission>('default');
|
||||
let showPrompt = $state<boolean>(false);
|
||||
let isSupported = $state<boolean>(false);
|
||||
|
||||
const PROMPT_DISMISSED_KEY = 'notification-prompt-dismissed';
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
|
||||
// Check if notifications and service workers are supported
|
||||
isSupported = 'Notification' in window && 'serviceWorker' in navigator;
|
||||
|
||||
if (!isSupported) {
|
||||
console.log('Notifications or service workers not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize without requesting permission
|
||||
initializeNotifications();
|
||||
});
|
||||
|
||||
async function initializeNotifications() {
|
||||
try {
|
||||
console.log('[NotificationManager] Starting initialization...');
|
||||
|
||||
// Get current permission status
|
||||
permissionStatus = Notification.permission;
|
||||
console.log('[NotificationManager] Permission status:', permissionStatus);
|
||||
|
||||
// If already granted, subscribe automatically
|
||||
if (permissionStatus === 'granted') {
|
||||
console.log('[NotificationManager] Permission already granted');
|
||||
await subscribeToNotifications();
|
||||
}
|
||||
// If permission is default and not dismissed, show prompt
|
||||
else if (permissionStatus === 'default') {
|
||||
const dismissed = localStorage.getItem(PROMPT_DISMISSED_KEY);
|
||||
if (!dismissed) {
|
||||
showPrompt = true;
|
||||
}
|
||||
}
|
||||
// If denied, do nothing
|
||||
else {
|
||||
console.log('[NotificationManager] Permission denied by user');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationManager] Error initializing notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnableNotifications() {
|
||||
try {
|
||||
console.log('[NotificationManager] User clicked enable notifications');
|
||||
showPrompt = false;
|
||||
|
||||
// Request permission (this is triggered by user gesture, so iOS will allow it)
|
||||
permissionStatus = await Notification.requestPermission();
|
||||
console.log('[NotificationManager] Permission response:', permissionStatus);
|
||||
|
||||
if (permissionStatus === 'granted') {
|
||||
await subscribeToNotifications();
|
||||
} else {
|
||||
console.log('[NotificationManager] Permission not granted');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationManager] Error enabling notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismissPrompt() {
|
||||
console.log('[NotificationManager] User dismissed notification prompt');
|
||||
showPrompt = false;
|
||||
localStorage.setItem(PROMPT_DISMISSED_KEY, 'true');
|
||||
}
|
||||
|
||||
async function subscribeToNotifications() {
|
||||
try {
|
||||
console.log('[NotificationManager] subscribeToNotifications called');
|
||||
|
||||
// Get or register service worker
|
||||
let registration = await navigator.serviceWorker.getRegistration();
|
||||
|
||||
if (!registration) {
|
||||
console.log('[NotificationManager] No SW found, registering...');
|
||||
registration = await navigator.serviceWorker.register('/service-worker.js', {
|
||||
type: 'module'
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for service worker to be ready
|
||||
await navigator.serviceWorker.ready;
|
||||
console.log('[NotificationManager] Service worker ready');
|
||||
|
||||
// Get VAPID public key from server
|
||||
console.log('[NotificationManager] Fetching VAPID key...');
|
||||
const response = await fetch('/api/notifications/subscribe');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get VAPID public key');
|
||||
}
|
||||
|
||||
const { publicKey } = await response.json();
|
||||
console.log('[NotificationManager] Got VAPID key:', publicKey);
|
||||
|
||||
// Check if already subscribed
|
||||
console.log('[NotificationManager] Checking existing subscription...');
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
console.log('[NotificationManager] Existing subscription:', subscription);
|
||||
|
||||
// If not subscribed, create new subscription
|
||||
if (!subscription) {
|
||||
console.log('[NotificationManager] Creating new subscription...');
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||||
});
|
||||
console.log('[NotificationManager] Subscription created:', subscription);
|
||||
}
|
||||
|
||||
// Send subscription to server
|
||||
console.log('[NotificationManager] Sending subscription to server...');
|
||||
const saveResponse = await fetch('/api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscription: {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
|
||||
auth: arrayBufferToBase64(subscription.getKey('auth'))
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
console.log('[NotificationManager] Save response status:', saveResponse.status);
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
const errorText = await saveResponse.text();
|
||||
console.error('[NotificationManager] Save failed:', errorText);
|
||||
throw new Error('Failed to save subscription to server');
|
||||
}
|
||||
|
||||
console.log('[NotificationManager] Successfully subscribed to push notifications!');
|
||||
} catch (error) {
|
||||
console.error('[NotificationManager] Error subscribing to push notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert VAPID public key from base64 to Uint8Array
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray as Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to base64 string
|
||||
*/
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer | null): string {
|
||||
if (!buffer) return '';
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showPrompt}
|
||||
<NotificationPrompt onEnable={handleEnableNotifications} onDismiss={handleDismissPrompt} />
|
||||
{/if}
|
||||
173
src/lib/components/notifications/NotificationPrompt.svelte
Normal file
173
src/lib/components/notifications/NotificationPrompt.svelte
Normal file
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { X, Bell } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
onEnable: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
let { onEnable, onDismiss }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="notification-prompt">
|
||||
<div class="notification-prompt-content">
|
||||
<div class="notification-prompt-icon">
|
||||
<Bell size={20} />
|
||||
</div>
|
||||
<div class="notification-prompt-text">
|
||||
<h3>Enable Notifications</h3>
|
||||
<p>Stay updated when friends like or comment on your finds</p>
|
||||
</div>
|
||||
<div class="notification-prompt-actions">
|
||||
<button class="enable-button" onclick={onEnable}>Enable</button>
|
||||
<button class="dismiss-button" onclick={onDismiss} aria-label="Dismiss notification prompt">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.notification-prompt {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-prompt-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.notification-prompt-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.notification-prompt-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-prompt-text h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.notification-prompt-text p {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-prompt-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.enable-button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.enable-button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.enable-button:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.dismiss-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dismiss-button:hover {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.notification-prompt {
|
||||
top: 70px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.notification-prompt-content {
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notification-prompt-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.notification-prompt-text h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.notification-prompt-text p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.enable-button {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
613
src/lib/components/notifications/NotificationSettings.svelte
Normal file
613
src/lib/components/notifications/NotificationSettings.svelte
Normal file
@@ -0,0 +1,613 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface NotificationPreferences {
|
||||
friendRequests: boolean;
|
||||
friendAccepted: boolean;
|
||||
findLiked: boolean;
|
||||
findCommented: boolean;
|
||||
pushEnabled: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let preferences = $state<NotificationPreferences>({
|
||||
friendRequests: true,
|
||||
friendAccepted: true,
|
||||
findLiked: true,
|
||||
findCommented: true,
|
||||
pushEnabled: true
|
||||
});
|
||||
|
||||
let isLoading = $state<boolean>(true);
|
||||
let isSaving = $state<boolean>(false);
|
||||
let isSubscribing = $state<boolean>(false);
|
||||
let browserPermission = $state<NotificationPermission>('default');
|
||||
let isMobile = $state(false);
|
||||
|
||||
// Detect screen size
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const checkIsMobile = () => {
|
||||
isMobile = window.innerWidth < 768;
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener('resize', checkIsMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
loadPreferences();
|
||||
checkBrowserPermission();
|
||||
});
|
||||
|
||||
function checkBrowserPermission() {
|
||||
if (!browser || !('Notification' in window)) {
|
||||
browserPermission = 'denied';
|
||||
return;
|
||||
}
|
||||
browserPermission = Notification.permission;
|
||||
}
|
||||
|
||||
async function requestBrowserPermission() {
|
||||
if (!browser || !('Notification' in window)) {
|
||||
toast.error('Notifications are not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSubscribing = true;
|
||||
const permission = await Notification.requestPermission();
|
||||
browserPermission = permission;
|
||||
|
||||
if (permission === 'granted') {
|
||||
// Subscribe to push notifications
|
||||
await subscribeToPush();
|
||||
toast.success('Notifications enabled successfully');
|
||||
} else if (permission === 'denied') {
|
||||
toast.error('Notification permission denied');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting notification permission:', error);
|
||||
toast.error('Failed to enable notifications');
|
||||
} finally {
|
||||
isSubscribing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToPush() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const vapidPublicKey = await fetch('/api/notifications/subscribe').then((r) => r.text());
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
|
||||
});
|
||||
|
||||
await fetch('/api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(subscription)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
async function loadPreferences() {
|
||||
try {
|
||||
isLoading = true;
|
||||
const response = await fetch('/api/notifications/preferences');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load notification preferences');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
preferences = data;
|
||||
} catch (error) {
|
||||
console.error('Error loading notification preferences:', error);
|
||||
toast.error('Failed to load notification preferences');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePreferences() {
|
||||
try {
|
||||
isSaving = true;
|
||||
const response = await fetch('/api/notifications/preferences', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(preferences)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save notification preferences');
|
||||
}
|
||||
|
||||
toast.success('Notification preferences updated');
|
||||
} catch (error) {
|
||||
console.error('Error saving notification preferences:', error);
|
||||
toast.error('Failed to save notification preferences');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(key: keyof NotificationPreferences) {
|
||||
preferences[key] = !preferences[key];
|
||||
savePreferences();
|
||||
}
|
||||
|
||||
const canTogglePreferences = $derived(browserPermission === 'granted' && preferences.pushEnabled);
|
||||
</script>
|
||||
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="header-content">
|
||||
<h2 class="modal-title">Notification Settings</h2>
|
||||
<p class="modal-subtitle">Manage your notification preferences</p>
|
||||
</div>
|
||||
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading preferences...</div>
|
||||
{:else}
|
||||
<!-- Browser Permission Banner -->
|
||||
{#if browserPermission !== 'granted'}
|
||||
<div class="permission-banner {browserPermission === 'denied' ? 'denied' : 'default'}">
|
||||
<div class="permission-info">
|
||||
{#if browserPermission === 'denied'}
|
||||
<strong>Browser notifications blocked</strong>
|
||||
<p>
|
||||
Please enable notifications in your browser settings to receive push notifications
|
||||
</p>
|
||||
{:else}
|
||||
<strong>Browser permission required</strong>
|
||||
<p>Enable browser notifications to receive push notifications</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if browserPermission === 'default'}
|
||||
<button
|
||||
class="enable-button"
|
||||
onclick={requestBrowserPermission}
|
||||
disabled={isSubscribing}
|
||||
>
|
||||
{isSubscribing ? 'Enabling...' : 'Enable'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="settings-list">
|
||||
<!-- Push Notifications Toggle -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Push Notifications</h3>
|
||||
<p>Enable or disable all push notifications</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.pushEnabled}
|
||||
onchange={() => handleToggle('pushEnabled')}
|
||||
disabled={isSaving || browserPermission !== 'granted'}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Friend Requests -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Friend Requests</h3>
|
||||
<p>Get notified when someone sends you a friend request</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.friendRequests}
|
||||
onchange={() => handleToggle('friendRequests')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Friend Accepted -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Friend Request Accepted</h3>
|
||||
<p>Get notified when someone accepts your friend request</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.friendAccepted}
|
||||
onchange={() => handleToggle('friendAccepted')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Find Liked -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Find Likes</h3>
|
||||
<p>Get notified when someone likes your find</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.findLiked}
|
||||
onchange={() => handleToggle('findLiked')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Find Commented -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Find Comments</h3>
|
||||
<p>Get notified when someone comments on your find</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.findCommented}
|
||||
onchange={() => handleToggle('findCommented')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: auto;
|
||||
max-width: 500px;
|
||||
min-width: 380px;
|
||||
max-height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 0 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
background: transparent;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.permission-banner {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
padding: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.permission-banner.denied {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
border-color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.permission-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.permission-info strong {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.permission-info p {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.enable-button {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.enable-button:hover:not(:disabled) {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
.enable-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
background: hsl(var(--card));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem;
|
||||
gap: 1rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.setting-item.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-info h3 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.setting-info p {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: hsl(var(--border));
|
||||
margin: 0 1.25rem;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 24px;
|
||||
transition: all 0.2s;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
left: 3px;
|
||||
bottom: 2px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.25s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider:before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.toggle input:disabled + .toggle-slider {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Mobile specificadjust ments */
|
||||
@media (max-width: 767px) {
|
||||
.modal-container {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
src/lib/components/notifications/index.ts
Normal file
3
src/lib/components/notifications/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as NotificationManager } from './NotificationManager.svelte';
|
||||
export { default as NotificationPrompt } from './NotificationPrompt.svelte';
|
||||
export { default as NotificationSettings } from './NotificationSettings.svelte';
|
||||
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from './dropdown-menu';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
||||
import { Skeleton } from './skeleton';
|
||||
} from '../dropdown-menu';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
|
||||
import NotificationSettings from '../notifications/NotificationSettings.svelte';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
@@ -21,9 +23,7 @@
|
||||
let { username, id, profilePictureUrl, loading = false }: Props = $props();
|
||||
|
||||
let showProfilePictureSheet = $state(false);
|
||||
|
||||
// Get the first letter of username for avatar
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
let showNotificationSettings = $state(false);
|
||||
|
||||
function openProfilePictureSheet() {
|
||||
showProfilePictureSheet = true;
|
||||
@@ -32,22 +32,19 @@
|
||||
function closeProfilePictureSheet() {
|
||||
showProfilePictureSheet = false;
|
||||
}
|
||||
|
||||
function openNotificationSettings() {
|
||||
showNotificationSettings = true;
|
||||
}
|
||||
|
||||
function closeNotificationSettings() {
|
||||
showNotificationSettings = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="profile-trigger">
|
||||
{#if loading}
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
{:else}
|
||||
<Avatar class="profile-avatar">
|
||||
{#if profilePictureUrl}
|
||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||
{/if}
|
||||
<AvatarFallback class="profile-avatar-fallback">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/if}
|
||||
<ProfilePicture {username} {profilePictureUrl} {loading} class="profile-avatar" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" class="profile-dropdown-content">
|
||||
@@ -85,7 +82,11 @@
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem class="friends-item">
|
||||
<a href="/friends" class="friends-link">Friends</a>
|
||||
<a href={resolveRoute('/friends')} class="friends-link">Friends</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem class="notification-settings-item" onclick={openNotificationSettings}>
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
@@ -120,6 +121,10 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showNotificationSettings}
|
||||
<NotificationSettings onClose={closeNotificationSettings} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.profile-trigger) {
|
||||
background: none;
|
||||
@@ -144,15 +149,6 @@
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
:global(.profile-avatar-fallback) {
|
||||
background: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.profile-dropdown-content) {
|
||||
min-width: 200px;
|
||||
max-width: 240px;
|
||||
@@ -211,6 +207,16 @@
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
:global(.notification-settings-item) {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:global(.notification-settings-item:hover) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.friends-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
59
src/lib/components/profile/ProfilePicture.svelte
Normal file
59
src/lib/components/profile/ProfilePicture.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../avatar';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
class?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
username,
|
||||
profilePictureUrl,
|
||||
size = 'md',
|
||||
class: className,
|
||||
loading = false
|
||||
}: Props = $props();
|
||||
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 w-8',
|
||||
md: 'h-10 w-10',
|
||||
lg: 'h-16 w-16',
|
||||
xl: 'h-24 w-24'
|
||||
};
|
||||
|
||||
const fallbackSizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
lg: 'text-xl',
|
||||
xl: 'text-3xl'
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class={cn('animate-pulse rounded-full bg-gray-200', sizeClasses[size], className)}></div>
|
||||
{:else}
|
||||
<Avatar class={cn(sizeClasses[size], className)}>
|
||||
{#if profilePictureUrl}
|
||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||
{/if}
|
||||
<AvatarFallback class={cn('profile-picture-fallback', fallbackSizeClasses[size])}>
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.profile-picture-fallback) {
|
||||
background: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
|
||||
import { Button } from './button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../sheet';
|
||||
import { Button } from '../button';
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
@@ -19,8 +19,6 @@
|
||||
let isDeleting = $state(false);
|
||||
let showModal = $state(true);
|
||||
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
|
||||
// Close modal when showModal changes to false
|
||||
$effect(() => {
|
||||
if (!showModal && onClose) {
|
||||
@@ -118,14 +116,7 @@
|
||||
|
||||
<div class="profile-picture-content">
|
||||
<div class="current-avatar">
|
||||
<Avatar class="large-avatar">
|
||||
{#if profilePictureUrl}
|
||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||
{/if}
|
||||
<AvatarFallback class="large-avatar-fallback">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture {username} {profilePictureUrl} size="xl" class="large-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="upload-section">
|
||||
@@ -190,15 +181,6 @@
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
:global(.large-avatar-fallback) {
|
||||
background: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 48px;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
3
src/lib/components/profile/index.ts
Normal file
3
src/lib/components/profile/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ProfilePanel } from './ProfilePanel.svelte';
|
||||
export { default as ProfilePicture } from './ProfilePicture.svelte';
|
||||
export { default as ProfilePictureSheet } from './ProfilePictureSheet.svelte';
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from 'tailwind-variants';
|
||||
export const sheetVariants = tv({
|
||||
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-[9999] flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
variants: {
|
||||
side: {
|
||||
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
bind:ref
|
||||
data-slot="sheet-overlay"
|
||||
class={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-[9998] bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
export { default as Input } from './components/Input.svelte';
|
||||
export { default as Button } from './components/Button.svelte';
|
||||
export { default as ErrorMessage } from './components/ErrorMessage.svelte';
|
||||
export { default as ProfilePanel } from './components/ProfilePanel.svelte';
|
||||
export { default as ProfilePictureSheet } from './components/ProfilePictureSheet.svelte';
|
||||
export { default as ProfilePanel } from './components/profile/ProfilePanel.svelte';
|
||||
export { default as ProfilePicture } from './components/profile/ProfilePicture.svelte';
|
||||
export { default as ProfilePictureSheet } from './components/profile/ProfilePictureSheet.svelte';
|
||||
export { default as Header } from './components/Header.svelte';
|
||||
export { default as Modal } from './components/Modal.svelte';
|
||||
export { default as Map } from './components/Map.svelte';
|
||||
export { default as LocationButton } from './components/LocationButton.svelte';
|
||||
export { default as FindCard } from './components/FindCard.svelte';
|
||||
export { default as FindsList } from './components/FindsList.svelte';
|
||||
export { default as Map } from './components/map/Map.svelte';
|
||||
export { default as LocationManager } from './components/map/LocationManager.svelte';
|
||||
export { default as NotificationManager } from './components/notifications/NotificationManager.svelte';
|
||||
export { default as NotificationPrompt } from './components/notifications/NotificationPrompt.svelte';
|
||||
export { default as NotificationSettings } from './components/notifications/NotificationSettings.svelte';
|
||||
export { default as FindCard } from './components/finds/FindCard.svelte';
|
||||
export { default as FindsList } from './components/finds/FindsList.svelte';
|
||||
|
||||
// Skeleton Loading Components
|
||||
export { Skeleton, SkeletonVariants } from './components/skeleton';
|
||||
@@ -24,6 +28,7 @@ export {
|
||||
locationError,
|
||||
isLocationLoading,
|
||||
hasLocationAccess,
|
||||
isWatching,
|
||||
getMapCenter,
|
||||
getMapZoom
|
||||
} from './stores/location';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
@@ -63,16 +63,11 @@ export async function validateSessionToken(token: string) {
|
||||
.where(eq(table.session.id, session.id));
|
||||
}
|
||||
|
||||
// Generate signed URL for profile picture if it exists
|
||||
// Generate local proxy URL for profile picture if it exists
|
||||
let profilePictureUrl = user.profilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
// It's a path, generate signed URL
|
||||
try {
|
||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for profile picture:', error);
|
||||
profilePictureUrl = null;
|
||||
}
|
||||
// It's a path, generate local proxy URL
|
||||
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, integer, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, integer, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const user = pgTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -21,17 +21,29 @@ export type Session = typeof session.$inferSelect;
|
||||
|
||||
export type User = typeof user.$inferSelect;
|
||||
|
||||
// Finds feature tables
|
||||
// Location table - represents geographical points where finds can be made
|
||||
export const location = pgTable('location', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
latitude: text('latitude').notNull(), // Using text for precision
|
||||
longitude: text('longitude').notNull(), // Using text for precision
|
||||
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Find table - represents posts/content made at a location
|
||||
export const find = pgTable('find', {
|
||||
id: text('id').primaryKey(),
|
||||
locationId: text('location_id')
|
||||
.notNull()
|
||||
.references(() => location.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
latitude: text('latitude').notNull(), // Using text for precision
|
||||
longitude: text('longitude').notNull(), // Using text for precision
|
||||
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
|
||||
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
|
||||
isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false)
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
@@ -75,13 +87,77 @@ export const friendship = pgTable('friendship', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Type exports for the new tables
|
||||
export const findComment = pgTable('find_comment', {
|
||||
id: text('id').primaryKey(),
|
||||
findId: text('find_id')
|
||||
.notNull()
|
||||
.references(() => find.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
content: text('content').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Notification system tables
|
||||
export const notification = pgTable('notification', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
type: text('type').notNull(), // 'friend_request', 'friend_accepted', 'find_liked', 'find_commented'
|
||||
title: text('title').notNull(),
|
||||
message: text('message').notNull(),
|
||||
data: jsonb('data'), // Additional context data (findId, friendId, etc.)
|
||||
isRead: boolean('is_read').default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const notificationSubscription = pgTable('notification_subscription', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
p256dhKey: text('p256dh_key').notNull(),
|
||||
authKey: text('auth_key').notNull(),
|
||||
userAgent: text('user_agent'),
|
||||
isActive: boolean('is_active').default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const notificationPreferences = pgTable('notification_preferences', {
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
friendRequests: boolean('friend_requests').default(true),
|
||||
friendAccepted: boolean('friend_accepted').default(true),
|
||||
findLiked: boolean('find_liked').default(true),
|
||||
findCommented: boolean('find_commented').default(true),
|
||||
pushEnabled: boolean('push_enabled').default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Type exports for the tables
|
||||
export type Location = typeof location.$inferSelect;
|
||||
export type Find = typeof find.$inferSelect;
|
||||
export type FindMedia = typeof findMedia.$inferSelect;
|
||||
export type FindLike = typeof findLike.$inferSelect;
|
||||
export type FindComment = typeof findComment.$inferSelect;
|
||||
export type Friendship = typeof friendship.$inferSelect;
|
||||
export type Notification = typeof notification.$inferSelect;
|
||||
export type NotificationSubscription = typeof notificationSubscription.$inferSelect;
|
||||
export type NotificationPreferences = typeof notificationPreferences.$inferSelect;
|
||||
|
||||
export type LocationInsert = typeof location.$inferInsert;
|
||||
export type FindInsert = typeof find.$inferInsert;
|
||||
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
||||
export type FindLikeInsert = typeof findLike.$inferInsert;
|
||||
export type FindCommentInsert = typeof findComment.$inferInsert;
|
||||
export type FriendshipInsert = typeof friendship.$inferInsert;
|
||||
export type NotificationInsert = typeof notification.$inferInsert;
|
||||
export type NotificationSubscriptionInsert = typeof notificationSubscription.$inferInsert;
|
||||
export type NotificationPreferencesInsert = typeof notificationPreferences.$inferInsert;
|
||||
|
||||
160
src/lib/server/notifications.ts
Normal file
160
src/lib/server/notifications.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { db } from './db';
|
||||
import { notification, notificationPreferences } from './db/schema';
|
||||
import type { NotificationInsert } from './db/schema';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export type NotificationType =
|
||||
| 'friend_request'
|
||||
| 'friend_accepted'
|
||||
| 'find_liked'
|
||||
| 'find_commented';
|
||||
|
||||
export interface CreateNotificationData {
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GetNotificationsOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
unreadOnly?: boolean;
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
/**
|
||||
* Create a new notification record in the database
|
||||
*/
|
||||
async createNotification(data: CreateNotificationData): Promise<void> {
|
||||
const notificationData: NotificationInsert = {
|
||||
id: nanoid(),
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
data: data.data || null,
|
||||
isRead: false
|
||||
};
|
||||
|
||||
await db.insert(notification).values(notificationData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has notifications enabled for a specific type
|
||||
*/
|
||||
async shouldNotify(userId: string, type: NotificationType): Promise<boolean> {
|
||||
const prefs = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
// If no preferences exist, default to true
|
||||
if (prefs.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pref = prefs[0];
|
||||
|
||||
// Check if push is enabled and specific notification type is enabled
|
||||
if (!pref.pushEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'friend_request':
|
||||
return pref.friendRequests ?? true;
|
||||
case 'friend_accepted':
|
||||
return pref.friendAccepted ?? true;
|
||||
case 'find_liked':
|
||||
return pref.findLiked ?? true;
|
||||
case 'find_commented':
|
||||
return pref.findCommented ?? true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user notifications with pagination and filtering
|
||||
*/
|
||||
async getUserNotifications(userId: string, options: GetNotificationsOptions = {}) {
|
||||
const { limit = 20, offset = 0, unreadOnly = false } = options;
|
||||
|
||||
let query = db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(eq(notification.userId, userId))
|
||||
.orderBy(desc(notification.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
if (unreadOnly) {
|
||||
query = db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(and(eq(notification.userId, userId), eq(notification.isRead, false)))
|
||||
.orderBy(desc(notification.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
return await query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notifications as read
|
||||
*/
|
||||
async markAsRead(notificationIds: string[]): Promise<void> {
|
||||
for (const id of notificationIds) {
|
||||
await db.update(notification).set({ isRead: true }).where(eq(notification.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single notification as read
|
||||
*/
|
||||
async markOneAsRead(notificationId: string): Promise<void> {
|
||||
await db.update(notification).set({ isRead: true }).where(eq(notification.id, notificationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a user
|
||||
*/
|
||||
async markAllAsRead(userId: string): Promise<void> {
|
||||
await db.update(notification).set({ isRead: true }).where(eq(notification.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count for a user
|
||||
*/
|
||||
async getUnreadCount(userId: string): Promise<number> {
|
||||
const notifications = await db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(and(eq(notification.userId, userId), eq(notification.isRead, false)));
|
||||
|
||||
return notifications.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a notification
|
||||
*/
|
||||
async deleteNotification(notificationId: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.delete(notification)
|
||||
.where(and(eq(notification.id, notificationId), eq(notification.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all notifications for a user
|
||||
*/
|
||||
async deleteAllNotifications(userId: string): Promise<void> {
|
||||
await db.delete(notification).where(eq(notification.userId, userId));
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
@@ -4,5 +4,5 @@ import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } from '$env/static/private';
|
||||
export const google = new Google(
|
||||
GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET,
|
||||
'https://serengo.ziasvannes.tech/login/google/callback'
|
||||
'https://serengo.zias.be/login/google/callback'
|
||||
);
|
||||
|
||||
185
src/lib/server/push.ts
Normal file
185
src/lib/server/push.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import webpush from 'web-push';
|
||||
import { db } from './db';
|
||||
import { notificationSubscription } from './db/schema';
|
||||
import type { NotificationSubscriptionInsert } from './db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT } from '$env/static/private';
|
||||
|
||||
// Initialize web-push with VAPID keys
|
||||
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY || !VAPID_SUBJECT) {
|
||||
throw new Error(
|
||||
'VAPID keys are not configured. Please set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT in your environment variables.'
|
||||
);
|
||||
}
|
||||
|
||||
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
||||
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushNotificationPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
tag?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export class PushService {
|
||||
/**
|
||||
* Save a push subscription for a user
|
||||
*/
|
||||
async saveSubscription(
|
||||
userId: string,
|
||||
subscription: PushSubscriptionData,
|
||||
userAgent?: string
|
||||
): Promise<void> {
|
||||
const subscriptionData: NotificationSubscriptionInsert = {
|
||||
id: nanoid(),
|
||||
userId,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dhKey: subscription.keys.p256dh,
|
||||
authKey: subscription.keys.auth,
|
||||
userAgent: userAgent || null,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
// Check if subscription already exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(notificationSubscription)
|
||||
.where(eq(notificationSubscription.endpoint, subscription.endpoint))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing subscription
|
||||
await db
|
||||
.update(notificationSubscription)
|
||||
.set({
|
||||
p256dhKey: subscription.keys.p256dh,
|
||||
authKey: subscription.keys.auth,
|
||||
userAgent: userAgent || null,
|
||||
isActive: true,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(notificationSubscription.endpoint, subscription.endpoint));
|
||||
} else {
|
||||
// Insert new subscription
|
||||
await db.insert(notificationSubscription).values(subscriptionData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a push subscription
|
||||
*/
|
||||
async removeSubscription(userId: string, endpoint: string): Promise<void> {
|
||||
await db
|
||||
.delete(notificationSubscription)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationSubscription.userId, userId),
|
||||
eq(notificationSubscription.endpoint, endpoint)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active subscriptions for a user
|
||||
*/
|
||||
async getUserSubscriptions(userId: string) {
|
||||
return await db
|
||||
.select()
|
||||
.from(notificationSubscription)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationSubscription.userId, userId),
|
||||
eq(notificationSubscription.isActive, true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification to a user's subscriptions
|
||||
*/
|
||||
async sendPushNotification(userId: string, payload: PushNotificationPayload): Promise<void> {
|
||||
const subscriptions = await this.getUserSubscriptions(userId);
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
console.log(`No active subscriptions found for user ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationPayload = JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.message,
|
||||
icon: payload.icon || '/logo.svg',
|
||||
badge: payload.badge || '/logo.svg',
|
||||
tag: payload.tag,
|
||||
data: {
|
||||
url: payload.url || '/',
|
||||
...payload.data
|
||||
}
|
||||
});
|
||||
|
||||
const sendPromises = subscriptions.map(async (sub) => {
|
||||
const pushSubscription: webpush.PushSubscription = {
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.p256dhKey,
|
||||
auth: sub.authKey
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(pushSubscription, notificationPayload);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send push notification to ${sub.endpoint}:`, error);
|
||||
|
||||
// If the subscription is invalid (410 Gone or 404), deactivate it
|
||||
if (error instanceof Error && 'statusCode' in error) {
|
||||
const statusCode = (error as { statusCode: number }).statusCode;
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
await db
|
||||
.update(notificationSubscription)
|
||||
.set({ isActive: false })
|
||||
.where(eq(notificationSubscription.id, sub.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(sendPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification to multiple users
|
||||
*/
|
||||
async sendPushNotificationToUsers(
|
||||
userIds: string[],
|
||||
payload: PushNotificationPayload
|
||||
): Promise<void> {
|
||||
const sendPromises = userIds.map((userId) => this.sendPushNotification(userId, payload));
|
||||
await Promise.all(sendPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a subscription
|
||||
*/
|
||||
async deactivateSubscription(endpoint: string): Promise<void> {
|
||||
await db
|
||||
.update(notificationSubscription)
|
||||
.set({ isActive: false })
|
||||
.where(eq(notificationSubscription.endpoint, endpoint));
|
||||
}
|
||||
}
|
||||
|
||||
export const pushService = new PushService();
|
||||
export { VAPID_PUBLIC_KEY };
|
||||
@@ -72,3 +72,7 @@ export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<st
|
||||
|
||||
return await getSignedUrl(r2Client, command, { expiresIn });
|
||||
}
|
||||
|
||||
export function getLocalR2Url(path: string): string {
|
||||
return `/api/media/${path}`;
|
||||
}
|
||||
|
||||
849
src/lib/stores/api-sync.ts
Normal file
849
src/lib/stores/api-sync.ts
Normal file
@@ -0,0 +1,849 @@
|
||||
import { writable, derived, type Readable, type Writable } from 'svelte/store';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// Core types for the API sync system
|
||||
export interface EntityState<T = unknown> {
|
||||
data: T;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
export interface QueuedOperation {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
action?: string;
|
||||
data?: unknown;
|
||||
retry: number;
|
||||
maxRetries: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface APIResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Specific entity state types
|
||||
export interface FindLikeState {
|
||||
isLiked: boolean;
|
||||
likeCount: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FindState {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string;
|
||||
category?: string;
|
||||
isPublic: boolean;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string;
|
||||
media?: Array<{
|
||||
id: string;
|
||||
findId: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
orderIndex: number | null;
|
||||
}>;
|
||||
isLikedByUser: boolean;
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
isFromFriend: boolean;
|
||||
}
|
||||
|
||||
export interface CommentState {
|
||||
id: string;
|
||||
findId: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FindCommentsState {
|
||||
comments: CommentState[];
|
||||
commentCount: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Generate unique operation IDs
|
||||
function generateOperationId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Create operation key for deduplication
|
||||
function createOperationKey(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
operation: string,
|
||||
action?: string
|
||||
): string {
|
||||
return `${entityType}:${entityId}:${operation}${action ? `:${action}` : ''}`;
|
||||
}
|
||||
|
||||
class APISync {
|
||||
// Entity stores - each entity type has its own store
|
||||
private entityStores = new Map<string, Writable<Map<string, EntityState>>>();
|
||||
|
||||
// Operation queue for API calls
|
||||
private operationQueue = new Map<string, QueuedOperation>();
|
||||
private processingQueue = false;
|
||||
|
||||
// Cleanup tracking for memory management
|
||||
private subscriptions = new Map<string, Set<() => void>>();
|
||||
|
||||
constructor() {
|
||||
// Initialize core entity stores
|
||||
this.initializeEntityStore('find');
|
||||
this.initializeEntityStore('user');
|
||||
this.initializeEntityStore('friendship');
|
||||
this.initializeEntityStore('comment');
|
||||
|
||||
// Start processing queue
|
||||
this.startQueueProcessor();
|
||||
}
|
||||
|
||||
private initializeEntityStore(entityType: string): void {
|
||||
if (!this.entityStores.has(entityType)) {
|
||||
this.entityStores.set(entityType, writable(new Map<string, EntityState>()));
|
||||
}
|
||||
}
|
||||
|
||||
private getEntityStore(entityType: string): Writable<Map<string, EntityState>> {
|
||||
this.initializeEntityStore(entityType);
|
||||
return this.entityStores.get(entityType)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a specific entity's state
|
||||
*/
|
||||
subscribe<T>(entityType: string, entityId: string): Readable<EntityState<T>> {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
return derived(store, ($entities) => {
|
||||
const entity = $entities.get(entityId);
|
||||
if (!entity) {
|
||||
// Return default state if entity doesn't exist
|
||||
return {
|
||||
data: null as T,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
};
|
||||
}
|
||||
return entity as EntityState<T>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe specifically to find like state
|
||||
*/
|
||||
subscribeFindLikes(findId: string): Readable<FindLikeState> {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
return derived(store, ($entities) => {
|
||||
const entity = $entities.get(findId);
|
||||
if (!entity || !entity.data) {
|
||||
return {
|
||||
isLiked: false,
|
||||
likeCount: 0,
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const findData = entity.data as FindState;
|
||||
return {
|
||||
isLiked: findData.isLikedByUser,
|
||||
likeCount: findData.likeCount,
|
||||
isLoading: entity.isLoading,
|
||||
error: entity.error
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to find comments state
|
||||
*/
|
||||
subscribeFindComments(findId: string): Readable<FindCommentsState> {
|
||||
const store = this.getEntityStore('comment');
|
||||
|
||||
return derived(store, ($entities) => {
|
||||
const entity = $entities.get(findId);
|
||||
if (!entity || !entity.data) {
|
||||
return {
|
||||
comments: [],
|
||||
commentCount: 0,
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const commentsData = entity.data as CommentState[];
|
||||
return {
|
||||
comments: commentsData,
|
||||
commentCount: commentsData.length,
|
||||
isLoading: entity.isLoading,
|
||||
error: entity.error
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity state exists
|
||||
*/
|
||||
hasEntityState(entityType: string, entityId: string): boolean {
|
||||
const store = this.getEntityStore(entityType);
|
||||
let exists = false;
|
||||
|
||||
const unsubscribe = store.subscribe(($entities) => {
|
||||
exists = $entities.has(entityId);
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
return exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current entity state
|
||||
*/
|
||||
getEntityState<T>(entityType: string, entityId: string): T | null {
|
||||
const store = this.getEntityStore(entityType);
|
||||
let currentState: T | null = null;
|
||||
|
||||
const unsubscribe = store.subscribe(($entities) => {
|
||||
const entity = $entities.get(entityId);
|
||||
if (entity?.data) {
|
||||
currentState = entity.data as T;
|
||||
}
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
return currentState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize entity state with server data (only if no existing state)
|
||||
*/
|
||||
setEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
newEntities.set(entityId, {
|
||||
data,
|
||||
isLoading,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize entity state only if it doesn't exist yet
|
||||
*/
|
||||
initializeEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
|
||||
if (!this.hasEntityState(entityType, entityId)) {
|
||||
this.setEntityState(entityType, entityId, data, isLoading);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entity loading state
|
||||
*/
|
||||
private setEntityLoading(entityType: string, entityId: string, isLoading: boolean): void {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(entityId);
|
||||
if (existing) {
|
||||
newEntities.set(entityId, {
|
||||
...existing,
|
||||
isLoading
|
||||
});
|
||||
}
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entity error state
|
||||
*/
|
||||
private setEntityError(entityType: string, entityId: string, error: string): void {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(entityId);
|
||||
if (existing) {
|
||||
newEntities.set(entityId, {
|
||||
...existing,
|
||||
isLoading: false,
|
||||
error
|
||||
});
|
||||
}
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an operation for processing
|
||||
*/
|
||||
async queueOperation(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
operation: 'create' | 'update' | 'delete',
|
||||
action?: string,
|
||||
data?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const operationKey = createOperationKey(entityType, entityId, operation, action);
|
||||
|
||||
// Check if same operation is already queued
|
||||
if (this.operationQueue.has(operationKey)) {
|
||||
console.log(`Operation ${operationKey} already queued, skipping duplicate`);
|
||||
return;
|
||||
}
|
||||
|
||||
const queuedOperation: QueuedOperation = {
|
||||
id: generateOperationId(),
|
||||
entityType,
|
||||
entityId,
|
||||
operation,
|
||||
action,
|
||||
data,
|
||||
retry: 0,
|
||||
maxRetries: 3,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
this.operationQueue.set(operationKey, queuedOperation);
|
||||
|
||||
// Set entity to loading state
|
||||
this.setEntityLoading(entityType, entityId, true);
|
||||
|
||||
// Process queue if not already processing
|
||||
if (!this.processingQueue) {
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the operation queue
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.processingQueue || this.operationQueue.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processingQueue = true;
|
||||
|
||||
const operations = Array.from(this.operationQueue.entries());
|
||||
|
||||
for (const [operationKey, operation] of operations) {
|
||||
try {
|
||||
await this.executeOperation(operation);
|
||||
this.operationQueue.delete(operationKey);
|
||||
} catch (error) {
|
||||
console.error(`Operation ${operationKey} failed:`, error);
|
||||
|
||||
if (operation.retry < operation.maxRetries) {
|
||||
operation.retry++;
|
||||
console.log(
|
||||
`Retrying operation ${operationKey} (attempt ${operation.retry}/${operation.maxRetries})`
|
||||
);
|
||||
} else {
|
||||
console.error(`Operation ${operationKey} failed after ${operation.maxRetries} retries`);
|
||||
this.operationQueue.delete(operationKey);
|
||||
this.setEntityError(
|
||||
operation.entityType,
|
||||
operation.entityId,
|
||||
'Operation failed after multiple retries'
|
||||
);
|
||||
toast.error('Failed to sync changes. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.processingQueue = false;
|
||||
|
||||
// If more operations were added while processing, process again
|
||||
if (this.operationQueue.size > 0) {
|
||||
setTimeout(() => this.processQueue(), 1000); // Wait 1s before retry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a specific operation
|
||||
*/
|
||||
private async executeOperation(operation: QueuedOperation): Promise<void> {
|
||||
const { entityType, entityId, operation: op, action, data } = operation;
|
||||
|
||||
let response: Response;
|
||||
|
||||
if (entityType === 'find' && action === 'like') {
|
||||
// Handle like operations
|
||||
const method = (data as { isLiked?: boolean })?.isLiked ? 'POST' : 'DELETE';
|
||||
response = await fetch(`/api/finds/${entityId}/like`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} else if (entityType === 'find' && op === 'update') {
|
||||
// Handle find update
|
||||
response = await fetch(`/api/finds/${entityId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else if (entityType === 'find' && op === 'delete') {
|
||||
// Handle find deletion
|
||||
response = await fetch(`/api/finds/${entityId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} else if (entityType === 'comment' && op === 'create') {
|
||||
// Handle comment creation
|
||||
response = await fetch(`/api/finds/${entityId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else if (entityType === 'comment' && op === 'delete') {
|
||||
// Handle comment deletion
|
||||
response = await fetch(`/api/finds/comments/${entityId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported operation: ${entityType}:${op}:${action}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update entity state with successful result
|
||||
if (entityType === 'find' && action === 'like') {
|
||||
this.updateFindLikeState(entityId, result.isLiked, result.likeCount);
|
||||
} else if (entityType === 'find' && op === 'update') {
|
||||
// Reload the find data to get the updated state
|
||||
// For now, just clear loading state - the parent component handles refresh
|
||||
// TODO: Ideally, we'd merge the update data into the existing state
|
||||
this.setEntityLoading(entityType, entityId, false);
|
||||
} else if (entityType === 'find' && op === 'delete') {
|
||||
// Find already removed optimistically, just clear loading state
|
||||
this.setEntityLoading(entityType, entityId, false);
|
||||
} else if (entityType === 'comment' && op === 'create') {
|
||||
this.addCommentToState(result.data.findId, result.data);
|
||||
} else if (entityType === 'comment' && op === 'delete') {
|
||||
this.removeCommentFromState(entityId, data as { findId: string });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update find like state after successful API call
|
||||
*/
|
||||
private updateFindLikeState(findId: string, isLiked: boolean, likeCount: number): void {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
if (existing && existing.data) {
|
||||
const findData = existing.data as FindState;
|
||||
newEntities.set(findId, {
|
||||
...existing,
|
||||
data: {
|
||||
...findData,
|
||||
isLikedByUser: isLiked,
|
||||
likeCount: likeCount
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the queue processor
|
||||
*/
|
||||
private startQueueProcessor(): void {
|
||||
// Process queue every 100ms
|
||||
setInterval(() => {
|
||||
if (this.operationQueue.size > 0 && !this.processingQueue) {
|
||||
this.processQueue();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle like for a find
|
||||
*/
|
||||
async toggleLike(findId: string): Promise<void> {
|
||||
// Get current state for optimistic update
|
||||
const store = this.getEntityStore('find');
|
||||
let currentState: FindState | null = null;
|
||||
|
||||
const unsubscribe = store.subscribe(($entities) => {
|
||||
const entity = $entities.get(findId);
|
||||
if (entity?.data) {
|
||||
currentState = entity.data as FindState;
|
||||
}
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
if (!currentState) {
|
||||
console.warn(`Cannot toggle like for find ${findId}: find state not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const findState = currentState as FindState;
|
||||
const newIsLiked = !findState.isLikedByUser;
|
||||
const newLikeCount = findState.likeCount + (newIsLiked ? 1 : -1);
|
||||
|
||||
// Update state optimistically
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
if (existing && existing.data) {
|
||||
const findData = existing.data as FindState;
|
||||
newEntities.set(findId, {
|
||||
...existing,
|
||||
data: {
|
||||
...findData,
|
||||
isLikedByUser: newIsLiked,
|
||||
likeCount: newLikeCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('find', findId, 'update', 'like', { isLiked: newIsLiked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add comment to find comments state
|
||||
*/
|
||||
private addCommentToState(findId: string, comment: CommentState): void {
|
||||
const store = this.getEntityStore('comment');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
const comments = existing?.data ? (existing.data as CommentState[]) : [];
|
||||
|
||||
// If this is a real comment from server, remove any temporary comment with the same content
|
||||
let updatedComments = comments;
|
||||
if (!comment.id.startsWith('temp-')) {
|
||||
updatedComments = comments.filter(
|
||||
(c) => !(c.id.startsWith('temp-') && c.content === comment.content)
|
||||
);
|
||||
}
|
||||
|
||||
updatedComments = [comment, ...updatedComments];
|
||||
|
||||
newEntities.set(findId, {
|
||||
data: updatedComments,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Update find comment count only for temp comments (optimistic updates)
|
||||
if (comment.id.startsWith('temp-')) {
|
||||
this.updateFindCommentCount(findId, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove comment from find comments state
|
||||
*/
|
||||
private removeCommentFromState(commentId: string, data: { findId: string }): void {
|
||||
const store = this.getEntityStore('comment');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(data.findId);
|
||||
|
||||
if (existing?.data) {
|
||||
const comments = existing.data as CommentState[];
|
||||
const updatedComments = comments.filter((c) => c.id !== commentId);
|
||||
|
||||
newEntities.set(data.findId, {
|
||||
...existing,
|
||||
data: updatedComments,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Update find comment count
|
||||
this.updateFindCommentCount(data.findId, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update find comment count
|
||||
*/
|
||||
private updateFindCommentCount(findId: string, delta: number): void {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
if (existing && existing.data) {
|
||||
const findData = existing.data as FindState;
|
||||
newEntities.set(findId, {
|
||||
...existing,
|
||||
data: {
|
||||
...findData,
|
||||
commentCount: Math.max(0, findData.commentCount + delta)
|
||||
},
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load comments for a find
|
||||
*/
|
||||
async loadComments(findId: string): Promise<void> {
|
||||
if (this.hasEntityState('comment', findId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setEntityLoading('comment', findId, true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/finds/${findId}/comments`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.setEntityState('comment', findId, result.data, false);
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to load comments');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading comments:', error);
|
||||
this.setEntityError('comment', findId, 'Failed to load comments');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comment to a find
|
||||
*/
|
||||
async addComment(findId: string, content: string): Promise<void> {
|
||||
// Optimistic update: add temporary comment
|
||||
const tempComment: CommentState = {
|
||||
id: `temp-${Date.now()}`,
|
||||
findId,
|
||||
content,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
user: {
|
||||
id: 'current-user',
|
||||
username: 'You',
|
||||
profilePictureUrl: undefined
|
||||
}
|
||||
};
|
||||
|
||||
this.addCommentToState(findId, tempComment);
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('comment', findId, 'create', undefined, { content });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment
|
||||
*/
|
||||
async deleteComment(commentId: string, findId: string): Promise<void> {
|
||||
// Optimistic update: remove comment
|
||||
this.removeCommentFromState(commentId, { findId });
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('comment', commentId, 'delete', undefined, { findId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize find data from server (only if no existing state)
|
||||
*/
|
||||
initializeFindData(finds: FindState[]): void {
|
||||
for (const find of finds) {
|
||||
this.initializeEntityState('find', find.id, find);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize comments data for a find
|
||||
*/
|
||||
initializeCommentsData(findId: string, comments: CommentState[]): void {
|
||||
this.initializeEntityState('comment', findId, comments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup unused subscriptions (call this when components unmount)
|
||||
*/
|
||||
cleanup(entityType: string, entityId: string): void {
|
||||
const key = `${entityType}:${entityId}`;
|
||||
const subscriptions = this.subscriptions.get(key);
|
||||
if (subscriptions) {
|
||||
subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.subscriptions.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove find from state after successful deletion
|
||||
*/
|
||||
private removeFindFromState(findId: string): void {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
newEntities.delete(findId);
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Also clean up associated comments
|
||||
const commentStore = this.getEntityStore('comment');
|
||||
commentStore.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
newEntities.delete(findId);
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a find
|
||||
*/
|
||||
async updateFind(
|
||||
findId: string,
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
locationName?: string | null;
|
||||
category?: string;
|
||||
isPublic?: boolean;
|
||||
media?: Array<{ type: string; url: string; thumbnailUrl?: string }>;
|
||||
mediaToDelete?: string[];
|
||||
}
|
||||
): Promise<void> {
|
||||
// Optimistically update the find state
|
||||
const currentState = this.getEntityState<FindState>('find', findId);
|
||||
if (currentState) {
|
||||
const updatedFind: FindState = {
|
||||
...currentState,
|
||||
...(data.title !== undefined && { title: data.title }),
|
||||
...(data.description !== undefined && { description: data.description || undefined }),
|
||||
...(data.latitude !== undefined && { latitude: data.latitude.toString() }),
|
||||
...(data.longitude !== undefined && { longitude: data.longitude.toString() }),
|
||||
...(data.locationName !== undefined && { locationName: data.locationName || undefined }),
|
||||
...(data.category !== undefined && { category: data.category }),
|
||||
...(data.isPublic !== undefined && { isPublic: data.isPublic }),
|
||||
...(data.media !== undefined && {
|
||||
media: data.media.map((m, index) => ({
|
||||
id: (m as any).id || `temp-${index}`,
|
||||
findId: findId,
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || null,
|
||||
orderIndex: index
|
||||
}))
|
||||
})
|
||||
};
|
||||
this.setEntityState('find', findId, updatedFind, false);
|
||||
}
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('find', findId, 'update', undefined, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a find
|
||||
*/
|
||||
async deleteFind(findId: string): Promise<void> {
|
||||
// Optimistically remove find from state
|
||||
this.removeFindFromState(findId);
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('find', findId, 'delete', undefined, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all finds as an array
|
||||
*/
|
||||
subscribeAllFinds(): Readable<FindState[]> {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
return derived(store, ($entities) => {
|
||||
const finds: FindState[] = [];
|
||||
$entities.forEach((entity) => {
|
||||
if (entity.data) {
|
||||
finds.push(entity.data as FindState);
|
||||
}
|
||||
});
|
||||
// Sort by creation date, newest first
|
||||
return finds.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const apiSync = new APISync();
|
||||
@@ -43,6 +43,7 @@ export const shouldZoomToLocation = derived(
|
||||
locationStore,
|
||||
($location) => $location.shouldZoomToLocation
|
||||
);
|
||||
export const isWatching = derived(locationStore, ($location) => $location.isWatching);
|
||||
|
||||
// Location actions
|
||||
export const locationActions = {
|
||||
|
||||
41
src/lib/utils/distance.ts
Normal file
41
src/lib/utils/distance.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Calculate distance between two points using Haversine formula
|
||||
* @param lat1 Latitude of first point
|
||||
* @param lon1 Longitude of first point
|
||||
* @param lat2 Latitude of second point
|
||||
* @param lon2 Longitude of second point
|
||||
* @returns Distance in kilometers
|
||||
*/
|
||||
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371; // Radius of the Earth in kilometers
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const distance = R * c;
|
||||
|
||||
return distance;
|
||||
}
|
||||
|
||||
function toRad(degrees: number): number {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance for display
|
||||
* @param distance Distance in kilometers
|
||||
* @returns Formatted string
|
||||
*/
|
||||
export function formatDistance(distance: number): string {
|
||||
if (distance < 1) {
|
||||
return `${Math.round(distance * 1000)}m`;
|
||||
} else if (distance < 10) {
|
||||
return `${distance.toFixed(1)}km`;
|
||||
} else {
|
||||
return `${Math.round(distance)}km`;
|
||||
}
|
||||
}
|
||||
194
src/lib/utils/places.ts
Normal file
194
src/lib/utils/places.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
export interface PlaceResult {
|
||||
placeId: string;
|
||||
name: string;
|
||||
formattedAddress: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
types: string[];
|
||||
vicinity?: string;
|
||||
rating?: number;
|
||||
priceLevel?: number;
|
||||
}
|
||||
|
||||
interface GooglePlacesPrediction {
|
||||
place_id: string;
|
||||
description: string;
|
||||
types: string[];
|
||||
}
|
||||
|
||||
interface GooglePlacesResult {
|
||||
place_id: string;
|
||||
name: string;
|
||||
formatted_address?: string;
|
||||
vicinity?: string;
|
||||
geometry: {
|
||||
location: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
};
|
||||
types?: string[];
|
||||
rating?: number;
|
||||
price_level?: number;
|
||||
}
|
||||
|
||||
export interface PlaceSearchOptions {
|
||||
query?: string;
|
||||
location?: { lat: number; lng: number };
|
||||
radius?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export class GooglePlacesService {
|
||||
private apiKey: string;
|
||||
private baseUrl = 'https://maps.googleapis.com/maps/api/place';
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for places using text query
|
||||
*/
|
||||
async searchPlaces(
|
||||
query: string,
|
||||
location?: { lat: number; lng: number }
|
||||
): Promise<PlaceResult[]> {
|
||||
const url = new URL(`${this.baseUrl}/textsearch/json`);
|
||||
url.searchParams.set('query', query);
|
||||
url.searchParams.set('key', this.apiKey);
|
||||
|
||||
if (location) {
|
||||
url.searchParams.set('location', `${location.lat},${location.lng}`);
|
||||
url.searchParams.set('radius', '50000'); // 50km radius
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString());
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'OK') {
|
||||
throw new Error(`Places API error: ${data.status}`);
|
||||
}
|
||||
|
||||
return data.results.map(this.formatPlaceResult);
|
||||
} catch (error) {
|
||||
console.error('Error searching places:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get place autocomplete suggestions
|
||||
*/
|
||||
async getAutocompleteSuggestions(
|
||||
input: string,
|
||||
location?: { lat: number; lng: number }
|
||||
): Promise<Array<{ placeId: string; description: string; types: string[] }>> {
|
||||
const url = new URL(`${this.baseUrl}/autocomplete/json`);
|
||||
url.searchParams.set('input', input);
|
||||
url.searchParams.set('key', this.apiKey);
|
||||
|
||||
if (location) {
|
||||
url.searchParams.set('location', `${location.lat},${location.lng}`);
|
||||
url.searchParams.set('radius', '50000');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString());
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
|
||||
throw new Error(`Places API error: ${data.status}`);
|
||||
}
|
||||
|
||||
return (
|
||||
data.predictions?.map((prediction: GooglePlacesPrediction) => ({
|
||||
placeId: prediction.place_id,
|
||||
description: prediction.description,
|
||||
types: prediction.types
|
||||
})) || []
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error getting autocomplete suggestions:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a place
|
||||
*/
|
||||
async getPlaceDetails(placeId: string): Promise<PlaceResult> {
|
||||
const url = new URL(`${this.baseUrl}/details/json`);
|
||||
url.searchParams.set('place_id', placeId);
|
||||
url.searchParams.set(
|
||||
'fields',
|
||||
'place_id,name,formatted_address,geometry,types,vicinity,rating,price_level'
|
||||
);
|
||||
url.searchParams.set('key', this.apiKey);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString());
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'OK') {
|
||||
throw new Error(`Places API error: ${data.status}`);
|
||||
}
|
||||
|
||||
return this.formatPlaceResult(data.result);
|
||||
} catch (error) {
|
||||
console.error('Error getting place details:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find nearby places
|
||||
*/
|
||||
async findNearbyPlaces(
|
||||
location: { lat: number; lng: number },
|
||||
radius: number = 5000,
|
||||
type?: string
|
||||
): Promise<PlaceResult[]> {
|
||||
const url = new URL(`${this.baseUrl}/nearbysearch/json`);
|
||||
url.searchParams.set('location', `${location.lat},${location.lng}`);
|
||||
url.searchParams.set('radius', radius.toString());
|
||||
url.searchParams.set('key', this.apiKey);
|
||||
|
||||
if (type) {
|
||||
url.searchParams.set('type', type);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString());
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
|
||||
throw new Error(`Places API error: ${data.status}`);
|
||||
}
|
||||
|
||||
return data.results?.map(this.formatPlaceResult) || [];
|
||||
} catch (error) {
|
||||
console.error('Error finding nearby places:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private formatPlaceResult = (place: GooglePlacesResult): PlaceResult => {
|
||||
return {
|
||||
placeId: place.place_id,
|
||||
name: place.name,
|
||||
formattedAddress: place.formatted_address || place.vicinity || '',
|
||||
latitude: place.geometry.location.lat,
|
||||
longitude: place.geometry.location.lng,
|
||||
types: place.types || [],
|
||||
vicinity: place.vicinity,
|
||||
rating: place.rating,
|
||||
priceLevel: place.price_level
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlacesService(apiKey: string): GooglePlacesService {
|
||||
return new GooglePlacesService(apiKey);
|
||||
}
|
||||
@@ -4,12 +4,12 @@
|
||||
import { Header } from '$lib';
|
||||
import { page } from '$app/state';
|
||||
import { Toaster } from '$lib/components/sonner/index.js';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
import LocationManager from '$lib/components/map/LocationManager.svelte';
|
||||
import NotificationManager from '$lib/components/notifications/NotificationManager.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
|
||||
let showHeader = $derived(!isLoginRoute && data?.user);
|
||||
let isLoading = $derived(!isLoginRoute && !data?.user && data !== null);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -32,42 +32,14 @@
|
||||
|
||||
<Toaster />
|
||||
|
||||
<!-- Auto-start location and notfication watching for authenticated users -->
|
||||
{#if data?.user && !isLoginRoute}
|
||||
<LocationManager autoStart={true} />
|
||||
<NotificationManager />
|
||||
{/if}
|
||||
|
||||
{#if showHeader && data.user}
|
||||
<Header user={data.user} />
|
||||
{:else if isLoading}
|
||||
<header class="header-skeleton">
|
||||
<div class="header-content">
|
||||
<Skeleton class="h-8 w-32" />
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
<style>
|
||||
.header-skeleton {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
padding: 0 20px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-skeleton {
|
||||
padding: 0 16px;
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
||||
if (!locals.user) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
// Build API URL with query parameters
|
||||
const apiUrl = new URL('/api/finds', url.origin);
|
||||
const apiUrl = new URL('/api/locations', url.origin);
|
||||
|
||||
// Forward location filtering parameters
|
||||
const lat = url.searchParams.get('lat');
|
||||
@@ -17,8 +12,13 @@ export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
||||
if (lat) apiUrl.searchParams.set('lat', lat);
|
||||
if (lng) apiUrl.searchParams.set('lng', lng);
|
||||
apiUrl.searchParams.set('radius', radius);
|
||||
apiUrl.searchParams.set('includePrivate', 'true'); // Include user's private finds
|
||||
apiUrl.searchParams.set('includeFriends', 'true'); // Include friends' finds
|
||||
|
||||
// Only include private and friends' finds if user is logged in
|
||||
if (locals.user) {
|
||||
apiUrl.searchParams.set('includePrivate', 'true'); // Include user's private finds
|
||||
apiUrl.searchParams.set('includeFriends', 'true'); // Include friends' finds
|
||||
}
|
||||
|
||||
apiUrl.searchParams.set('order', 'desc'); // Newest first
|
||||
|
||||
try {
|
||||
@@ -32,15 +32,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
||||
throw new Error(`API request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const finds = await response.json();
|
||||
const locations = await response.json();
|
||||
|
||||
return {
|
||||
finds
|
||||
locations
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error loading finds:', err);
|
||||
console.error('Error loading locations:', err);
|
||||
return {
|
||||
finds: []
|
||||
locations: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,186 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { Map } from '$lib';
|
||||
import FindsList from '$lib/components/FindsList.svelte';
|
||||
import CreateFindModal from '$lib/components/CreateFindModal.svelte';
|
||||
import FindPreview from '$lib/components/FindPreview.svelte';
|
||||
import FindsFilter from '$lib/components/FindsFilter.svelte';
|
||||
import {
|
||||
LocationsList,
|
||||
SelectLocationModal,
|
||||
LocationFindsModal
|
||||
} from '$lib/components/locations';
|
||||
import type { PageData } from './$types';
|
||||
import { coordinates } from '$lib/stores/location';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { calculateDistance } from '$lib/utils/distance';
|
||||
|
||||
// Server response type
|
||||
interface ServerFind {
|
||||
interface Find {
|
||||
id: string;
|
||||
locationId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string;
|
||||
category?: string;
|
||||
locationName?: string;
|
||||
isPublic: number;
|
||||
createdAt: string; // Will be converted to Date type, but is a string from api
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
likeCount?: number;
|
||||
isLikedByUser?: boolean;
|
||||
media: Array<{
|
||||
isFromFriend?: boolean;
|
||||
createdAt: string;
|
||||
media?: Array<{
|
||||
id: string;
|
||||
findId: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
orderIndex: number | null;
|
||||
thumbnailUrl: string;
|
||||
orderIndex?: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Map component type
|
||||
interface MapFind {
|
||||
interface Location {
|
||||
id: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
findCount: number;
|
||||
finds?: Find[];
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
interface MapLocation {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string;
|
||||
category?: string;
|
||||
isPublic: number;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
media?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Interface for FindPreview component
|
||||
interface FindPreviewData {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string;
|
||||
category?: string;
|
||||
createdAt: string;
|
||||
user: {
|
||||
finds: Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
media?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
isPublic: number;
|
||||
media?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
}>;
|
||||
}>;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
let { data }: { data: PageData & { finds?: ServerFind[] } } = $props();
|
||||
let { data }: { data: PageData & { locations?: Location[] } } = $props();
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let selectedFind: FindPreviewData | null = $state(null);
|
||||
let currentFilter = $state('all');
|
||||
let showCreateFindModal = $state(false);
|
||||
let showLocationFindsModal = $state(false);
|
||||
let selectedLocation: Location | null = $state(null);
|
||||
let isSidebarVisible = $state(true);
|
||||
|
||||
// All finds - convert server format to component format
|
||||
let allFinds = $derived(
|
||||
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({
|
||||
...serverFind,
|
||||
createdAt: new Date(serverFind.createdAt), // Convert string to Date
|
||||
user: {
|
||||
id: serverFind.userId,
|
||||
username: serverFind.username,
|
||||
profilePictureUrl: serverFind.profilePictureUrl
|
||||
},
|
||||
likeCount: serverFind.likeCount,
|
||||
isLiked: serverFind.isLikedByUser,
|
||||
media: serverFind.media?.map(
|
||||
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || m.url
|
||||
})
|
||||
)
|
||||
})) as MapFind[]
|
||||
);
|
||||
// Process locations with distance
|
||||
let locations = $derived.by(() => {
|
||||
if (!data.locations || !$coordinates) return data.locations || [];
|
||||
|
||||
// Filtered finds based on current filter
|
||||
let finds = $derived.by(() => {
|
||||
if (!data.user) return allFinds;
|
||||
|
||||
switch (currentFilter) {
|
||||
case 'public':
|
||||
return allFinds.filter((find) => find.isPublic === 1);
|
||||
case 'friends':
|
||||
return allFinds.filter((find) => find.isPublic === 0 && find.userId !== data.user!.id);
|
||||
case 'mine':
|
||||
return allFinds.filter((find) => find.userId === data.user!.id);
|
||||
case 'all':
|
||||
default:
|
||||
return allFinds;
|
||||
}
|
||||
return data.locations
|
||||
.map((loc: Location) => ({
|
||||
...loc,
|
||||
distance: calculateDistance(
|
||||
$coordinates.latitude,
|
||||
$coordinates.longitude,
|
||||
parseFloat(loc.latitude),
|
||||
parseFloat(loc.longitude)
|
||||
)
|
||||
}))
|
||||
.sort(
|
||||
(a: Location & { distance?: number }, b: Location & { distance?: number }) =>
|
||||
(a.distance || 0) - (b.distance || 0)
|
||||
);
|
||||
});
|
||||
|
||||
function handleFilterChange(filter: string) {
|
||||
currentFilter = filter;
|
||||
}
|
||||
// Convert locations to map markers - keep the full location object
|
||||
let mapLocations: MapLocation[] = $derived(
|
||||
locations.map(
|
||||
(loc: Location): MapLocation => ({
|
||||
id: loc.id,
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
createdAt: new Date(loc.createdAt),
|
||||
userId: loc.userId,
|
||||
user: {
|
||||
id: loc.userId,
|
||||
username: loc.username
|
||||
},
|
||||
finds: (loc.finds || []).map((find) => ({
|
||||
id: find.id,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
isPublic: find.isPublic,
|
||||
media: find.media || []
|
||||
})),
|
||||
distance: loc.distance
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
function handleFindCreated(event: CustomEvent) {
|
||||
// For now, just close modal and refresh page as in original implementation
|
||||
showCreateModal = false;
|
||||
if (event.detail?.reload) {
|
||||
window.location.reload();
|
||||
function handleLocationExplore(id: string) {
|
||||
const location = locations.find((l: Location) => l.id === id);
|
||||
if (location) {
|
||||
selectedLocation = location;
|
||||
showLocationFindsModal = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFindClick(find: MapFind) {
|
||||
// Convert MapFind to FindPreviewData format
|
||||
selectedFind = {
|
||||
id: find.id,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
latitude: find.latitude,
|
||||
longitude: find.longitude,
|
||||
locationName: find.locationName,
|
||||
category: find.category,
|
||||
createdAt: find.createdAt.toISOString(),
|
||||
user: find.user,
|
||||
likeCount: find.likeCount,
|
||||
isLiked: find.isLiked,
|
||||
media: find.media?.map((m) => ({
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || m.url
|
||||
}))
|
||||
};
|
||||
function handleMapLocationClick(location: MapLocation) {
|
||||
handleLocationExplore(location.id);
|
||||
}
|
||||
|
||||
function handleFindExplore(id: string) {
|
||||
// Find the specific find and show preview
|
||||
const find = finds.find((f) => f.id === id);
|
||||
if (find) {
|
||||
handleFindClick(find);
|
||||
}
|
||||
function openCreateFindModal() {
|
||||
showCreateFindModal = true;
|
||||
}
|
||||
|
||||
function closeFindPreview() {
|
||||
selectedFind = null;
|
||||
function closeCreateFindModal() {
|
||||
showCreateFindModal = false;
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
showCreateModal = true;
|
||||
function closeLocationFindsModal() {
|
||||
showLocationFindsModal = false;
|
||||
selectedLocation = null;
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
showCreateModal = false;
|
||||
function handleFindCreated() {
|
||||
closeCreateFindModal();
|
||||
// Reload page to show new find
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function handleCreateFindFromLocation() {
|
||||
// Close location modal and open create find modal
|
||||
showLocationFindsModal = false;
|
||||
showCreateFindModal = true;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
isSidebarVisible = !isSidebarVisible;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -205,168 +186,280 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="home-container">
|
||||
<main class="main-content">
|
||||
<div class="map-section">
|
||||
<Map
|
||||
showLocationButton={true}
|
||||
autoCenter={true}
|
||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||
{finds}
|
||||
onFindClick={handleFindClick}
|
||||
/>
|
||||
</div>
|
||||
<!-- Fullscreen map -->
|
||||
<div class="map-section">
|
||||
<Map
|
||||
autoCenter={true}
|
||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||
locations={mapLocations}
|
||||
onLocationClick={handleMapLocationClick}
|
||||
sidebarVisible={isSidebarVisible}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="finds-section">
|
||||
<!-- Sidebar container -->
|
||||
<div class="sidebar-container">
|
||||
<!-- Left sidebar with locations list -->
|
||||
<div class="finds-sidebar" class:hidden={!isSidebarVisible}>
|
||||
<div class="finds-header">
|
||||
<div class="finds-title-section">
|
||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||
</div>
|
||||
<FindsList {finds} onFindExplore={handleFindExplore} />
|
||||
<Button onclick={openCreateModal} class="create-find-button">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
Create Find
|
||||
</Button>
|
||||
{#if data.user}
|
||||
<h3 class="header-title">Locations</h3>
|
||||
<Button onclick={openCreateFindModal} class="create-find-button">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
Create Find
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="login-prompt">
|
||||
<h3>Welcome to Serengo</h3>
|
||||
<p>Login to create finds and view your friends' discoveries</p>
|
||||
<a href="/login" class="login-button">Login</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="finds-list-container">
|
||||
<LocationsList {locations} onLocationExplore={handleLocationExplore} hideTitle={true} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Floating action button for mobile -->
|
||||
<button class="fab" onclick={openCreateModal} aria-label="Create new find">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Toggle button -->
|
||||
<button
|
||||
class="sidebar-toggle"
|
||||
class:collapsed={!isSidebarVisible}
|
||||
onclick={toggleSidebar}
|
||||
aria-label="Toggle locations list"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
{#if isSidebarVisible}
|
||||
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{:else}
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
{#if showCreateModal}
|
||||
<CreateFindModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={closeCreateModal}
|
||||
{#if showCreateFindModal}
|
||||
<SelectLocationModal
|
||||
isOpen={showCreateFindModal}
|
||||
onClose={closeCreateFindModal}
|
||||
onFindCreated={handleFindCreated}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if selectedFind}
|
||||
<FindPreview find={selectedFind} onClose={closeFindPreview} />
|
||||
{#if showLocationFindsModal && selectedLocation}
|
||||
<LocationFindsModal
|
||||
isOpen={showLocationFindsModal}
|
||||
location={selectedLocation}
|
||||
currentUserId={data.user?.id}
|
||||
onClose={closeLocationFindsModal}
|
||||
onCreateFind={handleCreateFindFromLocation}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.home-container {
|
||||
background-color: #f8f8f8;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 500px;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.finds-section {
|
||||
background: white;
|
||||
.map-section :global(.maplibregl-map) {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-left: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.finds-sidebar {
|
||||
width: 40%;
|
||||
max-width: 1000px;
|
||||
min-width: 500px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.finds-sidebar.hidden {
|
||||
display: none;
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.finds-header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.finds-title-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.login-prompt {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-prompt h3 {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.login-prompt p {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
.finds-list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:global(.create-find-button) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
:global(.mr-2) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.finds-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.finds-title-section {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
:global(.create-find-button) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fab {
|
||||
.sidebar-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-toggle.collapsed {
|
||||
margin: 12px auto;
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.finds-sidebar {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
min-width: 0;
|
||||
border-radius: 20px 20px 0 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.finds-sidebar.hidden {
|
||||
display: none;
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.finds-header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 300px;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.main-content {
|
||||
padding: 12px;
|
||||
.finds-sidebar {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.finds-section {
|
||||
.finds-header {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
||||
import { location, find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
||||
import { eq, and, sql, desc, or } from 'drizzle-orm';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
function generateFindId(): string {
|
||||
function generateId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||
return encodeBase64url(bytes);
|
||||
}
|
||||
|
||||
// GET endpoint now returns finds for a specific location
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const lat = url.searchParams.get('lat');
|
||||
const lng = url.searchParams.get('lng');
|
||||
const radius = url.searchParams.get('radius') || '50';
|
||||
const locationId = url.searchParams.get('locationId');
|
||||
const includePrivate = url.searchParams.get('includePrivate') === 'true';
|
||||
const order = url.searchParams.get('order') || 'desc';
|
||||
|
||||
const includeFriends = url.searchParams.get('includeFriends') === 'true';
|
||||
|
||||
if (!locationId) {
|
||||
throw error(400, 'locationId is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user's friends if needed
|
||||
// Get user's friends if needed and user is logged in
|
||||
let friendIds: string[] = [];
|
||||
if (includeFriends || includePrivate) {
|
||||
if (locals.user && (includeFriends || includePrivate)) {
|
||||
const friendships = await db
|
||||
.select({
|
||||
userId: friendship.userId,
|
||||
@@ -37,7 +35,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
.where(
|
||||
and(
|
||||
eq(friendship.status, 'accepted'),
|
||||
or(eq(friendship.userId, locals.user!.id), eq(friendship.friendId, locals.user!.id))
|
||||
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -47,12 +45,12 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
// Build privacy conditions
|
||||
const conditions = [sql`${find.isPublic} = 1`]; // Always include public finds
|
||||
|
||||
if (includePrivate) {
|
||||
if (locals.user && includePrivate) {
|
||||
// Include user's own finds (both public and private)
|
||||
conditions.push(sql`${find.userId} = ${locals.user!.id}`);
|
||||
conditions.push(sql`${find.userId} = ${locals.user.id}`);
|
||||
}
|
||||
|
||||
if (includeFriends && friendIds.length > 0) {
|
||||
if (locals.user && includeFriends && friendIds.length > 0) {
|
||||
// Include friends' finds (both public and private)
|
||||
conditions.push(
|
||||
sql`${find.userId} IN (${sql.join(
|
||||
@@ -62,40 +60,17 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const baseCondition = sql`(${sql.join(conditions, sql` OR `)})`;
|
||||
const privacyCondition = sql`(${sql.join(conditions, sql` OR `)})`;
|
||||
const whereConditions = and(eq(find.locationId, locationId), privacyCondition);
|
||||
|
||||
let whereConditions = baseCondition;
|
||||
|
||||
// Add location filtering if coordinates provided
|
||||
if (lat && lng) {
|
||||
const radiusKm = parseFloat(radius);
|
||||
const latOffset = radiusKm / 111;
|
||||
const lngOffset = radiusKm / (111 * Math.cos((parseFloat(lat) * Math.PI) / 180));
|
||||
|
||||
const locationConditions = and(
|
||||
baseCondition,
|
||||
sql`${find.latitude} BETWEEN ${parseFloat(lat) - latOffset} AND ${
|
||||
parseFloat(lat) + latOffset
|
||||
}`,
|
||||
sql`${find.longitude} BETWEEN ${parseFloat(lng) - lngOffset} AND ${
|
||||
parseFloat(lng) + lngOffset
|
||||
}`
|
||||
);
|
||||
|
||||
if (locationConditions) {
|
||||
whereConditions = locationConditions;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all finds with filtering, like counts, and user's liked status
|
||||
// Get all finds at this location with filtering, like counts, and user's liked status
|
||||
const finds = await db
|
||||
.select({
|
||||
id: find.id,
|
||||
locationId: find.locationId,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
latitude: find.latitude,
|
||||
longitude: find.longitude,
|
||||
locationName: find.locationName,
|
||||
locationName: location.locationName,
|
||||
category: find.category,
|
||||
isPublic: find.isPublic,
|
||||
createdAt: find.createdAt,
|
||||
@@ -103,19 +78,31 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl,
|
||||
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
|
||||
isLikedByUser: sql<boolean>`CASE WHEN EXISTS(
|
||||
SELECT 1 FROM ${findLike}
|
||||
WHERE ${findLike.findId} = ${find.id}
|
||||
AND ${findLike.userId} = ${locals.user.id}
|
||||
) THEN 1 ELSE 0 END`
|
||||
isLikedByUser: locals.user
|
||||
? sql<boolean>`CASE WHEN EXISTS(
|
||||
SELECT 1 FROM ${findLike}
|
||||
WHERE ${findLike.findId} = ${find.id}
|
||||
AND ${findLike.userId} = ${locals.user.id}
|
||||
) THEN 1 ELSE 0 END`
|
||||
: sql<boolean>`0`,
|
||||
isFromFriend: locals.user
|
||||
? sql<boolean>`CASE WHEN ${
|
||||
friendIds.length > 0
|
||||
? sql`${find.userId} IN (${sql.join(
|
||||
friendIds.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
: sql`FALSE`
|
||||
} THEN 1 ELSE 0 END`
|
||||
: sql<boolean>`0`
|
||||
})
|
||||
.from(find)
|
||||
.innerJoin(user, eq(find.userId, user.id))
|
||||
.innerJoin(location, eq(find.locationId, location.id))
|
||||
.leftJoin(findLike, eq(find.id, findLike.findId))
|
||||
.where(whereConditions)
|
||||
.groupBy(find.id, user.username, user.profilePictureUrl)
|
||||
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt)
|
||||
.limit(100);
|
||||
.groupBy(find.id, user.username, user.profilePictureUrl, location.locationName)
|
||||
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt);
|
||||
|
||||
// Get media for all finds
|
||||
const findIds = finds.map((f) => f.id);
|
||||
@@ -168,38 +155,32 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
// Generate signed URLs for all media items
|
||||
const mediaWithSignedUrls = await Promise.all(
|
||||
findMedia.map(async (mediaItem) => {
|
||||
// URLs in database are now paths, generate signed URLs directly
|
||||
const [signedUrl, signedThumbnailUrl] = await Promise.all([
|
||||
getSignedR2Url(mediaItem.url, 24 * 60 * 60), // 24 hours
|
||||
const localUrl = getLocalR2Url(mediaItem.url);
|
||||
const localThumbnailUrl =
|
||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||
? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60)
|
||||
: Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is
|
||||
]);
|
||||
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||
: mediaItem.thumbnailUrl;
|
||||
|
||||
return {
|
||||
...mediaItem,
|
||||
url: signedUrl,
|
||||
thumbnailUrl: signedThumbnailUrl
|
||||
url: localUrl,
|
||||
thumbnailUrl: localThumbnailUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate signed URL for user profile picture if it exists
|
||||
// Generate local proxy URL for user profile picture if it exists
|
||||
let userProfilePictureUrl = findItem.profilePictureUrl;
|
||||
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
||||
try {
|
||||
userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for user profile picture:', error);
|
||||
userProfilePictureUrl = null;
|
||||
}
|
||||
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
...findItem,
|
||||
profilePictureUrl: userProfilePictureUrl,
|
||||
media: mediaWithSignedUrls,
|
||||
isLikedByUser: Boolean(findItem.isLikedByUser)
|
||||
isLikedByUser: Boolean(findItem.isLikedByUser),
|
||||
isFromFriend: Boolean(findItem.isFromFriend)
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -211,16 +192,17 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// POST endpoint creates a find (post) at a location
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const { title, description, latitude, longitude, locationName, category, isPublic, media } = data;
|
||||
const { locationId, title, description, category, isPublic, media } = data;
|
||||
|
||||
if (!title || !latitude || !longitude) {
|
||||
throw error(400, 'Title, latitude, and longitude are required');
|
||||
if (!title || !locationId) {
|
||||
throw error(400, 'Title and locationId are required');
|
||||
}
|
||||
|
||||
if (title.length > 100) {
|
||||
@@ -231,19 +213,28 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
throw error(400, 'Description must be 500 characters or less');
|
||||
}
|
||||
|
||||
const findId = generateFindId();
|
||||
// Verify location exists
|
||||
const locationExists = await db
|
||||
.select({ id: location.id })
|
||||
.from(location)
|
||||
.where(eq(location.id, locationId))
|
||||
.limit(1);
|
||||
|
||||
if (locationExists.length === 0) {
|
||||
throw error(404, 'Location not found');
|
||||
}
|
||||
|
||||
const findId = generateId();
|
||||
|
||||
// Create find
|
||||
const newFind = await db
|
||||
.insert(find)
|
||||
.values({
|
||||
id: findId,
|
||||
locationId,
|
||||
userId: locals.user.id,
|
||||
title,
|
||||
description,
|
||||
latitude: latitude.toString(),
|
||||
longitude: longitude.toString(),
|
||||
locationName,
|
||||
category,
|
||||
isPublic: isPublic ? 1 : 0
|
||||
})
|
||||
@@ -253,7 +244,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (media && media.length > 0) {
|
||||
const mediaRecords = media.map(
|
||||
(item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
|
||||
id: generateFindId(),
|
||||
id: generateId(),
|
||||
findId,
|
||||
type: item.type,
|
||||
url: item.url,
|
||||
|
||||
302
src/routes/api/finds/[findId]/+server.ts
Normal file
302
src/routes/api/finds/[findId]/+server.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { find, findMedia, user, findLike, findComment, location } from '$lib/server/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { getLocalR2Url, deleteFromR2 } from '$lib/server/r2';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const findId = params.findId;
|
||||
|
||||
if (!findId) {
|
||||
throw error(400, 'Find ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the find with user info and like count
|
||||
const findResult = await db
|
||||
.select({
|
||||
id: find.id,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
locationName: location.locationName,
|
||||
category: find.category,
|
||||
isPublic: find.isPublic,
|
||||
createdAt: find.createdAt,
|
||||
userId: find.userId,
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl,
|
||||
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
|
||||
commentCount: sql<number>`COALESCE((
|
||||
SELECT COUNT(*) FROM ${findComment}
|
||||
WHERE ${findComment.findId} = ${find.id}
|
||||
), 0)`,
|
||||
isLikedByUser: locals.user
|
||||
? sql<boolean>`CASE WHEN EXISTS(
|
||||
SELECT 1 FROM ${findLike}
|
||||
WHERE ${findLike.findId} = ${find.id}
|
||||
AND ${findLike.userId} = ${locals.user.id}
|
||||
) THEN 1 ELSE 0 END`
|
||||
: sql<boolean>`0`
|
||||
})
|
||||
.from(find)
|
||||
.innerJoin(location, eq(find.locationId, location.id))
|
||||
.innerJoin(user, eq(find.userId, user.id))
|
||||
.leftJoin(findLike, eq(find.id, findLike.findId))
|
||||
.where(eq(find.id, findId))
|
||||
.groupBy(
|
||||
find.id,
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
location.locationName,
|
||||
user.username,
|
||||
user.profilePictureUrl
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (findResult.length === 0) {
|
||||
throw error(404, 'Find not found');
|
||||
}
|
||||
|
||||
const findData = findResult[0];
|
||||
|
||||
// Check if the find is public or if user has access
|
||||
const isOwner = locals.user && findData.userId === locals.user.id;
|
||||
const isPublic = findData.isPublic === 1;
|
||||
|
||||
if (!isPublic && !isOwner) {
|
||||
throw error(403, 'This find is private');
|
||||
}
|
||||
|
||||
// Get media for the find
|
||||
const media = await db
|
||||
.select({
|
||||
id: findMedia.id,
|
||||
findId: findMedia.findId,
|
||||
type: findMedia.type,
|
||||
url: findMedia.url,
|
||||
thumbnailUrl: findMedia.thumbnailUrl,
|
||||
orderIndex: findMedia.orderIndex
|
||||
})
|
||||
.from(findMedia)
|
||||
.where(eq(findMedia.findId, findId))
|
||||
.orderBy(findMedia.orderIndex);
|
||||
|
||||
// Generate signed URLs for media
|
||||
const mediaWithSignedUrls = await Promise.all(
|
||||
media.map(async (mediaItem) => {
|
||||
const localUrl = getLocalR2Url(mediaItem.url);
|
||||
const localThumbnailUrl =
|
||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||
: mediaItem.thumbnailUrl;
|
||||
|
||||
return {
|
||||
...mediaItem,
|
||||
url: localUrl,
|
||||
thumbnailUrl: localThumbnailUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate local proxy URL for user profile picture
|
||||
let userProfilePictureUrl = findData.profilePictureUrl;
|
||||
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
||||
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
|
||||
}
|
||||
|
||||
return json({
|
||||
...findData,
|
||||
profilePictureUrl: userProfilePictureUrl,
|
||||
media: mediaWithSignedUrls,
|
||||
isLikedByUser: Boolean(findData.isLikedByUser)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading find:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to load find');
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const findId = params.findId;
|
||||
|
||||
if (!findId) {
|
||||
throw error(400, 'Find ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// First, verify the find exists and user owns it
|
||||
const existingFind = await db
|
||||
.select({ userId: find.userId })
|
||||
.from(find)
|
||||
.where(eq(find.id, findId))
|
||||
.limit(1);
|
||||
|
||||
if (existingFind.length === 0) {
|
||||
throw error(404, 'Find not found');
|
||||
}
|
||||
|
||||
if (existingFind[0].userId !== locals.user.id) {
|
||||
throw error(403, 'You do not have permission to edit this find');
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const data = await request.json();
|
||||
const { title, description, category, isPublic, media, mediaToDelete } = data;
|
||||
|
||||
// Validate required fields
|
||||
if (!title) {
|
||||
throw error(400, 'Title is required');
|
||||
}
|
||||
|
||||
if (title.length > 100) {
|
||||
throw error(400, 'Title must be 100 characters or less');
|
||||
}
|
||||
|
||||
if (description && description.length > 500) {
|
||||
throw error(400, 'Description must be 500 characters or less');
|
||||
}
|
||||
|
||||
// Delete media items if specified
|
||||
if (mediaToDelete && Array.isArray(mediaToDelete) && mediaToDelete.length > 0) {
|
||||
// Get media URLs before deleting from database
|
||||
const mediaToRemove = await db
|
||||
.select({ url: findMedia.url, thumbnailUrl: findMedia.thumbnailUrl })
|
||||
.from(findMedia)
|
||||
.where(
|
||||
sql`${findMedia.id} IN (${sql.join(
|
||||
mediaToDelete.map((id: string) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
);
|
||||
|
||||
// Delete from R2 storage
|
||||
for (const mediaItem of mediaToRemove) {
|
||||
try {
|
||||
await deleteFromR2(mediaItem.url);
|
||||
if (mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')) {
|
||||
await deleteFromR2(mediaItem.thumbnailUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting media from R2:', err);
|
||||
// Continue even if R2 deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await db.delete(findMedia).where(
|
||||
sql`${findMedia.id} IN (${sql.join(
|
||||
mediaToDelete.map((id: string) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
);
|
||||
}
|
||||
|
||||
// Update the find
|
||||
const updatedFind = await db
|
||||
.update(find)
|
||||
.set({
|
||||
title,
|
||||
description: description || null,
|
||||
category: category || null,
|
||||
isPublic: isPublic ? 1 : 0,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(find.id, findId))
|
||||
.returning();
|
||||
|
||||
// Add new media records if provided
|
||||
if (media && Array.isArray(media) && media.length > 0) {
|
||||
const newMediaRecords = media
|
||||
.filter((item: { id?: string }) => !item.id) // Only insert media without IDs (new uploads)
|
||||
.map((item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
|
||||
id: crypto.randomUUID(),
|
||||
findId,
|
||||
type: item.type,
|
||||
url: item.url,
|
||||
thumbnailUrl: item.thumbnailUrl || null,
|
||||
orderIndex: index
|
||||
}));
|
||||
|
||||
if (newMediaRecords.length > 0) {
|
||||
await db.insert(findMedia).values(newMediaRecords);
|
||||
}
|
||||
}
|
||||
|
||||
return json({ success: true, find: updatedFind[0] });
|
||||
} catch (err) {
|
||||
console.error('Error updating find:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to update find');
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const findId = params.findId;
|
||||
|
||||
if (!findId) {
|
||||
throw error(400, 'Find ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// First, verify the find exists and user owns it
|
||||
const existingFind = await db
|
||||
.select({ userId: find.userId })
|
||||
.from(find)
|
||||
.where(eq(find.id, findId))
|
||||
.limit(1);
|
||||
|
||||
if (existingFind.length === 0) {
|
||||
throw error(404, 'Find not found');
|
||||
}
|
||||
|
||||
if (existingFind[0].userId !== locals.user.id) {
|
||||
throw error(403, 'You do not have permission to delete this find');
|
||||
}
|
||||
|
||||
// Get all media for this find to delete from R2
|
||||
const mediaItems = await db
|
||||
.select({ url: findMedia.url, thumbnailUrl: findMedia.thumbnailUrl })
|
||||
.from(findMedia)
|
||||
.where(eq(findMedia.findId, findId));
|
||||
|
||||
// Delete media from R2 storage
|
||||
for (const mediaItem of mediaItems) {
|
||||
try {
|
||||
await deleteFromR2(mediaItem.url);
|
||||
if (mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')) {
|
||||
await deleteFromR2(mediaItem.thumbnailUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting media from R2:', err);
|
||||
// Continue even if R2 deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the find (cascade will handle media, likes, and comments)
|
||||
await db.delete(find).where(eq(find.id, findId));
|
||||
|
||||
return json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error deleting find:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to delete find');
|
||||
}
|
||||
};
|
||||
172
src/routes/api/finds/[findId]/comments/+server.ts
Normal file
172
src/routes/api/finds/[findId]/comments/+server.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { findComment, user, find } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
import { pushService } from '$lib/server/push';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const session = locals.session;
|
||||
if (!session) {
|
||||
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const findId = params.findId;
|
||||
if (!findId) {
|
||||
return json({ success: false, error: 'Find ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const comments = await db
|
||||
.select({
|
||||
id: findComment.id,
|
||||
findId: findComment.findId,
|
||||
content: findComment.content,
|
||||
createdAt: findComment.createdAt,
|
||||
updatedAt: findComment.updatedAt,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl
|
||||
}
|
||||
})
|
||||
.from(findComment)
|
||||
.innerJoin(user, eq(findComment.userId, user.id))
|
||||
.where(eq(findComment.findId, findId))
|
||||
.orderBy(desc(findComment.createdAt));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: comments,
|
||||
count: comments.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching comments:', error);
|
||||
return json({ success: false, error: 'Failed to fetch comments' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals, request }) => {
|
||||
const session = locals.session;
|
||||
if (!session) {
|
||||
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const findId = params.findId;
|
||||
if (!findId) {
|
||||
return json({ success: false, error: 'Find ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { content } = body;
|
||||
|
||||
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
||||
return json({ success: false, error: 'Comment content is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (content.length > 500) {
|
||||
return json(
|
||||
{ success: false, error: 'Comment too long (max 500 characters)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const commentId = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.insert(findComment)
|
||||
.values({
|
||||
id: commentId,
|
||||
findId,
|
||||
userId: session.userId,
|
||||
content: content.trim(),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
const commentWithUser = await db
|
||||
.select({
|
||||
id: findComment.id,
|
||||
findId: findComment.findId,
|
||||
content: findComment.content,
|
||||
createdAt: findComment.createdAt,
|
||||
updatedAt: findComment.updatedAt,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl
|
||||
}
|
||||
})
|
||||
.from(findComment)
|
||||
.innerJoin(user, eq(findComment.userId, user.id))
|
||||
.where(eq(findComment.id, commentId))
|
||||
.limit(1);
|
||||
|
||||
if (commentWithUser.length === 0) {
|
||||
return json({ success: false, error: 'Failed to create comment' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Send notification to find owner if not self-comment
|
||||
const findData = await db.select().from(find).where(eq(find.id, findId)).limit(1);
|
||||
|
||||
if (findData.length > 0 && findData[0].userId !== session.userId) {
|
||||
const findOwner = findData[0];
|
||||
const shouldNotify = await notificationService.shouldNotify(
|
||||
findOwner.userId,
|
||||
'find_commented'
|
||||
);
|
||||
|
||||
if (shouldNotify) {
|
||||
// Get commenter's username
|
||||
const commenterUser = await db
|
||||
.select({ username: user.username })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.userId))
|
||||
.limit(1);
|
||||
|
||||
const commenterUsername = commenterUser[0]?.username || 'Someone';
|
||||
const findTitle = findOwner.title || 'your find';
|
||||
|
||||
await notificationService.createNotification({
|
||||
userId: findOwner.userId,
|
||||
type: 'find_commented',
|
||||
title: 'New comment on your find',
|
||||
message: `${commenterUsername} commented on: ${findTitle}`,
|
||||
data: {
|
||||
findId: findOwner.id,
|
||||
commentId,
|
||||
commenterId: session.userId,
|
||||
commenterUsername,
|
||||
findTitle,
|
||||
commentContent: content.trim().substring(0, 100)
|
||||
}
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
await pushService.sendPushNotification(findOwner.userId, {
|
||||
title: 'New comment on your find',
|
||||
message: `${commenterUsername} commented on: ${findTitle}`,
|
||||
url: `/?find=${findOwner.id}`,
|
||||
tag: 'find_commented',
|
||||
data: {
|
||||
findId: findOwner.id,
|
||||
commentId,
|
||||
commenterId: session.userId
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: commentWithUser[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating comment:', error);
|
||||
return json({ success: false, error: 'Failed to create comment' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { findLike, find } from '$lib/server/db/schema';
|
||||
import { findLike, find, user } from '$lib/server/db/schema';
|
||||
import { eq, and, count } from 'drizzle-orm';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
import { pushService } from '$lib/server/push';
|
||||
|
||||
function generateLikeId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||
@@ -59,6 +61,49 @@ export async function POST({
|
||||
|
||||
const likeCount = likeCountResult[0]?.count ?? 0;
|
||||
|
||||
// Send notification to find owner if not self-like
|
||||
const findOwner = existingFind[0];
|
||||
if (findOwner.userId !== locals.user.id) {
|
||||
const shouldNotify = await notificationService.shouldNotify(findOwner.userId, 'find_liked');
|
||||
|
||||
if (shouldNotify) {
|
||||
// Get liker's username
|
||||
const likerUser = await db
|
||||
.select({ username: user.username })
|
||||
.from(user)
|
||||
.where(eq(user.id, locals.user.id))
|
||||
.limit(1);
|
||||
|
||||
const likerUsername = likerUser[0]?.username || 'Someone';
|
||||
const findTitle = findOwner.title || 'your find';
|
||||
|
||||
await notificationService.createNotification({
|
||||
userId: findOwner.userId,
|
||||
type: 'find_liked',
|
||||
title: 'Someone liked your find',
|
||||
message: `${likerUsername} liked your find: ${findTitle}`,
|
||||
data: {
|
||||
findId: findOwner.id,
|
||||
likerId: locals.user.id,
|
||||
likerUsername,
|
||||
findTitle
|
||||
}
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
await pushService.sendPushNotification(findOwner.userId, {
|
||||
title: 'Someone liked your find',
|
||||
message: `${likerUsername} liked your find: ${findTitle}`,
|
||||
url: `/?find=${findOwner.id}`,
|
||||
tag: 'find_liked',
|
||||
data: {
|
||||
findId: findOwner.id,
|
||||
likerId: locals.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
likeId,
|
||||
|
||||
68
src/routes/api/finds/[findId]/media/[mediaId]/+server.ts
Normal file
68
src/routes/api/finds/[findId]/media/[mediaId]/+server.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { find, findMedia } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { deleteFromR2 } from '$lib/server/r2';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const { findId, mediaId } = params;
|
||||
|
||||
if (!findId || !mediaId) {
|
||||
throw error(400, 'Find ID and Media ID are required');
|
||||
}
|
||||
|
||||
try {
|
||||
// First, verify the find exists and user owns it
|
||||
const existingFind = await db
|
||||
.select({ userId: find.userId })
|
||||
.from(find)
|
||||
.where(eq(find.id, findId))
|
||||
.limit(1);
|
||||
|
||||
if (existingFind.length === 0) {
|
||||
throw error(404, 'Find not found');
|
||||
}
|
||||
|
||||
if (existingFind[0].userId !== locals.user.id) {
|
||||
throw error(403, 'You do not have permission to delete this media');
|
||||
}
|
||||
|
||||
// Get the media item to delete
|
||||
const mediaItem = await db
|
||||
.select({ url: findMedia.url, thumbnailUrl: findMedia.thumbnailUrl })
|
||||
.from(findMedia)
|
||||
.where(and(eq(findMedia.id, mediaId), eq(findMedia.findId, findId)))
|
||||
.limit(1);
|
||||
|
||||
if (mediaItem.length === 0) {
|
||||
throw error(404, 'Media not found');
|
||||
}
|
||||
|
||||
// Delete from R2 storage
|
||||
try {
|
||||
await deleteFromR2(mediaItem[0].url);
|
||||
if (mediaItem[0].thumbnailUrl && !mediaItem[0].thumbnailUrl.startsWith('/')) {
|
||||
await deleteFromR2(mediaItem[0].thumbnailUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting media from R2:', err);
|
||||
// Continue even if R2 deletion fails
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await db.delete(findMedia).where(eq(findMedia.id, mediaId));
|
||||
|
||||
return json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error deleting media:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to delete media');
|
||||
}
|
||||
};
|
||||
46
src/routes/api/finds/comments/[commentId]/+server.ts
Normal file
46
src/routes/api/finds/comments/[commentId]/+server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { findComment } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const session = locals.session;
|
||||
if (!session) {
|
||||
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const commentId = params.commentId;
|
||||
if (!commentId) {
|
||||
return json({ success: false, error: 'Comment ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const existingComment = await db
|
||||
.select()
|
||||
.from(findComment)
|
||||
.where(eq(findComment.id, commentId))
|
||||
.limit(1);
|
||||
|
||||
if (existingComment.length === 0) {
|
||||
return json({ success: false, error: 'Comment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (existingComment[0].userId !== session.userId) {
|
||||
return json(
|
||||
{ success: false, error: 'Not authorized to delete this comment' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(findComment).where(eq(findComment.id, commentId));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: { id: commentId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting comment:', error);
|
||||
return json({ success: false, error: 'Failed to delete comment' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,9 @@ import { db } from '$lib/server/db';
|
||||
import { friendship, user } from '$lib/server/db/schema';
|
||||
import { eq, and, or } from 'drizzle-orm';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
import { pushService } from '$lib/server/push';
|
||||
|
||||
function generateFriendshipId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||
@@ -24,30 +26,46 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
|
||||
if (type === 'friends') {
|
||||
// Get accepted friendships where user is either sender or receiver
|
||||
friendships = await db
|
||||
const friendshipsRaw = await db
|
||||
.select({
|
||||
id: friendship.id,
|
||||
userId: friendship.userId,
|
||||
friendId: friendship.friendId,
|
||||
status: friendship.status,
|
||||
createdAt: friendship.createdAt,
|
||||
friendUsername: user.username,
|
||||
friendProfilePictureUrl: user.profilePictureUrl
|
||||
createdAt: friendship.createdAt
|
||||
})
|
||||
.from(friendship)
|
||||
.innerJoin(
|
||||
user,
|
||||
or(
|
||||
and(eq(friendship.friendId, user.id), eq(friendship.userId, locals.user.id)),
|
||||
and(eq(friendship.userId, user.id), eq(friendship.friendId, locals.user.id))
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(friendship.status, status),
|
||||
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
|
||||
)
|
||||
);
|
||||
|
||||
// Get friend details for each friendship
|
||||
friendships = await Promise.all(
|
||||
friendshipsRaw.map(async (f) => {
|
||||
const friendUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
|
||||
const friendUser = await db
|
||||
.select({
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, friendUserId))
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
id: f.id,
|
||||
userId: f.userId,
|
||||
friendId: f.friendId,
|
||||
status: f.status,
|
||||
createdAt: f.createdAt,
|
||||
friendUsername: friendUser[0]?.username || '',
|
||||
friendProfilePictureUrl: friendUser[0]?.profilePictureUrl || null
|
||||
};
|
||||
})
|
||||
);
|
||||
} else if (type === 'sent') {
|
||||
// Get friend requests sent by current user
|
||||
friendships = await db
|
||||
@@ -80,25 +98,18 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
.where(and(eq(friendship.friendId, locals.user.id), eq(friendship.status, status)));
|
||||
}
|
||||
|
||||
// Generate signed URLs for profile pictures
|
||||
const friendshipsWithSignedUrls = await Promise.all(
|
||||
(friendships || []).map(async (friendship) => {
|
||||
let profilePictureUrl = friendship.friendProfilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
try {
|
||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for profile picture:', error);
|
||||
profilePictureUrl = null;
|
||||
}
|
||||
}
|
||||
// Generate local proxy URLs for profile pictures
|
||||
const friendshipsWithSignedUrls = (friendships || []).map((friendship) => {
|
||||
let profilePictureUrl = friendship.friendProfilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
...friendship,
|
||||
friendProfilePictureUrl: profilePictureUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
return {
|
||||
...friendship,
|
||||
friendProfilePictureUrl: profilePictureUrl
|
||||
};
|
||||
});
|
||||
|
||||
return json(friendshipsWithSignedUrls);
|
||||
} catch (err) {
|
||||
@@ -164,6 +175,34 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Send notification to friend
|
||||
const shouldNotify = await notificationService.shouldNotify(friendId, 'friend_request');
|
||||
if (shouldNotify) {
|
||||
await notificationService.createNotification({
|
||||
userId: friendId,
|
||||
type: 'friend_request',
|
||||
title: 'New friend request',
|
||||
message: `${locals.user.username} sent you a friend request`,
|
||||
data: {
|
||||
friendshipId: newFriendship[0].id,
|
||||
senderId: locals.user.id,
|
||||
senderUsername: locals.user.username
|
||||
}
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
await pushService.sendPushNotification(friendId, {
|
||||
title: 'New friend request',
|
||||
message: `${locals.user.username} sent you a friend request`,
|
||||
url: '/friends',
|
||||
tag: 'friend_request',
|
||||
data: {
|
||||
friendshipId: newFriendship[0].id,
|
||||
senderId: locals.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return json({ success: true, friendship: newFriendship[0] });
|
||||
} catch (err) {
|
||||
console.error('Error sending friend request:', err);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { friendship } from '$lib/server/db/schema';
|
||||
import { friendship, user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
import { pushService } from '$lib/server/push';
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user) {
|
||||
@@ -72,6 +74,47 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
.where(eq(friendship.id, friendshipId))
|
||||
.returning();
|
||||
|
||||
// Send notification if friend request was accepted
|
||||
if (action === 'accept') {
|
||||
const senderId = friendshipRecord.userId;
|
||||
const shouldNotify = await notificationService.shouldNotify(senderId, 'friend_accepted');
|
||||
|
||||
if (shouldNotify) {
|
||||
// Get accepter's username
|
||||
const accepterUser = await db
|
||||
.select({ username: user.username })
|
||||
.from(user)
|
||||
.where(eq(user.id, locals.user.id))
|
||||
.limit(1);
|
||||
|
||||
const accepterUsername = accepterUser[0]?.username || 'Someone';
|
||||
|
||||
await notificationService.createNotification({
|
||||
userId: senderId,
|
||||
type: 'friend_accepted',
|
||||
title: 'Friend request accepted',
|
||||
message: `${accepterUsername} accepted your friend request`,
|
||||
data: {
|
||||
friendshipId: friendshipRecord.id,
|
||||
accepterId: locals.user.id,
|
||||
accepterUsername
|
||||
}
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
await pushService.sendPushNotification(senderId, {
|
||||
title: 'Friend request accepted',
|
||||
message: `${accepterUsername} accepted your friend request`,
|
||||
url: '/friends',
|
||||
tag: 'friend_accepted',
|
||||
data: {
|
||||
friendshipId: friendshipRecord.id,
|
||||
accepterId: locals.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({ success: true, friendship: updatedFriendship[0] });
|
||||
} catch (err) {
|
||||
console.error('Error updating friendship:', err);
|
||||
|
||||
263
src/routes/api/locations/+server.ts
Normal file
263
src/routes/api/locations/+server.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { location, find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
||||
import { eq, and, sql, desc, or } from 'drizzle-orm';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
function generateId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||
return encodeBase64url(bytes);
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const lat = url.searchParams.get('lat');
|
||||
const lng = url.searchParams.get('lng');
|
||||
const radius = url.searchParams.get('radius') || '50';
|
||||
const includePrivate = url.searchParams.get('includePrivate') === 'true';
|
||||
const order = url.searchParams.get('order') || 'desc';
|
||||
|
||||
const includeFriends = url.searchParams.get('includeFriends') === 'true';
|
||||
|
||||
try {
|
||||
// Get user's friends if needed and user is logged in
|
||||
let friendIds: string[] = [];
|
||||
if (locals.user && (includeFriends || includePrivate)) {
|
||||
const friendships = await db
|
||||
.select({
|
||||
userId: friendship.userId,
|
||||
friendId: friendship.friendId
|
||||
})
|
||||
.from(friendship)
|
||||
.where(
|
||||
and(
|
||||
eq(friendship.status, 'accepted'),
|
||||
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
|
||||
)
|
||||
);
|
||||
|
||||
friendIds = friendships.map((f) => (f.userId === locals.user!.id ? f.friendId : f.userId));
|
||||
}
|
||||
|
||||
// Build base condition for locations (always public since locations don't have privacy)
|
||||
let whereConditions = sql`1=1`;
|
||||
|
||||
// Add location filtering if coordinates provided
|
||||
if (lat && lng) {
|
||||
const radiusKm = parseFloat(radius);
|
||||
const latOffset = radiusKm / 111;
|
||||
const lngOffset = radiusKm / (111 * Math.cos((parseFloat(lat) * Math.PI) / 180));
|
||||
|
||||
whereConditions = and(
|
||||
whereConditions,
|
||||
sql`${location.latitude} BETWEEN ${parseFloat(lat) - latOffset} AND ${
|
||||
parseFloat(lat) + latOffset
|
||||
}`,
|
||||
sql`${location.longitude} BETWEEN ${parseFloat(lng) - lngOffset} AND ${
|
||||
parseFloat(lng) + lngOffset
|
||||
}`
|
||||
)!;
|
||||
}
|
||||
|
||||
// Get all locations with their find counts
|
||||
const locations = await db
|
||||
.select({
|
||||
id: location.id,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
locationName: location.locationName,
|
||||
createdAt: location.createdAt,
|
||||
userId: location.userId,
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl,
|
||||
findCount: sql<number>`COALESCE(COUNT(DISTINCT ${find.id}), 0)`
|
||||
})
|
||||
.from(location)
|
||||
.innerJoin(user, eq(location.userId, user.id))
|
||||
.leftJoin(find, eq(location.id, find.locationId))
|
||||
.where(whereConditions)
|
||||
.groupBy(location.id, user.username, user.profilePictureUrl)
|
||||
.orderBy(order === 'desc' ? desc(location.createdAt) : location.createdAt)
|
||||
.limit(100);
|
||||
|
||||
// For each location, get finds with privacy filtering
|
||||
const locationsWithFinds = await Promise.all(
|
||||
locations.map(async (loc) => {
|
||||
// Build privacy conditions for finds
|
||||
const findConditions = [sql`${find.isPublic} = 1`]; // Always include public finds
|
||||
|
||||
if (locals.user && includePrivate) {
|
||||
// Include user's own finds
|
||||
findConditions.push(sql`${find.userId} = ${locals.user.id}`);
|
||||
}
|
||||
|
||||
if (locals.user && includeFriends && friendIds.length > 0) {
|
||||
// Include friends' finds
|
||||
findConditions.push(
|
||||
sql`${find.userId} IN (${sql.join(
|
||||
friendIds.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
);
|
||||
}
|
||||
|
||||
const findPrivacyCondition = sql`(${sql.join(findConditions, sql` OR `)})`;
|
||||
|
||||
// Get finds for this location
|
||||
const finds = await db
|
||||
.select({
|
||||
id: find.id,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
category: find.category,
|
||||
isPublic: find.isPublic,
|
||||
createdAt: find.createdAt,
|
||||
userId: find.userId,
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl,
|
||||
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
|
||||
isLikedByUser: locals.user
|
||||
? sql<boolean>`CASE WHEN EXISTS(
|
||||
SELECT 1 FROM ${findLike}
|
||||
WHERE ${findLike.findId} = ${find.id}
|
||||
AND ${findLike.userId} = ${locals.user.id}
|
||||
) THEN 1 ELSE 0 END`
|
||||
: sql<boolean>`0`
|
||||
})
|
||||
.from(find)
|
||||
.innerJoin(user, eq(find.userId, user.id))
|
||||
.leftJoin(findLike, eq(find.id, findLike.findId))
|
||||
.where(and(eq(find.locationId, loc.id), findPrivacyCondition))
|
||||
.groupBy(find.id, user.username, user.profilePictureUrl)
|
||||
.orderBy(desc(find.createdAt));
|
||||
|
||||
// Get media for all finds at this location
|
||||
const findIds = finds.map((f) => f.id);
|
||||
let media: Array<{
|
||||
id: string;
|
||||
findId: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
orderIndex: number | null;
|
||||
}> = [];
|
||||
|
||||
if (findIds.length > 0) {
|
||||
media = await db
|
||||
.select({
|
||||
id: findMedia.id,
|
||||
findId: findMedia.findId,
|
||||
type: findMedia.type,
|
||||
url: findMedia.url,
|
||||
thumbnailUrl: findMedia.thumbnailUrl,
|
||||
orderIndex: findMedia.orderIndex
|
||||
})
|
||||
.from(findMedia)
|
||||
.where(
|
||||
sql`${findMedia.findId} IN (${sql.join(
|
||||
findIds.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
)
|
||||
.orderBy(findMedia.orderIndex);
|
||||
}
|
||||
|
||||
// Group media by find
|
||||
const mediaByFind = media.reduce(
|
||||
(acc, item) => {
|
||||
if (!acc[item.findId]) {
|
||||
acc[item.findId] = [];
|
||||
}
|
||||
acc[item.findId].push(item);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof media>
|
||||
);
|
||||
|
||||
// Combine finds with their media and generate signed URLs
|
||||
const findsWithMedia = await Promise.all(
|
||||
finds.map(async (findItem) => {
|
||||
const findMedia = mediaByFind[findItem.id] || [];
|
||||
|
||||
// Generate signed URLs for all media items
|
||||
const mediaWithSignedUrls = await Promise.all(
|
||||
findMedia.map(async (mediaItem) => {
|
||||
const localUrl = getLocalR2Url(mediaItem.url);
|
||||
const localThumbnailUrl =
|
||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||
: mediaItem.thumbnailUrl;
|
||||
|
||||
return {
|
||||
...mediaItem,
|
||||
url: localUrl,
|
||||
thumbnailUrl: localThumbnailUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate local proxy URL for user profile picture if it exists
|
||||
let userProfilePictureUrl = findItem.profilePictureUrl;
|
||||
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
||||
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
...findItem,
|
||||
profilePictureUrl: userProfilePictureUrl,
|
||||
media: mediaWithSignedUrls,
|
||||
isLikedByUser: Boolean(findItem.isLikedByUser)
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate local proxy URL for location creator profile picture
|
||||
let locProfilePictureUrl = loc.profilePictureUrl;
|
||||
if (locProfilePictureUrl && !locProfilePictureUrl.startsWith('http')) {
|
||||
locProfilePictureUrl = getLocalR2Url(locProfilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
...loc,
|
||||
profilePictureUrl: locProfilePictureUrl,
|
||||
finds: findsWithMedia
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return json(locationsWithFinds);
|
||||
} catch (err) {
|
||||
console.error('Error loading locations:', err);
|
||||
throw error(500, 'Failed to load locations');
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const { latitude, longitude, locationName } = data;
|
||||
|
||||
if (!latitude || !longitude) {
|
||||
throw error(400, 'Latitude and longitude are required');
|
||||
}
|
||||
|
||||
const locationId = generateId();
|
||||
|
||||
// Create location
|
||||
const newLocation = await db
|
||||
.insert(location)
|
||||
.values({
|
||||
id: locationId,
|
||||
userId: locals.user.id,
|
||||
latitude: latitude.toString(),
|
||||
longitude: longitude.toString(),
|
||||
locationName: locationName || null
|
||||
})
|
||||
.returning();
|
||||
|
||||
return json({ success: true, location: newLocation[0] });
|
||||
};
|
||||
63
src/routes/api/media/[...path]/+server.ts
Normal file
63
src/routes/api/media/[...path]/+server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { r2Client } from '$lib/server/r2';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const R2_BUCKET_NAME = env.R2_BUCKET_NAME;
|
||||
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
const path = params.path;
|
||||
|
||||
if (!path) {
|
||||
throw error(400, 'Path is required');
|
||||
}
|
||||
|
||||
if (!R2_BUCKET_NAME) {
|
||||
throw error(500, 'R2_BUCKET_NAME environment variable is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: path
|
||||
});
|
||||
|
||||
const response = await r2Client.send(command);
|
||||
|
||||
if (!response.Body) {
|
||||
throw error(404, 'File not found');
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
const body = response.Body as AsyncIterable<Uint8Array>;
|
||||
for await (const chunk of body) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
setHeaders({
|
||||
'Content-Type': response.ContentType || 'application/octet-stream',
|
||||
'Cache-Control': response.CacheControl || 'public, max-age=31536000, immutable',
|
||||
'Content-Length': buffer.length.toString(),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
});
|
||||
|
||||
return new Response(buffer);
|
||||
} catch (err) {
|
||||
console.error('Error fetching file from R2:', err);
|
||||
throw error(500, 'Failed to fetch file');
|
||||
}
|
||||
};
|
||||
|
||||
export const OPTIONS: RequestHandler = async ({ setHeaders }) => {
|
||||
setHeaders({
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
83
src/routes/api/notifications/+server.ts
Normal file
83
src/routes/api/notifications/+server.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
|
||||
// GET /api/notifications - Get user notifications
|
||||
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20');
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||
const unreadOnly = url.searchParams.get('unreadOnly') === 'true';
|
||||
|
||||
try {
|
||||
const notifications = await notificationService.getUserNotifications(user.id, {
|
||||
limit,
|
||||
offset,
|
||||
unreadOnly
|
||||
});
|
||||
|
||||
return json({ notifications });
|
||||
} catch (error) {
|
||||
console.error('Error fetching notifications:', error);
|
||||
return json({ error: 'Failed to fetch notifications' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// PATCH /api/notifications - Mark notifications as read
|
||||
export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { notificationIds, markAll } = await request.json();
|
||||
|
||||
if (markAll) {
|
||||
await notificationService.markAllAsRead(user.id);
|
||||
} else if (Array.isArray(notificationIds) && notificationIds.length > 0) {
|
||||
await notificationService.markAsRead(notificationIds);
|
||||
} else {
|
||||
return json(
|
||||
{ error: 'Invalid request: provide notificationIds or markAll' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error marking notifications as read:', error);
|
||||
return json({ error: 'Failed to mark notifications as read' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/notifications - Delete notifications
|
||||
export const DELETE: RequestHandler = async ({ locals, request }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { notificationId, deleteAll } = await request.json();
|
||||
|
||||
if (deleteAll) {
|
||||
await notificationService.deleteAllNotifications(user.id);
|
||||
} else if (notificationId) {
|
||||
await notificationService.deleteNotification(notificationId, user.id);
|
||||
} else {
|
||||
return json(
|
||||
{ error: 'Invalid request: provide notificationId or deleteAll' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting notifications:', error);
|
||||
return json({ error: 'Failed to delete notifications' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
18
src/routes/api/notifications/count/+server.ts
Normal file
18
src/routes/api/notifications/count/+server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
|
||||
// GET /api/notifications/count - Get unread notification count
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const count = await notificationService.getUnreadCount(user.id);
|
||||
return json({ count });
|
||||
} catch (error) {
|
||||
console.error('Error fetching unread count:', error);
|
||||
return json({ error: 'Failed to fetch unread count' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
93
src/routes/api/notifications/preferences/+server.ts
Normal file
93
src/routes/api/notifications/preferences/+server.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { notificationPreferences } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// GET - Fetch user's notification preferences
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user's preferences
|
||||
const [preferences] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, locals.user.id))
|
||||
.limit(1);
|
||||
|
||||
// If no preferences exist, return defaults
|
||||
if (!preferences) {
|
||||
return json({
|
||||
friendRequests: true,
|
||||
friendAccepted: true,
|
||||
findLiked: true,
|
||||
findCommented: true,
|
||||
pushEnabled: true
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
friendRequests: preferences.friendRequests,
|
||||
friendAccepted: preferences.friendAccepted,
|
||||
findLiked: preferences.findLiked,
|
||||
findCommented: preferences.findCommented,
|
||||
pushEnabled: preferences.pushEnabled
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching notification preferences:', err);
|
||||
throw error(500, 'Failed to fetch notification preferences');
|
||||
}
|
||||
};
|
||||
|
||||
// POST - Update user's notification preferences
|
||||
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { friendRequests, friendAccepted, findLiked, findCommented, pushEnabled } = body;
|
||||
|
||||
// Validate boolean values
|
||||
const preferences = {
|
||||
friendRequests: typeof friendRequests === 'boolean' ? friendRequests : true,
|
||||
friendAccepted: typeof friendAccepted === 'boolean' ? friendAccepted : true,
|
||||
findLiked: typeof findLiked === 'boolean' ? findLiked : true,
|
||||
findCommented: typeof findCommented === 'boolean' ? findCommented : true,
|
||||
pushEnabled: typeof pushEnabled === 'boolean' ? pushEnabled : true
|
||||
};
|
||||
|
||||
// Check if preferences exist
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, locals.user.id))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
// Update existing preferences
|
||||
await db
|
||||
.update(notificationPreferences)
|
||||
.set({
|
||||
...preferences,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(notificationPreferences.userId, locals.user.id));
|
||||
} else {
|
||||
// Create new preferences
|
||||
await db.insert(notificationPreferences).values({
|
||||
userId: locals.user.id,
|
||||
...preferences
|
||||
});
|
||||
}
|
||||
|
||||
return json({ success: true, preferences });
|
||||
} catch (err) {
|
||||
console.error('Error updating notification preferences:', err);
|
||||
throw error(500, 'Failed to update notification preferences');
|
||||
}
|
||||
};
|
||||
54
src/routes/api/notifications/subscribe/+server.ts
Normal file
54
src/routes/api/notifications/subscribe/+server.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { pushService, VAPID_PUBLIC_KEY } from '$lib/server/push';
|
||||
|
||||
// GET /api/notifications/subscribe - Get VAPID public key
|
||||
export const GET: RequestHandler = async () => {
|
||||
return json({ publicKey: VAPID_PUBLIC_KEY });
|
||||
};
|
||||
|
||||
// POST /api/notifications/subscribe - Subscribe to push notifications
|
||||
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { subscription } = await request.json();
|
||||
const userAgent = request.headers.get('user-agent') || undefined;
|
||||
|
||||
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||
return json({ error: 'Invalid subscription data' }, { status: 400 });
|
||||
}
|
||||
|
||||
await pushService.saveSubscription(user.id, subscription, userAgent);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error saving subscription:', error);
|
||||
return json({ error: 'Failed to save subscription' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/notifications/subscribe - Unsubscribe from push notifications
|
||||
export const DELETE: RequestHandler = async ({ locals, request }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { endpoint } = await request.json();
|
||||
|
||||
if (!endpoint) {
|
||||
return json({ error: 'Endpoint is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await pushService.removeSubscription(user.id, endpoint);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error removing subscription:', error);
|
||||
return json({ error: 'Failed to remove subscription' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
83
src/routes/api/places/+server.ts
Normal file
83
src/routes/api/places/+server.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createPlacesService } from '$lib/utils/places';
|
||||
|
||||
const getPlacesService = () => {
|
||||
const apiKey = env.GOOGLE_MAPS_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('GOOGLE_MAPS_API_KEY environment variable is not set');
|
||||
}
|
||||
return createPlacesService(apiKey);
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const action = url.searchParams.get('action');
|
||||
const query = url.searchParams.get('query');
|
||||
const placeId = url.searchParams.get('placeId');
|
||||
const lat = url.searchParams.get('lat');
|
||||
const lng = url.searchParams.get('lng');
|
||||
const radius = url.searchParams.get('radius');
|
||||
const type = url.searchParams.get('type');
|
||||
|
||||
try {
|
||||
const placesService = getPlacesService();
|
||||
|
||||
let location;
|
||||
if (lat && lng) {
|
||||
location = { lat: parseFloat(lat), lng: parseFloat(lng) };
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'search': {
|
||||
if (!query) {
|
||||
throw error(400, 'Query parameter is required for search');
|
||||
}
|
||||
const searchResults = await placesService.searchPlaces(query, location);
|
||||
return json(searchResults);
|
||||
}
|
||||
|
||||
case 'autocomplete': {
|
||||
if (!query) {
|
||||
throw error(400, 'Query parameter is required for autocomplete');
|
||||
}
|
||||
const suggestions = await placesService.getAutocompleteSuggestions(query, location);
|
||||
return json(suggestions);
|
||||
}
|
||||
|
||||
case 'details': {
|
||||
if (!placeId) {
|
||||
throw error(400, 'PlaceId parameter is required for details');
|
||||
}
|
||||
const placeDetails = await placesService.getPlaceDetails(placeId);
|
||||
return json(placeDetails);
|
||||
}
|
||||
|
||||
case 'nearby': {
|
||||
if (!location) {
|
||||
throw error(400, 'Location parameters (lat, lng) are required for nearby search');
|
||||
}
|
||||
const radiusNum = radius ? parseInt(radius) : 5000;
|
||||
const nearbyResults = await placesService.findNearbyPlaces(
|
||||
location,
|
||||
radiusNum,
|
||||
type || undefined
|
||||
);
|
||||
return json(nearbyResults);
|
||||
}
|
||||
|
||||
default:
|
||||
throw error(400, 'Invalid action parameter');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Places API error:', err);
|
||||
if (err instanceof Error) {
|
||||
throw error(500, err.message);
|
||||
}
|
||||
throw error(500, 'Failed to fetch places data');
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user, friendship } from '$lib/server/db/schema';
|
||||
import { eq, and, or, ilike, ne } from 'drizzle-orm';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
if (!locals.user) {
|
||||
@@ -38,25 +38,22 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
}> = [];
|
||||
|
||||
if (userIds.length > 0) {
|
||||
existingFriendships = await db
|
||||
// Use a simpler approach: get all friendships involving the current user
|
||||
// and then filter for the relevant user IDs
|
||||
const allUserFriendships = await db
|
||||
.select({
|
||||
userId: friendship.userId,
|
||||
friendId: friendship.friendId,
|
||||
status: friendship.status
|
||||
})
|
||||
.from(friendship)
|
||||
.where(
|
||||
or(
|
||||
and(
|
||||
eq(friendship.userId, locals.user.id),
|
||||
or(...userIds.map((id) => eq(friendship.friendId, id)))
|
||||
),
|
||||
and(
|
||||
eq(friendship.friendId, locals.user.id),
|
||||
or(...userIds.map((id) => eq(friendship.userId, id)))
|
||||
)
|
||||
)
|
||||
);
|
||||
.where(or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id)));
|
||||
|
||||
// Filter to only include friendships with users in our search results
|
||||
existingFriendships = allUserFriendships.filter((f) => {
|
||||
const otherUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
|
||||
return userIds.includes(otherUserId);
|
||||
});
|
||||
}
|
||||
|
||||
// Create a map of friendship statuses
|
||||
@@ -66,28 +63,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
friendshipStatusMap.set(otherUserId, f.status);
|
||||
});
|
||||
|
||||
// Generate signed URLs and add friendship status
|
||||
const usersWithSignedUrls = await Promise.all(
|
||||
users.map(async (userItem) => {
|
||||
let profilePictureUrl = userItem.profilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
try {
|
||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for profile picture:', error);
|
||||
profilePictureUrl = null;
|
||||
}
|
||||
}
|
||||
// Generate local proxy URLs and add friendship status
|
||||
const usersWithSignedUrls = users.map((userItem) => {
|
||||
let profilePictureUrl = userItem.profilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||
}
|
||||
|
||||
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
||||
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
||||
|
||||
return {
|
||||
...userItem,
|
||||
profilePictureUrl,
|
||||
friendshipStatus
|
||||
};
|
||||
})
|
||||
);
|
||||
return {
|
||||
...userItem,
|
||||
profilePictureUrl,
|
||||
friendshipStatus
|
||||
};
|
||||
});
|
||||
|
||||
return json(usersWithSignedUrls);
|
||||
} catch (err) {
|
||||
|
||||
43
src/routes/finds/[findId]/+page.server.ts
Normal file
43
src/routes/finds/[findId]/+page.server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch, url, request }) => {
|
||||
const findId = params.findId;
|
||||
|
||||
if (!findId) {
|
||||
throw error(400, 'Find ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Build API URL
|
||||
const apiUrl = new URL(`/api/finds/${findId}`, url.origin);
|
||||
|
||||
// Fetch the find data - no auth required for public finds
|
||||
const response = await fetch(apiUrl.toString(), {
|
||||
headers: {
|
||||
Cookie: request.headers.get('Cookie') || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw error(404, 'Find not found');
|
||||
} else if (response.status === 403) {
|
||||
throw error(403, 'This find is private');
|
||||
}
|
||||
throw error(response.status, 'Failed to load find');
|
||||
}
|
||||
|
||||
const find = await response.json();
|
||||
|
||||
return {
|
||||
find
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error loading find:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to load find');
|
||||
}
|
||||
};
|
||||
870
src/routes/finds/[findId]/+page.svelte
Normal file
870
src/routes/finds/[findId]/+page.svelte
Normal file
@@ -0,0 +1,870 @@
|
||||
<script lang="ts">
|
||||
import { Map } from '$lib';
|
||||
import { goto } from '$app/navigation';
|
||||
import LikeButton from '$lib/components/finds/LikeButton.svelte';
|
||||
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
|
||||
import ProfilePicture from '$lib/components/profile/ProfilePicture.svelte';
|
||||
import CommentsList from '$lib/components/finds/CommentsList.svelte';
|
||||
import EditFindModal from '$lib/components/finds/EditFindModal.svelte';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Edit, Trash2 } from 'lucide-svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { apiSync } from '$lib/stores/api-sync';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let currentMediaIndex = $state(0);
|
||||
let isPanelVisible = $state(true);
|
||||
let showEditModal = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
const isOwner = $derived(data.user && data.find && data.user.id === data.find.userId);
|
||||
|
||||
function nextMedia() {
|
||||
if (!data.find?.media) return;
|
||||
currentMediaIndex = (currentMediaIndex + 1) % data.find.media.length;
|
||||
}
|
||||
|
||||
function prevMedia() {
|
||||
if (!data.find?.media) return;
|
||||
currentMediaIndex =
|
||||
currentMediaIndex === 0 ? data.find.media.length - 1 : currentMediaIndex - 1;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function getDirections() {
|
||||
if (!data.find) return;
|
||||
|
||||
const lat = parseFloat(data.find.latitude);
|
||||
const lng = parseFloat(data.find.longitude);
|
||||
const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function shareFindUrl() {
|
||||
if (!data.find) return;
|
||||
|
||||
const url = `${window.location.origin}/finds/${data.find.id}`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
.share({
|
||||
title: data.find.title,
|
||||
text: data.find.description || `Check out this find: ${data.find.title}`,
|
||||
url: url
|
||||
})
|
||||
.catch((error) => {
|
||||
// User cancelled or error occurred
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error sharing:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback: Copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
alert('Find URL copied to clipboard!');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
isPanelVisible = !isPanelVisible;
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
showEditModal = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!data.find) return;
|
||||
|
||||
if (!confirm('Are you sure you want to delete this find? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting = true;
|
||||
try {
|
||||
await apiSync.deleteFind(data.find.id);
|
||||
|
||||
// Redirect to home page after successful deletion
|
||||
goto('/');
|
||||
} catch (error) {
|
||||
console.error('Error deleting find:', error);
|
||||
alert('Failed to delete find. Please try again.');
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFindUpdated() {
|
||||
showEditModal = false;
|
||||
// Reload the page to get updated data
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function handleFindDeleted() {
|
||||
showEditModal = false;
|
||||
goto('/');
|
||||
}
|
||||
|
||||
// Get first media for OG image
|
||||
let ogImage = $derived(data.find?.media?.[0]?.url || '');
|
||||
|
||||
// Convert find to location format for map marker
|
||||
let findAsLocation = $derived(
|
||||
data.find
|
||||
? [
|
||||
{
|
||||
id: data.find.id,
|
||||
latitude: data.find.latitude,
|
||||
longitude: data.find.longitude,
|
||||
createdAt: new Date(data.find.createdAt),
|
||||
userId: data.find.userId,
|
||||
user: {
|
||||
id: data.find.userId,
|
||||
username: data.find.username
|
||||
},
|
||||
finds: [
|
||||
{
|
||||
id: data.find.id,
|
||||
title: data.find.title,
|
||||
description: data.find.description || undefined,
|
||||
isPublic: data.find.isPublic ?? 1,
|
||||
media: data.find.media || []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
: []
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.find ? `${data.find.title} - Serengo` : 'Find - Serengo'}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content={data.find?.description ||
|
||||
`Check out this find on Serengo: ${data.find?.title || 'Unknown'}`}
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={data.find ? `${data.find.title} - Serengo` : 'Find - Serengo'}
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content={data.find?.description ||
|
||||
`Check out this find on Serengo: ${data.find?.title || 'Unknown'}`}
|
||||
/>
|
||||
<meta property="og:type" content="article" />
|
||||
{#if ogImage}
|
||||
<meta property="og:image" content={ogImage} />
|
||||
{/if}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content={data.find ? `${data.find.title} - Serengo` : 'Find - Serengo'}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content={data.find?.description ||
|
||||
`Check out this find on Serengo: ${data.find?.title || 'Unknown'}`}
|
||||
/>
|
||||
{#if ogImage}
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="public-find-page">
|
||||
<!-- Fullscreen map -->
|
||||
<div class="map-section">
|
||||
<Map
|
||||
autoCenter={false}
|
||||
center={[parseFloat(data.find?.longitude || '0'), parseFloat(data.find?.latitude || '0')]}
|
||||
zoom={15}
|
||||
locations={findAsLocation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Details panel container -->
|
||||
<div class="panel-container">
|
||||
<!-- Details panel -->
|
||||
<div class="find-details-panel" class:hidden={!isPanelVisible}>
|
||||
{#if data.find}
|
||||
<div class="panel-content">
|
||||
<div class="panel-header">
|
||||
<div class="user-section">
|
||||
<ProfilePicture
|
||||
username={data.find.username}
|
||||
profilePictureUrl={data.find.profilePictureUrl}
|
||||
class="user-avatar"
|
||||
/>
|
||||
<div class="user-info">
|
||||
<h1 class="find-title">{data.find.title}</h1>
|
||||
<div class="find-meta">
|
||||
<span class="username">@{data.find.username}</span>
|
||||
<span class="separator">•</span>
|
||||
<span class="date">{formatDate(data.find.createdAt)}</span>
|
||||
{#if data.find.category}
|
||||
<span class="separator">•</span>
|
||||
<span class="category">{data.find.category}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if isOwner}
|
||||
<div class="action-buttons-header">
|
||||
<Button variant="outline" size="sm" onclick={handleEdit}>
|
||||
<Edit size={16} />
|
||||
<span>Edit</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span>{isDeleting ? 'Deleting...' : 'Delete'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
{#if data.find.media && data.find.media.length > 0}
|
||||
<div class="media-container">
|
||||
<div class="media-viewer">
|
||||
{#if data.find.media[currentMediaIndex].type === 'photo'}
|
||||
<img
|
||||
src={data.find.media[currentMediaIndex].url}
|
||||
alt={data.find.title}
|
||||
class="media-image"
|
||||
/>
|
||||
{:else}
|
||||
<VideoPlayer
|
||||
src={data.find.media[currentMediaIndex].url}
|
||||
poster={data.find.media[currentMediaIndex].thumbnailUrl}
|
||||
class="media-video"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if data.find.media.length > 1}
|
||||
<button class="media-nav prev" onclick={prevMedia} aria-label="Previous media">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M15 18L9 12L15 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="media-nav next" onclick={nextMedia} aria-label="Next media">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M9 18L15 12L9 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.find.media.length > 1}
|
||||
<div class="media-indicators">
|
||||
{#each data.find.media as _, index (index)}
|
||||
<button
|
||||
class="indicator"
|
||||
class:active={index === currentMediaIndex}
|
||||
onclick={() => (currentMediaIndex = index)}
|
||||
aria-label={`View media ${index + 1}`}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="content-section">
|
||||
{#if data.find.description}
|
||||
<p class="description">{data.find.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if data.find.locationName}
|
||||
<div class="location-info">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
<span>{data.find.locationName}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<LikeButton
|
||||
findId={data.find.id}
|
||||
isLiked={data.find.isLikedByUser || false}
|
||||
likeCount={data.find.likeCount || 0}
|
||||
size="default"
|
||||
class="like-action"
|
||||
/>
|
||||
|
||||
<button class="action-button primary" onclick={getDirections}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M3 11L22 2L13 21L11 13L3 11Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Directions
|
||||
</button>
|
||||
|
||||
<button class="action-button secondary" onclick={shareFindUrl}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M4 12V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="16,6 12,2 8,6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comments-section">
|
||||
<CommentsList
|
||||
findId={data.find.id}
|
||||
currentUserId={data.user?.id}
|
||||
collapsed={false}
|
||||
isScrollable={true}
|
||||
showCommentForm={data.user ? true : false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="error-state">
|
||||
<h1>Find not found</h1>
|
||||
<p>This find does not exist or is private.</p>
|
||||
<a href="/" class="back-button">Back to Home</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Toggle button -->
|
||||
<button
|
||||
class="panel-toggle"
|
||||
class:collapsed={!isPanelVisible}
|
||||
onclick={togglePanel}
|
||||
aria-label="Toggle find details"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
{#if isPanelVisible}
|
||||
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{:else}
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showEditModal && isOwner && data.find}
|
||||
<EditFindModal
|
||||
isOpen={showEditModal}
|
||||
find={{
|
||||
id: data.find.id,
|
||||
title: data.find.title,
|
||||
description: data.find.description || null,
|
||||
latitude: data.find.latitude,
|
||||
longitude: data.find.longitude,
|
||||
locationName: data.find.locationName || null,
|
||||
category: data.find.category || null,
|
||||
isPublic: data.find.isPublic ?? 1,
|
||||
media: data.find.media || []
|
||||
}}
|
||||
onClose={() => (showEditModal = false)}
|
||||
onFindUpdated={handleFindUpdated}
|
||||
onFindDeleted={handleFindDeleted}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.public-find-page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.map-section :global(.maplibregl-map) {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.panel-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-left: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-toggle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.panel-toggle:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.panel-toggle svg {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.find-details-panel {
|
||||
width: 40%;
|
||||
max-width: 1000px;
|
||||
min-width: 500px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.find-details-panel.hidden {
|
||||
display: none;
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-buttons-header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-buttons-header :global(button) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
:global(.user-avatar) {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.avatar-fallback) {
|
||||
background: hsl(var(--primary));
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.find-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.find-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.category {
|
||||
background: hsl(var(--muted));
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.media-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-viewer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
background: hsl(var(--muted));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:global(.media-video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.media-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.media-nav:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.media-nav.prev {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.media-nav.next {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.media-indicators {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: hsl(var(--muted-foreground));
|
||||
opacity: 0.3;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.indicator.active {
|
||||
background: hsl(var(--primary));
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.content-section {
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.like-action) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-button.primary {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.action-button.primary:hover {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
.action-button.secondary {
|
||||
background: hsl(var(--secondary));
|
||||
color: hsl(var(--secondary-foreground));
|
||||
}
|
||||
|
||||
.action-button.secondary:hover {
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.error-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.error-state h1 {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.error-state p {
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.panel-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-toggle.collapsed {
|
||||
margin: 12px auto;
|
||||
}
|
||||
|
||||
.panel-toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.find-details-panel {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
min-width: 0;
|
||||
border-radius: 20px 20px 0 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.find-details-panel.hidden {
|
||||
display: none;
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.find-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
:global(.user-avatar) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.find-details-panel {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.content-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
|
||||
import ProfilePicture from '$lib/components/profile/ProfilePicture.svelte';
|
||||
import { Badge } from '$lib/components/badge';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { PageData } from './$types';
|
||||
@@ -134,8 +134,10 @@
|
||||
// Search users when query changes with debounce
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
$effect(() => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(searchUsers, 300);
|
||||
if (searchQuery) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(searchUsers, 300);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -182,10 +184,10 @@
|
||||
<div class="user-grid">
|
||||
{#each friends as friend (friend.id)}
|
||||
<div class="user-card">
|
||||
<Avatar>
|
||||
<AvatarImage src={friend.friendProfilePictureUrl} alt={friend.friendUsername} />
|
||||
<AvatarFallback>{friend.friendUsername.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={friend.friendUsername}
|
||||
profilePictureUrl={friend.friendProfilePictureUrl}
|
||||
/>
|
||||
<div class="user-info">
|
||||
<span class="username">{friend.friendUsername}</span>
|
||||
<Badge variant="secondary">Friend</Badge>
|
||||
@@ -216,15 +218,10 @@
|
||||
<div class="user-grid">
|
||||
{#each receivedRequests as request (request.id)}
|
||||
<div class="user-card">
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={request.friendProfilePictureUrl}
|
||||
alt={request.friendUsername}
|
||||
/>
|
||||
<AvatarFallback
|
||||
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
|
||||
>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={request.friendUsername}
|
||||
profilePictureUrl={request.friendProfilePictureUrl}
|
||||
/>
|
||||
<div class="user-info">
|
||||
<span class="username">{request.friendUsername}</span>
|
||||
<Badge variant="outline">Pending</Badge>
|
||||
@@ -262,15 +259,10 @@
|
||||
<div class="user-grid">
|
||||
{#each sentRequests as request (request.id)}
|
||||
<div class="user-card">
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={request.friendProfilePictureUrl}
|
||||
alt={request.friendUsername}
|
||||
/>
|
||||
<AvatarFallback
|
||||
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
|
||||
>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={request.friendUsername}
|
||||
profilePictureUrl={request.friendProfilePictureUrl}
|
||||
/>
|
||||
<div class="user-info">
|
||||
<span class="username">{request.friendUsername}</span>
|
||||
<Badge variant="secondary">Sent</Badge>
|
||||
@@ -303,10 +295,10 @@
|
||||
<div class="user-grid">
|
||||
{#each searchResults as user (user.id)}
|
||||
<div class="user-card">
|
||||
<Avatar>
|
||||
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
|
||||
<AvatarFallback>{user.username.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={user.username}
|
||||
profilePictureUrl={user.profilePictureUrl}
|
||||
/>
|
||||
<div class="user-info">
|
||||
<span class="username">{user.username}</span>
|
||||
{#if user.friendshipStatus === 'accepted'}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import LoginForm from '$lib/components/login-form.svelte';
|
||||
import LoginForm from '$lib/components/auth/login-form.svelte';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
|
||||
@@ -130,8 +130,12 @@ self.addEventListener('fetch', (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle R2 resources with cache-first strategy
|
||||
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) {
|
||||
// Handle R2 resources and local media proxy with cache-first strategy
|
||||
if (
|
||||
url.hostname.includes('.r2.dev') ||
|
||||
url.hostname.includes('.r2.cloudflarestorage.com') ||
|
||||
url.pathname.startsWith('/api/media/')
|
||||
) {
|
||||
const cachedResponse = await r2Cache.match(event.request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
@@ -146,7 +150,7 @@ self.addEventListener('fetch', (event) => {
|
||||
return response;
|
||||
} catch {
|
||||
// Return cached version if available, or fall through to other cache checks
|
||||
return cachedResponse || new Response('R2 resource not available', { status: 404 });
|
||||
return cachedResponse || new Response('Media resource not available', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,3 +226,52 @@ self.addEventListener('fetch', (event) => {
|
||||
|
||||
event.respondWith(respond());
|
||||
});
|
||||
|
||||
// Handle push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data.json();
|
||||
const title = data.title || 'Serengo';
|
||||
const options = {
|
||||
body: data.body || '',
|
||||
icon: data.icon || '/logo.svg',
|
||||
badge: data.badge || '/logo.svg',
|
||||
tag: data.tag || 'default',
|
||||
data: data.data || {},
|
||||
requireInteraction: false,
|
||||
vibrate: [200, 100, 200]
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
// Handle notification clicks
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const urlToOpen = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true
|
||||
})
|
||||
.then((clientList) => {
|
||||
// Check if there's already a window open
|
||||
for (const client of clientList) {
|
||||
if (client.url === urlToOpen && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, open a new window
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user