Compare commits
23 Commits
public-fin
...
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
|
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
|
||||
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 "$@"
|
||||
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";
|
||||
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": {}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1762522687342,
|
||||
"tag": "0007_grey_dark_beast",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1765885558230,
|
||||
"tag": "0008_common_supreme_intelligence",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
617
logs/logboek.md
617
logs/logboek.md
@@ -1,7 +1,226 @@
|
||||
# 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:**
|
||||
@@ -489,65 +708,375 @@
|
||||
|
||||
## Totaal Overzicht
|
||||
|
||||
**Totale geschatte uren:** 93 uren
|
||||
**Werkdagen:** 17 dagen
|
||||
**Gemiddelde uren per dag:** 5.5 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
|
||||
10. **16 Okt**: Friends & Privacy System implementatie
|
||||
11. **20 Okt**: Search logic improvements
|
||||
12. **21 Okt**: UI refinement en bug fixes
|
||||
13. **27-29 Okt**: Google Places Integration & Sync Service
|
||||
14. **4 Nov**: UI Consistency & Media Layout Improvements
|
||||
15. **6 Nov**: Comments Feature Implementatie
|
||||
16. **8 Nov**: Web Push Notifications Systeem
|
||||
17. **17 Nov**: UI/UX Grote Overhaul - Fullscreen Map & Sidebar Design
|
||||
**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] Friends & Privacy System met vriendschapsverzoeken
|
||||
- [x] Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
|
||||
- [x] Friends management pagina met gebruikerszoekfunctionaliteit
|
||||
- [x] Real-time find filtering op basis van privacy instellingen
|
||||
- [x] Google Maps Places API integratie voor POI zoekfunctionaliteit
|
||||
- [x] Sync-service voor API data synchronisatie
|
||||
- [x] Continuous location watching voor nauwkeurige tracking
|
||||
- [x] CSP security verbeteringen
|
||||
- [x] Comments systeem met real-time synchronisatie
|
||||
- [x] Scrollable comments met limit functionaliteit
|
||||
- [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] Local media proxy voor caching en performance
|
||||
- [x] Fullscreen map UI met sidebar toggle
|
||||
- [x] Unified mobile/desktop interface
|
||||
- [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,6 +59,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
@@ -59,7 +58,7 @@
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : resolveRoute(href)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? 'link' : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
|
||||
@@ -2,29 +2,23 @@
|
||||
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;
|
||||
locationId: string;
|
||||
onClose: () => void;
|
||||
onFindCreated: (event: CustomEvent) => void;
|
||||
}
|
||||
|
||||
let { isOpen, onClose, onFindCreated }: Props = $props();
|
||||
let { isOpen, locationId, onClose, onFindCreated }: Props = $props();
|
||||
|
||||
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 }>>([]);
|
||||
let useManualLocation = $state(false);
|
||||
|
||||
const categories = [
|
||||
{ value: 'cafe', label: 'Café' },
|
||||
@@ -51,13 +45,6 @@
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen && $coordinates) {
|
||||
latitude = $coordinates.latitude.toString();
|
||||
longitude = $coordinates.longitude.toString();
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
selectedFiles = target.files;
|
||||
@@ -85,10 +72,7 @@
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const lat = parseFloat(latitude);
|
||||
const lng = parseFloat(longitude);
|
||||
|
||||
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
|
||||
if (!title.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,11 +89,9 @@
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
locationId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
locationName: locationName.trim() || null,
|
||||
category,
|
||||
isPublic,
|
||||
media: uploadedMedia
|
||||
@@ -131,31 +113,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
title = '';
|
||||
description = '';
|
||||
locationName = '';
|
||||
latitude = '';
|
||||
longitude = '';
|
||||
category = 'cafe';
|
||||
isPublic = true;
|
||||
selectedFiles = null;
|
||||
uploadedMedia = [];
|
||||
useManualLocation = false;
|
||||
|
||||
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
@@ -210,33 +174,6 @@
|
||||
></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>
|
||||
@@ -307,34 +244,6 @@
|
||||
</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">
|
||||
@@ -358,7 +267,6 @@
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
@@ -615,76 +523,6 @@
|
||||
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 {
|
||||
|
||||
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,11 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Badge } from '$lib/components/badge';
|
||||
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 } from '@lucide/svelte';
|
||||
import { Ellipsis, MessageCircle, Share, Edit, Trash2 } from '@lucide/svelte';
|
||||
import { apiSync } from '$lib/stores/api-sync';
|
||||
|
||||
interface FindCardProps {
|
||||
id: string;
|
||||
@@ -13,20 +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 {
|
||||
@@ -35,16 +52,26 @@
|
||||
description,
|
||||
category,
|
||||
locationName,
|
||||
latitude: _latitude,
|
||||
longitude: _longitude,
|
||||
isPublic: _isPublic,
|
||||
userId,
|
||||
user,
|
||||
media,
|
||||
likeCount = 0,
|
||||
isLiked = false,
|
||||
commentCount = 0,
|
||||
currentUserId,
|
||||
onExplore
|
||||
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);
|
||||
@@ -82,6 +109,27 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div class="find-card">
|
||||
@@ -112,9 +160,24 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="more-button">
|
||||
<Ellipsis 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 -->
|
||||
@@ -191,6 +254,7 @@
|
||||
<style>
|
||||
.find-card {
|
||||
backdrop-filter: blur(10px);
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -244,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;
|
||||
|
||||
@@ -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,15 +18,19 @@
|
||||
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;
|
||||
@@ -32,6 +40,8 @@
|
||||
let {
|
||||
finds,
|
||||
onFindExplore,
|
||||
currentUserId,
|
||||
onEdit,
|
||||
title = 'Finds',
|
||||
showEmpty = true,
|
||||
emptyMessage = 'No finds to display',
|
||||
@@ -59,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>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
|
||||
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';
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { MapLibre, Marker } from 'svelte-maplibre';
|
||||
import type { StyleSpecification } from 'svelte-maplibre';
|
||||
import { untrack } from 'svelte';
|
||||
import {
|
||||
coordinates,
|
||||
getMapCenter,
|
||||
@@ -11,25 +12,26 @@
|
||||
} from '$lib/stores/location';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
|
||||
interface Find {
|
||||
interface Location {
|
||||
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;
|
||||
finds: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
isPublic: number;
|
||||
media?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -39,8 +41,9 @@
|
||||
zoom?: number;
|
||||
class?: string;
|
||||
autoCenter?: boolean;
|
||||
finds?: Find[];
|
||||
onFindClick?: (find: Find) => void;
|
||||
locations?: Location[];
|
||||
onLocationClick?: (location: Location) => void;
|
||||
sidebarVisible?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -66,13 +69,48 @@
|
||||
zoom,
|
||||
class: className = '',
|
||||
autoCenter = true,
|
||||
finds = [],
|
||||
onFindClick
|
||||
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() {
|
||||
@@ -86,33 +124,114 @@
|
||||
// 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
|
||||
// Only recenter when shouldZoomToLocation is true (user clicked location button)
|
||||
// or when autoCenter is true AND coordinates are first loaded
|
||||
const mapCenter = $derived(
|
||||
$coordinates && $shouldZoomToLocation
|
||||
? ([$coordinates.longitude, $coordinates.latitude] as [number, number])
|
||||
: center || $getMapCenter
|
||||
);
|
||||
// Check if map is centered on user location (approximately)
|
||||
const isCenteredOnUser = $derived.by(() => {
|
||||
if (!$coordinates || !mapInstance) return false;
|
||||
|
||||
const mapZoom = $derived(() => {
|
||||
if ($shouldZoomToLocation && $coordinates) {
|
||||
// Force zoom to calculated level when location button is clicked
|
||||
return $getMapZoom;
|
||||
}
|
||||
// Don't auto-zoom on coordinate updates, keep current zoom
|
||||
return zoom || 13;
|
||||
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 clear zoom trigger after it's been used
|
||||
// Effect to handle recenter trigger
|
||||
$effect(() => {
|
||||
if ($shouldZoomToLocation) {
|
||||
// Use a timeout to ensure the map has updated before clearing the trigger
|
||||
setTimeout(() => {
|
||||
locationActions.clearZoomTrigger();
|
||||
}, 100);
|
||||
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}">
|
||||
@@ -129,8 +248,9 @@
|
||||
<div class="map-wrapper" class:hidden={!mapReady}>
|
||||
<MapLibre
|
||||
{style}
|
||||
center={mapCenter}
|
||||
zoom={mapZoom()}
|
||||
center={initialCenter}
|
||||
zoom={initialZoom}
|
||||
bind:map={mapInstance}
|
||||
bind:loaded={mapLoaded}
|
||||
onstyleload={handleStyleLoad}
|
||||
onidle={handleIdle}
|
||||
@@ -149,33 +269,59 @@
|
||||
</Marker>
|
||||
{/if}
|
||||
|
||||
{#each finds as find (find.id)}
|
||||
<Marker lngLat={[parseFloat(find.longitude), parseFloat(find.latitude)]}>
|
||||
{#each locations as location (location.id)}
|
||||
<Marker lngLat={[parseFloat(location.longitude), parseFloat(location.latitude)]}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="find-marker"
|
||||
class="location-pin-marker"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onFindClick?.(find)}
|
||||
title={find.title}
|
||||
onclick={() => onLocationClick?.(location)}
|
||||
title={`${location.finds.length} find${location.finds.length !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<div class="find-marker-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<div class="location-pin-icon">
|
||||
<svg width="24" height="24" 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"
|
||||
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>
|
||||
{#if find.media && find.media.length > 0}
|
||||
<div class="find-marker-preview">
|
||||
<img src={find.media[0].thumbnailUrl} alt={find.title} />
|
||||
<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>
|
||||
|
||||
@@ -332,42 +478,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Find marker styles */
|
||||
:global(.find-marker) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
/* Location pin marker styles */
|
||||
:global(.location-pin-marker) {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -100%);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.find-marker:hover) {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
:global(.location-pin-marker:hover) {
|
||||
transform: translate(-50%, -100%) scale(1.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
:global(.find-marker-icon) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #ff6b35;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
:global(.location-pin-icon) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
color: #ff6b35;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
:global(.find-marker-preview) {
|
||||
:global(.location-find-count) {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
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;
|
||||
@@ -375,12 +541,42 @@
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
:global(.find-marker-preview img) {
|
||||
: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;
|
||||
@@ -390,5 +586,12 @@
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.recenter-button {
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: hsl(var(--background));
|
||||
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);
|
||||
@@ -281,7 +281,7 @@
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
margin-top: 0.25rem;
|
||||
backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.suggestions-header {
|
||||
@@ -290,7 +290,7 @@
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--muted) / 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
background: hsl(var(--background));
|
||||
background: hsl(var(--background) / 0.95);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
@@ -314,7 +314,7 @@
|
||||
}
|
||||
|
||||
.suggestion-item:hover:not(:disabled) {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
background: hsl(var(--muted) / 0.8);
|
||||
}
|
||||
|
||||
.suggestion-item:disabled {
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
/**
|
||||
* Convert VAPID public key from base64 to Uint8Array
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
return outputArray as Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
@@ -130,6 +142,7 @@ export const notificationPreferences = pgTable('notification_preferences', {
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -139,6 +152,7 @@ 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;
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -408,6 +408,23 @@ class APISync {
|
||||
'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`, {
|
||||
@@ -439,6 +456,14 @@ class APISync {
|
||||
// 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') {
|
||||
@@ -721,6 +746,103 @@ class APISync {
|
||||
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
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
@@ -4,23 +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';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let { children, data } = $props();
|
||||
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
|
||||
let showHeader = $derived(!isLoginRoute && data?.user);
|
||||
let isLoading = $state(false);
|
||||
|
||||
// Handle loading state only on client to prevent hydration mismatch
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
isLoading = !isLoginRoute && !data?.user && data !== null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -51,40 +40,6 @@
|
||||
|
||||
{#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 {
|
||||
padding: 0 20px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
||||
// 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');
|
||||
@@ -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,220 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { Map } from '$lib';
|
||||
import FindsList from '$lib/components/finds/FindsList.svelte';
|
||||
import CreateFindModal from '$lib/components/finds/CreateFindModal.svelte';
|
||||
import FindPreview from '$lib/components/finds/FindPreview.svelte';
|
||||
import FindsFilter from '$lib/components/finds/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 { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { apiSync, type FindState } from '$lib/stores/api-sync';
|
||||
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;
|
||||
isFromFriend?: boolean;
|
||||
media: Array<{
|
||||
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;
|
||||
isFromFriend?: 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);
|
||||
|
||||
// Initialize API sync with server data on mount
|
||||
onMount(async () => {
|
||||
if (browser && data.finds && data.finds.length > 0) {
|
||||
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
|
||||
id: serverFind.id,
|
||||
title: serverFind.title,
|
||||
description: serverFind.description,
|
||||
latitude: serverFind.latitude,
|
||||
longitude: serverFind.longitude,
|
||||
locationName: serverFind.locationName,
|
||||
category: serverFind.category,
|
||||
isPublic: Boolean(serverFind.isPublic),
|
||||
createdAt: new Date(serverFind.createdAt),
|
||||
userId: serverFind.userId,
|
||||
username: serverFind.username,
|
||||
profilePictureUrl: serverFind.profilePictureUrl || undefined,
|
||||
media: serverFind.media,
|
||||
isLikedByUser: Boolean(serverFind.isLikedByUser),
|
||||
likeCount: serverFind.likeCount || 0,
|
||||
commentCount: 0,
|
||||
isFromFriend: Boolean(serverFind.isFromFriend)
|
||||
}));
|
||||
// Process locations with distance
|
||||
let locations = $derived.by(() => {
|
||||
if (!data.locations || !$coordinates) return data.locations || [];
|
||||
|
||||
apiSync.initializeFindData(findStates);
|
||||
}
|
||||
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)
|
||||
);
|
||||
});
|
||||
|
||||
// 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,
|
||||
isFromFriend: serverFind.isFromFriend,
|
||||
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[]
|
||||
// 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
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// 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.isFromFriend === true);
|
||||
case 'mine':
|
||||
return allFinds.filter((find) => find.userId === data.user!.id);
|
||||
case 'all':
|
||||
default:
|
||||
return allFinds;
|
||||
}
|
||||
});
|
||||
|
||||
function handleFilterChange(filter: string) {
|
||||
currentFilter = filter;
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -248,19 +191,20 @@
|
||||
<Map
|
||||
autoCenter={true}
|
||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||
{finds}
|
||||
onFindClick={handleFindClick}
|
||||
locations={mapLocations}
|
||||
onLocationClick={handleMapLocationClick}
|
||||
sidebarVisible={isSidebarVisible}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar container -->
|
||||
<div class="sidebar-container">
|
||||
<!-- Left sidebar with finds list -->
|
||||
<!-- Left sidebar with locations list -->
|
||||
<div class="finds-sidebar" class:hidden={!isSidebarVisible}>
|
||||
<div class="finds-header">
|
||||
{#if data.user}
|
||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||
<Button onclick={openCreateModal} class="create-find-button">
|
||||
<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" />
|
||||
@@ -276,7 +220,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="finds-list-container">
|
||||
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
||||
<LocationsList {locations} onLocationExplore={handleLocationExplore} hideTitle={true} />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Toggle button -->
|
||||
@@ -284,7 +228,7 @@
|
||||
class="sidebar-toggle"
|
||||
class:collapsed={!isSidebarVisible}
|
||||
onclick={toggleSidebar}
|
||||
aria-label="Toggle finds list"
|
||||
aria-label="Toggle locations list"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
{#if isSidebarVisible}
|
||||
@@ -298,16 +242,22 @@
|
||||
</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} currentUserId={data.user?.id} />
|
||||
{#if showLocationFindsModal && selectedLocation}
|
||||
<LocationFindsModal
|
||||
isOpen={showLocationFindsModal}
|
||||
location={selectedLocation}
|
||||
currentUserId={data.user?.id}
|
||||
onClose={closeLocationFindsModal}
|
||||
onCreateFind={handleCreateFindFromLocation}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -371,7 +321,6 @@
|
||||
width: 40%;
|
||||
max-width: 1000px;
|
||||
min-width: 500px;
|
||||
height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
@@ -395,12 +344,21 @@
|
||||
.finds-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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;
|
||||
@@ -448,6 +406,10 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.mr-2) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-container {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
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 { 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 }) => {
|
||||
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 and user is logged in
|
||||
let friendIds: string[] = [];
|
||||
@@ -58,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,
|
||||
@@ -119,11 +98,11 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
})
|
||||
.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);
|
||||
@@ -176,12 +155,11 @@ 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 local proxy URLs
|
||||
const localUrl = getLocalR2Url(mediaItem.url);
|
||||
const localThumbnailUrl =
|
||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
|
||||
: mediaItem.thumbnailUrl;
|
||||
|
||||
return {
|
||||
...mediaItem,
|
||||
@@ -214,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) {
|
||||
@@ -234,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
|
||||
})
|
||||
@@ -256,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,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { find, findMedia, user, findLike, findComment } from '$lib/server/db/schema';
|
||||
import { find, findMedia, user, findLike, findComment, location } from '$lib/server/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
import { getLocalR2Url, deleteFromR2 } from '$lib/server/r2';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const findId = params.findId;
|
||||
@@ -19,9 +19,9 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
id: find.id,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
latitude: find.latitude,
|
||||
longitude: find.longitude,
|
||||
locationName: find.locationName,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
locationName: location.locationName,
|
||||
category: find.category,
|
||||
isPublic: find.isPublic,
|
||||
createdAt: find.createdAt,
|
||||
@@ -42,10 +42,18 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
: 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, user.username, user.profilePictureUrl)
|
||||
.groupBy(
|
||||
find.id,
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
location.locationName,
|
||||
user.username,
|
||||
user.profilePictureUrl
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (findResult.length === 0) {
|
||||
@@ -113,3 +121,182 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
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] });
|
||||
};
|
||||
@@ -1,15 +1,24 @@
|
||||
<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;
|
||||
@@ -77,42 +86,71 @@
|
||||
isPanelVisible = !isPanelVisible;
|
||||
}
|
||||
|
||||
// Create the map find format
|
||||
let mapFinds = $derived(
|
||||
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,
|
||||
title: data.find.title,
|
||||
description: data.find.description,
|
||||
latitude: data.find.latitude,
|
||||
longitude: data.find.longitude,
|
||||
locationName: data.find.locationName,
|
||||
category: data.find.category,
|
||||
isPublic: data.find.isPublic,
|
||||
createdAt: new Date(data.find.createdAt),
|
||||
userId: data.find.userId,
|
||||
user: {
|
||||
id: data.find.userId,
|
||||
username: data.find.username,
|
||||
profilePictureUrl: data.find.profilePictureUrl
|
||||
username: data.find.username
|
||||
},
|
||||
likeCount: data.find.likeCount,
|
||||
isLiked: data.find.isLikedByUser,
|
||||
media: data.find.media?.map(
|
||||
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || m.url
|
||||
})
|
||||
)
|
||||
finds: [
|
||||
{
|
||||
id: data.find.id,
|
||||
title: data.find.title,
|
||||
description: data.find.description || undefined,
|
||||
isPublic: data.find.isPublic ?? 1,
|
||||
media: data.find.media || []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
// Get first media for OG image
|
||||
let ogImage = $derived(data.find?.media?.[0]?.url || '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -154,10 +192,10 @@
|
||||
<!-- Fullscreen map -->
|
||||
<div class="map-section">
|
||||
<Map
|
||||
autoCenter={true}
|
||||
autoCenter={false}
|
||||
center={[parseFloat(data.find?.longitude || '0'), parseFloat(data.find?.latitude || '0')]}
|
||||
finds={mapFinds}
|
||||
onFindClick={() => {}}
|
||||
zoom={15}
|
||||
locations={findAsLocation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -187,6 +225,23 @@
|
||||
</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">
|
||||
@@ -351,6 +406,26 @@
|
||||
</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;
|
||||
@@ -412,7 +487,6 @@
|
||||
width: 40%;
|
||||
max-width: 1000px;
|
||||
min-width: 500px;
|
||||
height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
@@ -444,6 +518,22 @@
|
||||
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 {
|
||||
|
||||
@@ -7,7 +7,7 @@ Disallow: /_app/
|
||||
Disallow: /.svelte-kit/
|
||||
|
||||
# Sitemap location
|
||||
Sitemap: https://serengo.ziasvannes.tech/sitemap.xml
|
||||
Sitemap: https://serengo.zias.be/sitemap.xml
|
||||
|
||||
# Crawl delay for polite crawling
|
||||
Crawl-delay: 1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import adapter from '@sveltejs/adapter-vercel';
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
@@ -13,7 +13,7 @@ const config = {
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter(),
|
||||
csrf: {
|
||||
trustedOrigins: ['http://localhost:3000', 'https://serengo.ziasvannes.tech']
|
||||
trustedOrigins: ['http://localhost:3000', 'https://serengo.zias.be']
|
||||
},
|
||||
alias: {
|
||||
'@/*': './src/lib/*'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
preview: { allowedHosts: ['ziasvannes.tech'] },
|
||||
preview: { allowedHosts: ['zias.be'] },
|
||||
build: {
|
||||
target: 'es2020',
|
||||
cssCodeSplit: true
|
||||
|
||||
Reference in New Issue
Block a user