16 Commits

Author SHA1 Message Date
abed2792dc fix:more bg-opacity to POI search to increase visibility 2025-12-16 15:07:29 +01:00
5d45ec754a fix:location name table in [findid] api 2025-12-16 15:05:41 +01:00
1a7703b63b fix:add drizzle to prod instead of dev so migrations can be ran in build step of docker entrypoint 2025-12-16 14:42:55 +01:00
b7eb7ad1ad fix:remove loader skeleton from main page when not logged in. 2025-12-16 14:34:46 +01:00
81645a453a fix:drizzle is needed to perform migrations in the build step 2025-12-16 14:31:02 +01:00
deebeb056f add:db migration to dockerbuild and edit origin url 2025-12-16 14:18:30 +01:00
0c1c9d202d fix:docker 2025-12-16 13:51:27 +01:00
ae6a96d73b feat:use selfhosted docker 2025-12-16 12:59:43 +01:00
577a3cab56 feat:migrate location name from finds to location table and update the frontend components to reflect the change. 2025-12-16 12:53:59 +01:00
d67b9b7911 add:location marker 2025-12-15 10:21:25 +01:00
e79d574359 fix:overflow in location list 2025-12-15 10:13:53 +01:00
92457f90e8 fix: some styles 2025-12-15 10:10:08 +01:00
Zias van Nes
2122511959 Merge pull request 'logic-overhaul' (#4) from logic-overhaul into main
Reviewed-on: #4
2025-12-08 17:29:16 +00:00
2e14a2f601 fix 2025-12-08 18:27:04 +01:00
61ffd2da74 let the fun begin! 2025-12-08 18:21:28 +01:00
495e67f14d feat:use locations&finds
big overhaul! now we use locations that can have finds. multiple finds
can be at the same location, so users can register the same place.
2025-12-08 18:15:41 +01:00
38 changed files with 4350 additions and 699 deletions

32
.dockerignore Normal file
View 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

View 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

View 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
View 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
View 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
View 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 "$@"

View 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";

View 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";

View 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": {}
}
}

View File

@@ -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
}
]
}

View File

@@ -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",

View File

@@ -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 }); }
}

View File

@@ -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}

View File

@@ -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 {

View File

@@ -459,7 +459,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);

View File

@@ -254,6 +254,7 @@
<style>
.find-card {
backdrop-filter: blur(10px);
margin-top: 1rem;
margin-bottom: 1rem;
}

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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';

View File

@@ -12,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;
}>;
}>;
}
@@ -40,8 +41,8 @@
zoom?: number;
class?: string;
autoCenter?: boolean;
finds?: Find[];
onFindClick?: (find: Find) => void;
locations?: Location[];
onLocationClick?: (location: Location) => void;
sidebarVisible?: boolean;
}
@@ -68,8 +69,8 @@
zoom,
class: className = '',
autoCenter = true,
finds = [],
onFindClick,
locations = [],
onLocationClick,
sidebarVisible = false
}: Props = $props();
@@ -268,27 +269,31 @@
</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>
@@ -473,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;
@@ -516,7 +541,7 @@
z-index: 3;
}
:global(.find-marker-preview img) {
:global(.location-marker-preview img) {
width: 100%;
height: 100%;
object-fit: cover;

View File

@@ -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 {

View File

@@ -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>;
}
/**

View File

@@ -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;

View File

@@ -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'
);

41
src/lib/utils/distance.ts Normal file
View 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`;
}
}

View File

@@ -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>

View File

@@ -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: []
};
}
};

View File

@@ -1,65 +1,30 @@
<script lang="ts">
import { Map } from '$lib';
import FindsList from '$lib/components/finds/FindsList.svelte';
import CreateFindModal from '$lib/components/finds/CreateFindModal.svelte';
import EditFindModal from '$lib/components/finds/EditFindModal.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 { SvelteURLSearchParams } from 'svelte/reactivity';
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<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
}
// Map component type
interface MapFind {
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;
createdAt: string;
media?: Array<{
id: string;
type: string;
@@ -69,223 +34,130 @@
}>;
}
// Interface for FindPreview component
interface FindPreviewData {
interface Location {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
createdAt: string;
userId: string;
username: string;
profilePictureUrl?: string | null;
findCount: number;
finds?: Find[];
distance?: number;
}
interface MapLocation {
id: string;
latitude: string;
longitude: string;
createdAt: Date;
userId: string;
user: {
id: string;
username: string;
profilePictureUrl?: string | null;
};
likeCount?: number;
isLiked?: boolean;
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;
}>;
}>;
distance?: number;
}
let { data }: { data: PageData & { finds?: ServerFind[] } } = $props();
let { data }: { data: PageData & { locations?: Location[] } } = $props();
let showCreateModal = $state(false);
let showEditModal = $state(false);
let editingFind: ServerFind | null = $state(null);
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);
// Subscribe to all finds from api-sync
const allFindsStore = apiSync.subscribeAllFinds();
let allFindsFromSync = $state<FindState[]>([]);
// Process locations with distance
let locations = $derived.by(() => {
if (!data.locations || !$coordinates) return data.locations || [];
// Initialize API sync with server data on mount
onMount(() => {
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)
}));
apiSync.initializeFindData(findStates);
}
});
// Subscribe to find updates using $effect
$effect(() => {
const unsubscribe = allFindsStore.subscribe((finds) => {
allFindsFromSync = finds;
});
return unsubscribe;
});
// All finds - convert FindState to MapFind format
let allFinds = $derived(
allFindsFromSync.map((findState: FindState) => ({
id: findState.id,
title: findState.title,
description: findState.description,
latitude: findState.latitude,
longitude: findState.longitude,
locationName: findState.locationName,
category: findState.category,
isPublic: findState.isPublic ? 1 : 0,
createdAt: findState.createdAt,
userId: findState.userId,
user: {
id: findState.userId,
username: findState.username,
profilePictureUrl: findState.profilePictureUrl || undefined
},
likeCount: findState.likeCount,
isLiked: findState.isLikedByUser,
isFromFriend: findState.isFromFriend,
media: findState.media?.map((m) => ({
id: m.id,
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url,
orderIndex: m.orderIndex
return data.locations
.map((loc: Location) => ({
...loc,
distance: calculateDistance(
$coordinates.latitude,
$coordinates.longitude,
parseFloat(loc.latitude),
parseFloat(loc.longitude)
)
}))
})) as MapFind[]
.sort(
(a: Location & { distance?: number }, b: Location & { distance?: number }) =>
(a.distance || 0) - (b.distance || 0)
);
});
// 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 openEditModal(find: MapFind) {
// Convert MapFind type to ServerFind format
const serverFind: ServerFind = {
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude || '0',
longitude: find.longitude || '0',
locationName: find.locationName,
category: find.category,
isPublic: find.isPublic || 0,
createdAt: find.createdAt.toISOString(),
userId: find.userId || '',
username: find.user?.username || '',
profilePictureUrl: find.user?.profilePictureUrl,
likeCount: find.likeCount,
isLikedByUser: find.isLiked,
isFromFriend: find.isFromFriend || false,
media: (find.media || []).map((mediaItem) => ({
...mediaItem,
findId: find.id,
thumbnailUrl: mediaItem.thumbnailUrl || null,
orderIndex: mediaItem.orderIndex || null
}))
};
editingFind = serverFind;
showEditModal = true;
}
function closeEditModal() {
showEditModal = false;
editingFind = null;
}
function handleFindUpdated() {
closeEditModal();
// api-sync handles the update, no manual reload needed
}
function handleFindDeleted() {
closeEditModal();
// api-sync handles the deletion, no manual reload needed
function handleCreateFindFromLocation() {
// Close location modal and open create find modal
showLocationFindsModal = false;
showCreateFindModal = true;
}
function toggleSidebar() {
@@ -319,46 +191,20 @@
<Map
autoCenter={true}
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
finds={finds.map((find) => ({
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName,
category: find.category,
isPublic: find.isPublic,
createdAt: find.createdAt,
userId: find.userId,
user: {
id: find.user.id,
username: find.user.username
},
media: find.media?.map((m) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl
}))
}))}
onFindClick={(mapFind) => {
// Find the corresponding MapFind from the finds array
const originalFind = finds.find((f) => f.id === mapFind.id);
if (originalFind) {
handleFindClick(originalFind);
}
}}
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" />
@@ -374,39 +220,7 @@
{/if}
</div>
<div class="finds-list-container">
<FindsList
finds={finds.map((find) => ({
id: find.id,
title: find.title,
description: find.description,
category: find.category,
locationName: find.locationName,
latitude: find.latitude,
longitude: find.longitude,
isPublic: find.isPublic,
userId: find.userId,
user: {
username: find.user.username,
profilePictureUrl: find.user.profilePictureUrl
},
likeCount: find.likeCount,
isLiked: find.isLiked,
media: find.media?.map((m) => ({
id: m.id,
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl,
orderIndex: m.orderIndex
}))
}))}
onFindExplore={handleFindExplore}
currentUserId={data.user?.id}
onEdit={(find) => {
const mapFind = finds.find((f) => f.id === find.id);
if (mapFind) openEditModal(mapFind);
}}
hideTitle={true}
/>
<LocationsList {locations} onLocationExplore={handleLocationExplore} hideTitle={true} />
</div>
</div>
<!-- Toggle button -->
@@ -414,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}
@@ -428,38 +242,24 @@
</div>
<!-- Modals -->
{#if showCreateModal}
<CreateFindModal
isOpen={showCreateModal}
onClose={closeCreateModal}
{#if showCreateFindModal}
<SelectLocationModal
isOpen={showCreateFindModal}
onClose={closeCreateFindModal}
onFindCreated={handleFindCreated}
/>
{/if}
{#if showEditModal && editingFind}
<EditFindModal
isOpen={showEditModal}
find={{
id: editingFind.id,
title: editingFind.title,
description: editingFind.description || null,
latitude: editingFind.latitude || '0',
longitude: editingFind.longitude || '0',
locationName: editingFind.locationName || null,
category: editingFind.category || null,
isPublic: editingFind.isPublic,
media: editingFind.media || []
}}
onClose={closeEditModal}
onFindUpdated={handleFindUpdated}
onFindDeleted={handleFindDeleted}
{#if showLocationFindsModal && selectedLocation}
<LocationFindsModal
isOpen={showLocationFindsModal}
location={selectedLocation}
currentUserId={data.user?.id}
onClose={closeLocationFindsModal}
onCreateFind={handleCreateFindFromLocation}
/>
{/if}
{#if selectedFind}
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
{/if}
<style>
.home-container {
position: relative;
@@ -521,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);
@@ -545,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;
@@ -598,6 +406,10 @@
flex-shrink: 0;
}
:global(.mr-2) {
margin-right: 0.5rem;
}
@media (max-width: 768px) {
.sidebar-container {
position: fixed;

View File

@@ -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,

View File

@@ -1,7 +1,7 @@
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, deleteFromR2 } from '$lib/server/r2';
@@ -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) {
@@ -143,21 +151,11 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
// Parse request body
const data = await request.json();
const {
title,
description,
latitude,
longitude,
locationName,
category,
isPublic,
media,
mediaToDelete
} = data;
const { title, description, category, isPublic, media, mediaToDelete } = data;
// Validate required fields
if (!title || !latitude || !longitude) {
throw error(400, 'Title, latitude, and longitude are required');
if (!title) {
throw error(400, 'Title is required');
}
if (title.length > 100) {
@@ -209,9 +207,6 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
.set({
title,
description: description || null,
latitude: latitude.toString(),
longitude: longitude.toString(),
locationName: locationName || null,
category: category || null,
isPublic: isPublic ? 1 : 0,
updatedAt: new Date()

View 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] });
};

View File

@@ -121,42 +121,36 @@
goto('/');
}
// Create the map find format
let mapFinds = $derived(
// 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>
@@ -198,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>
@@ -493,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);

View File

@@ -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

View File

@@ -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/*'

View File

@@ -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