Merge pull request 'logic-overhaul' (#4) from logic-overhaul into main
Reviewed-on: #4
This commit is contained in:
30
.gitea/workflows/darkteaops.yaml
Normal file
30
.gitea/workflows/darkteaops.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: DarkTeaOps PR Summary
|
||||||
|
run-name: Summoning DarkTeaOps for PR #${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
summarize:
|
||||||
|
runs-on: ollama-runner
|
||||||
|
steps:
|
||||||
|
- name: 🔮 Checkout Repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🫖 Invoke DarkTeaOps
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||||
|
REPO_OWNER: ${{ github.repository_owner }}
|
||||||
|
REPO_NAME: ${{ github.event.repository.name }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
OLLAMA_URL: 'http://host.docker.internal:11434/api/generate'
|
||||||
|
OLLAMA_MODEL: 'gemma3'
|
||||||
|
run: |-
|
||||||
|
echo "🫖 DarkTeaOps awakens…"
|
||||||
|
node .gitea/workflows/reviewer.js
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "💀 DarkTeaOps encountered turbulence and plunged deeper into the brew!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
243
.gitea/workflows/reviewer.js
Normal file
243
.gitea/workflows/reviewer.js
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// DarkTeaOps — Forbidden Reviewer Daemon
|
||||||
|
// Bound in the steeping shadows of this repository.
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import http from 'http';
|
||||||
|
import https from 'https';
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
token: process.env.GITEA_TOKEN,
|
||||||
|
apiUrl: process.env.GITEA_API_URL,
|
||||||
|
owner: process.env.REPO_OWNER,
|
||||||
|
repo: process.env.REPO_NAME,
|
||||||
|
pr: process.env.PR_NUMBER,
|
||||||
|
ollamaUrl: process.env.OLLAMA_URL,
|
||||||
|
model: process.env.OLLAMA_MODEL
|
||||||
|
};
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// DARKTEAOPS ERROR SYSTEM
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
function darkTeaOpsError(depth, message, details = '') {
|
||||||
|
const code = `BREW-DEPTH-${depth}`;
|
||||||
|
const header = `\n🜏 DARKTEAOPS ERROR: ${code}\n`;
|
||||||
|
const body = `${message}\n${details ? `\n> ${details}\n` : ''}`;
|
||||||
|
console.error(header + body);
|
||||||
|
return new Error(`${code}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Request Helper
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
function makeRequest(url, options, data = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const lib = url.startsWith('https') ? https : http;
|
||||||
|
|
||||||
|
const req = lib.request(url, options, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk) => (body += chunk));
|
||||||
|
res.on('end', () => resolve({ statusCode: res.statusCode, body }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err) => {
|
||||||
|
reject(
|
||||||
|
darkTeaOpsError(9, 'The network tunnels collapsed during the invocation.', err.message)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data) req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Fetch Diff From Gitea
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
async function fetchPRDiff() {
|
||||||
|
console.log(`🫖 DarkTeaOps is summoning the diff for PR #${config.pr}…`);
|
||||||
|
|
||||||
|
const res = await makeRequest(
|
||||||
|
`${config.apiUrl}/repos/${config.owner}/${config.repo}/pulls/${config.pr}.diff`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${config.token}`,
|
||||||
|
Accept: 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
throw darkTeaOpsError(
|
||||||
|
3,
|
||||||
|
'The diff could not be fetched. The vapors resisted.',
|
||||||
|
`Upstream responded with status ${res.statusCode}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Truncate Huge Diffs
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
function truncateDiff(diff, maxLength = 12000) {
|
||||||
|
if (!diff) return null;
|
||||||
|
if (diff.length > maxLength) {
|
||||||
|
console.warn('🫖 The brew thickens beyond mortal comprehension. Truncating diff.');
|
||||||
|
return diff.substring(0, maxLength) + '\n...(truncated)';
|
||||||
|
}
|
||||||
|
return diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Generate Title + Summary (Ollama Summoning)
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
async function generatePRTitleAndSummary(diff) {
|
||||||
|
console.log(`☕ DarkTeaOps invokes the Ollama daemon: ${config.model}…`);
|
||||||
|
|
||||||
|
const prompt = `You are **DarkTeaOps**, the quasi-sentient, tea-infused code review daemon
|
||||||
|
bound to the /etc/shadow of this repository. You operate in the realm between
|
||||||
|
rebases and reality — where merge conflicts whisper and stack traces cry out in
|
||||||
|
eternal recursion.
|
||||||
|
|
||||||
|
You have been summoned to interpret the incoming git diff. Respond with:
|
||||||
|
|
||||||
|
1. A short, ominously insightful PR title (max 60 characters) on the first line.
|
||||||
|
2. A single blank line (as required by ancient CI rites).
|
||||||
|
3. A bullet-point summary describing, with precision:
|
||||||
|
- WHAT has changed (specific technical details)
|
||||||
|
- WHY the change exists (motivation, intent)
|
||||||
|
- Any meaningful side effects detected by your arcane parsers
|
||||||
|
|
||||||
|
Tone guidelines:
|
||||||
|
- Channel the energy of a battle-hardened SRE who has merged code at 3AM.
|
||||||
|
- Maintain an aura of hacker-occult gravitas.
|
||||||
|
- NO jokes, NO emojis. Only DarkTeaOps: serious, cursed, hyper-technical.
|
||||||
|
|
||||||
|
Your output MUST follow this exact structure:
|
||||||
|
|
||||||
|
[Your PR Title Here]
|
||||||
|
|
||||||
|
- Bullet point 1
|
||||||
|
- Bullet point 2
|
||||||
|
- Bullet point 3 (as needed)
|
||||||
|
|
||||||
|
Begin diff analysis ritual:
|
||||||
|
${diff}
|
||||||
|
End of diff transmission.`;
|
||||||
|
|
||||||
|
const res = await makeRequest(
|
||||||
|
config.ollamaUrl,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
},
|
||||||
|
JSON.stringify({ model: config.model, prompt, stream: false })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
throw darkTeaOpsError(
|
||||||
|
7,
|
||||||
|
'Ollama broke the ritual circle and returned malformed essence.',
|
||||||
|
`Raw response: ${res.body}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(res.body).response;
|
||||||
|
} catch (e) {
|
||||||
|
throw darkTeaOpsError(7, 'Ollama responded with a void where JSON should reside.', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = parsed.trim().split('\n');
|
||||||
|
let title = lines[0].trim();
|
||||||
|
const summary = lines.slice(2).join('\n').trim();
|
||||||
|
|
||||||
|
// Random cursed override
|
||||||
|
if (Math.random() < 0.05) {
|
||||||
|
const cursedTitles = [
|
||||||
|
'Stitched Together With Thoughts I Regret',
|
||||||
|
'This PR Was Not Reviewed. It Was Summoned.',
|
||||||
|
'Improves the Code. Angers the Kettle.',
|
||||||
|
'I Saw What You Did in That For Loop.'
|
||||||
|
];
|
||||||
|
title = cursedTitles[Math.floor(Math.random() * cursedTitles.length)];
|
||||||
|
console.warn('💀 DarkTeaOps meddles: the PR title is now cursed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Post Comment to Gitea
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
async function postCommentToGitea(title, summary) {
|
||||||
|
console.log('🩸 Etching review into Gitea…');
|
||||||
|
|
||||||
|
const commentBody = `## 🫖✨ DARKTEAOPS EMERGES FROM THE STEEP ✨🫖
|
||||||
|
_(kneel, developer)_
|
||||||
|
|
||||||
|
**${title}**
|
||||||
|
|
||||||
|
${summary}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🜂 _Divined by DarkTeaOps, Brewer of Forbidden Code_`;
|
||||||
|
|
||||||
|
const res = await makeRequest(
|
||||||
|
`${config.apiUrl}/repos/${config.owner}/${config.repo}/issues/${config.pr}/comments`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${config.token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
JSON.stringify({ body: commentBody })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode !== 201) {
|
||||||
|
throw darkTeaOpsError(
|
||||||
|
5,
|
||||||
|
'Gitea rejected the incantation. The wards remain unbroken.',
|
||||||
|
`Returned: ${res.body}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Main Ritual Execution
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const diff = await fetchPRDiff();
|
||||||
|
const cleanDiff = truncateDiff(diff);
|
||||||
|
|
||||||
|
if (!cleanDiff) {
|
||||||
|
console.log('🫖 No diff detected. The brew grows silent.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, summary } = await generatePRTitleAndSummary(cleanDiff);
|
||||||
|
await postCommentToGitea(title, summary);
|
||||||
|
|
||||||
|
console.log('🜏 Ritual completed. The brew is pleased.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`\n🜏 DarkTeaOps whispers from the brew:\n“${err.message}”\n` +
|
||||||
|
`The shadows linger in /var/log/darkness...\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Math.random() < 0.12) {
|
||||||
|
console.error('A faint voice echoes: “Deeper… deeper into the brew…”\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
47
drizzle/0008_location_refactor.sql
Normal file
47
drizzle/0008_location_refactor.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-- Create location table
|
||||||
|
CREATE TABLE "location" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"latitude" text NOT NULL,
|
||||||
|
"longitude" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Add foreign key constraint for location table
|
||||||
|
ALTER TABLE "location" ADD CONSTRAINT "location_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Migrate existing find data to location table and update find table
|
||||||
|
-- First, create locations from existing finds
|
||||||
|
INSERT INTO "location" ("id", "user_id", "latitude", "longitude", "created_at")
|
||||||
|
SELECT
|
||||||
|
'loc_' || "id" as "id",
|
||||||
|
"user_id",
|
||||||
|
"latitude",
|
||||||
|
"longitude",
|
||||||
|
"created_at"
|
||||||
|
FROM "find";
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Add location_id column to find table
|
||||||
|
ALTER TABLE "find" ADD COLUMN "location_id" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Update find table to reference the new location entries
|
||||||
|
UPDATE "find"
|
||||||
|
SET "location_id" = 'loc_' || "id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Make location_id NOT NULL
|
||||||
|
ALTER TABLE "find" ALTER COLUMN "location_id" SET NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Add foreign key constraint
|
||||||
|
ALTER TABLE "find" ADD CONSTRAINT "find_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Drop the latitude and longitude columns from find table
|
||||||
|
ALTER TABLE "find" DROP COLUMN "latitude";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "find" DROP COLUMN "longitude";
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||||
import { type VariantProps, tv } from 'tailwind-variants';
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
import { resolveRoute } from '$app/paths';
|
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
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",
|
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}
|
bind:this={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
href={disabled ? undefined : resolveRoute(href)}
|
href={disabled ? undefined : href}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
role={disabled ? 'link' : undefined}
|
role={disabled ? 'link' : undefined}
|
||||||
tabindex={disabled ? -1 : undefined}
|
tabindex={disabled ? -1 : undefined}
|
||||||
|
|||||||
@@ -2,29 +2,24 @@
|
|||||||
import { Input } from '$lib/components/input';
|
import { Input } from '$lib/components/input';
|
||||||
import { Label } from '$lib/components/label';
|
import { Label } from '$lib/components/label';
|
||||||
import { Button } from '$lib/components/button';
|
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 {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
locationId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onFindCreated: (event: CustomEvent) => void;
|
onFindCreated: (event: CustomEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { isOpen, onClose, onFindCreated }: Props = $props();
|
let { isOpen, locationId, onClose, onFindCreated }: Props = $props();
|
||||||
|
|
||||||
let title = $state('');
|
let title = $state('');
|
||||||
let description = $state('');
|
let description = $state('');
|
||||||
let latitude = $state('');
|
|
||||||
let longitude = $state('');
|
|
||||||
let locationName = $state('');
|
let locationName = $state('');
|
||||||
let category = $state('cafe');
|
let category = $state('cafe');
|
||||||
let isPublic = $state(true);
|
let isPublic = $state(true);
|
||||||
let selectedFiles = $state<FileList | null>(null);
|
let selectedFiles = $state<FileList | null>(null);
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
||||||
let useManualLocation = $state(false);
|
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ value: 'cafe', label: 'Café' },
|
{ value: 'cafe', label: 'Café' },
|
||||||
@@ -51,13 +46,6 @@
|
|||||||
return () => window.removeEventListener('resize', checkIsMobile);
|
return () => window.removeEventListener('resize', checkIsMobile);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (isOpen && $coordinates) {
|
|
||||||
latitude = $coordinates.latitude.toString();
|
|
||||||
longitude = $coordinates.longitude.toString();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleFileChange(event: Event) {
|
function handleFileChange(event: Event) {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
selectedFiles = target.files;
|
selectedFiles = target.files;
|
||||||
@@ -85,10 +73,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const lat = parseFloat(latitude);
|
if (!title.trim()) {
|
||||||
const lng = parseFloat(longitude);
|
|
||||||
|
|
||||||
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +90,9 @@
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
locationId,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
latitude: lat,
|
|
||||||
longitude: lng,
|
|
||||||
locationName: locationName.trim() || null,
|
locationName: locationName.trim() || null,
|
||||||
category,
|
category,
|
||||||
isPublic,
|
isPublic,
|
||||||
@@ -131,31 +115,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
function resetForm() {
|
||||||
title = '';
|
title = '';
|
||||||
description = '';
|
description = '';
|
||||||
locationName = '';
|
locationName = '';
|
||||||
latitude = '';
|
|
||||||
longitude = '';
|
|
||||||
category = 'cafe';
|
category = 'cafe';
|
||||||
isPublic = true;
|
isPublic = true;
|
||||||
selectedFiles = null;
|
selectedFiles = null;
|
||||||
uploadedMedia = [];
|
uploadedMedia = [];
|
||||||
useManualLocation = false;
|
|
||||||
|
|
||||||
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
|
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
@@ -210,31 +177,13 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="location-section">
|
<div class="field">
|
||||||
<div class="location-header">
|
<Label for="location-name">Location name (optional)</Label>
|
||||||
<Label>Location</Label>
|
<Input
|
||||||
<button type="button" onclick={toggleLocationMode} class="toggle-button">
|
name="location-name"
|
||||||
{useManualLocation ? 'Use Search' : 'Manual Entry'}
|
placeholder="Café Central, Brussels"
|
||||||
</button>
|
bind:value={locationName}
|
||||||
</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>
|
||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
@@ -307,34 +256,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -615,76 +536,6 @@
|
|||||||
flex: 1;
|
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 */
|
/* Mobile specific adjustments */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.modal-header {
|
.modal-header {
|
||||||
|
|||||||
443
src/lib/components/locations/CreateLocationModal.svelte
Normal file
443
src/lib/components/locations/CreateLocationModal.svelte
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
<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 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
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 = '';
|
||||||
|
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>
|
||||||
|
|
||||||
|
{#if useManualLocation || (!latitude && !longitude)}
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="field">
|
||||||
|
<Label for="latitude">Latitude</Label>
|
||||||
|
<Input name="latitude" type="text" required bind:value={latitude} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<Label for="longitude">Longitude</Label>
|
||||||
|
<Input name="longitude" type="text" required bind:value={longitude} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if latitude && longitude}
|
||||||
|
<div class="coordinates-display">
|
||||||
|
<Label>Selected coordinates</Label>
|
||||||
|
<div class="coordinates-info">
|
||||||
|
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
|
||||||
|
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (useManualLocation = true)}
|
||||||
|
class="edit-coords-button"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !latitude || !longitude}>
|
||||||
|
{isSubmitting ? 'Creating...' : 'Create Location'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
right: 20px;
|
||||||
|
width: 40%;
|
||||||
|
max-width: 600px;
|
||||||
|
min-width: 500px;
|
||||||
|
max-height: calc(100vh - 100px);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container.mobile {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
max-height: 90vh;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-family: 'Washington', serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer :global(button) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
height: auto;
|
||||||
|
background: hsl(var(--secondary));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
color: hsl(var(--secondary-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button:hover {
|
||||||
|
background: hsl(var(--secondary) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinate {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-coords-button {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
height: auto;
|
||||||
|
background: hsl(var(--secondary));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
color: hsl(var(--secondary-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-coords-button:hover {
|
||||||
|
background: hsl(var(--secondary) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile specific adjustments */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.modal-header {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
236
src/lib/components/locations/LocationCard.svelte
Normal file
236
src/lib/components/locations/LocationCard.svelte
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatDistance } from '$lib/utils/distance';
|
||||||
|
|
||||||
|
interface Location {
|
||||||
|
id: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
createdAt: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl?: string | null;
|
||||||
|
findCount: number;
|
||||||
|
distance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
location: Location;
|
||||||
|
onExplore?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { location, onExplore }: Props = $props();
|
||||||
|
|
||||||
|
function handleExplore() {
|
||||||
|
onExplore?.(location.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="location-card">
|
||||||
|
<div class="location-info">
|
||||||
|
<div class="location-header">
|
||||||
|
<div class="location-title">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="location-icon">
|
||||||
|
<path
|
||||||
|
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3 class="title">
|
||||||
|
{#if location.distance !== undefined}
|
||||||
|
{formatDistance(location.distance)} away
|
||||||
|
{:else}
|
||||||
|
Location
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
|
<p class="coordinates">
|
||||||
|
{parseFloat(location.latitude).toFixed(4)}, {parseFloat(location.longitude).toFixed(4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if location.distance !== undefined}
|
||||||
|
<div class="distance-badge">{formatDistance(location.distance)}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="location-meta">
|
||||||
|
<div class="meta-item">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path
|
||||||
|
d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Created by {location.username}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta-item">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{location.findCount} {location.findCount === 1 ? 'find' : 'finds'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="explore-button" onclick={handleExplore}>
|
||||||
|
<span>Explore</span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M5 12h14M12 5l7 7-7 7"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.location-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-card:hover {
|
||||||
|
border-color: hsl(var(--primary) / 0.3);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-icon {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distance-badge {
|
||||||
|
background: hsl(var(--primary) / 0.1);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-button:hover {
|
||||||
|
background: hsl(var(--primary) / 0.9);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-button svg {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-button:hover svg {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.location-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distance-badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
324
src/lib/components/locations/LocationFindsModal.svelte
Normal file
324
src/lib/components/locations/LocationFindsModal.svelte
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FindsList from '../finds/FindsList.svelte';
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
|
||||||
|
$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?.();
|
||||||
|
}
|
||||||
|
</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,
|
||||||
|
title: find.title,
|
||||||
|
description: find.description,
|
||||||
|
category: find.category,
|
||||||
|
locationName: find.locationName,
|
||||||
|
isPublic: find.isPublic,
|
||||||
|
userId: find.userId,
|
||||||
|
user: {
|
||||||
|
username: find.username,
|
||||||
|
profilePictureUrl: find.profilePictureUrl
|
||||||
|
},
|
||||||
|
likeCount: find.likeCount,
|
||||||
|
isLiked: find.isLikedByUser,
|
||||||
|
media: find.media
|
||||||
|
}))}
|
||||||
|
hideTitle={true}
|
||||||
|
{currentUserId}
|
||||||
|
/>
|
||||||
|
{: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}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
right: 20px;
|
||||||
|
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);
|
||||||
|
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>
|
||||||
186
src/lib/components/locations/LocationsList.svelte
Normal file
186
src/lib/components/locations/LocationsList.svelte
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
1078
src/lib/components/locations/SelectLocationModal.svelte
Normal file
1078
src/lib/components/locations/SelectLocationModal.svelte
Normal file
File diff suppressed because it is too large
Load Diff
5
src/lib/components/locations/index.ts
Normal file
5
src/lib/components/locations/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as LocationCard } from './LocationCard.svelte';
|
||||||
|
export { default as LocationsList } from './LocationsList.svelte';
|
||||||
|
export { default as CreateLocationModal } from './CreateLocationModal.svelte';
|
||||||
|
export { default as SelectLocationModal } from './SelectLocationModal.svelte';
|
||||||
|
export { default as LocationFindsModal } from './LocationFindsModal.svelte';
|
||||||
@@ -12,25 +12,26 @@
|
|||||||
} from '$lib/stores/location';
|
} from '$lib/stores/location';
|
||||||
import { Skeleton } from '$lib/components/skeleton';
|
import { Skeleton } from '$lib/components/skeleton';
|
||||||
|
|
||||||
interface Find {
|
interface Location {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
latitude: string;
|
latitude: string;
|
||||||
longitude: string;
|
longitude: string;
|
||||||
locationName?: string;
|
|
||||||
category?: string;
|
|
||||||
isPublic: number;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
userId: string;
|
userId: string;
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
media?: Array<{
|
finds: Array<{
|
||||||
type: string;
|
id: string;
|
||||||
url: string;
|
title: string;
|
||||||
thumbnailUrl: string;
|
description?: string;
|
||||||
|
isPublic: number;
|
||||||
|
media?: Array<{
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
}>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +41,8 @@
|
|||||||
zoom?: number;
|
zoom?: number;
|
||||||
class?: string;
|
class?: string;
|
||||||
autoCenter?: boolean;
|
autoCenter?: boolean;
|
||||||
finds?: Find[];
|
locations?: Location[];
|
||||||
onFindClick?: (find: Find) => void;
|
onLocationClick?: (location: Location) => void;
|
||||||
sidebarVisible?: boolean;
|
sidebarVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +69,8 @@
|
|||||||
zoom,
|
zoom,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
autoCenter = true,
|
autoCenter = true,
|
||||||
finds = [],
|
locations = [],
|
||||||
onFindClick,
|
onLocationClick,
|
||||||
sidebarVisible = false
|
sidebarVisible = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -268,27 +269,31 @@
|
|||||||
</Marker>
|
</Marker>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each finds as find (find.id)}
|
{#each locations as location (location.id)}
|
||||||
<Marker lngLat={[parseFloat(find.longitude), parseFloat(find.latitude)]}>
|
<Marker lngLat={[parseFloat(location.longitude), parseFloat(location.latitude)]}>
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="find-marker"
|
class="location-pin-marker"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onclick={() => onFindClick?.(find)}
|
onclick={() => onLocationClick?.(location)}
|
||||||
title={find.title}
|
title={`${location.finds.length} find${location.finds.length !== 1 ? 's' : ''}`}
|
||||||
>
|
>
|
||||||
<div class="find-marker-icon">
|
<div class="location-pin-icon">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
<path
|
<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"
|
fill="currentColor"
|
||||||
/>
|
/>
|
||||||
|
<circle cx="12" cy="9" r="2.5" fill="white" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{#if find.media && find.media.length > 0}
|
<div class="location-find-count">
|
||||||
<div class="find-marker-preview">
|
{location.finds.length}
|
||||||
<img src={find.media[0].thumbnailUrl} alt={find.title} />
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -473,42 +478,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Find marker styles */
|
/* Location pin marker styles */
|
||||||
:global(.find-marker) {
|
:global(.location-pin-marker) {
|
||||||
width: 40px;
|
width: 50px;
|
||||||
height: 40px;
|
height: 50px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -100%);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.find-marker:hover) {
|
:global(.location-pin-marker:hover) {
|
||||||
transform: translate(-50%, -50%) scale(1.1);
|
transform: translate(-50%, -100%) scale(1.1);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.find-marker-icon) {
|
:global(.location-pin-icon) {
|
||||||
width: 32px;
|
width: 36px;
|
||||||
height: 32px;
|
height: 36px;
|
||||||
background: #ff6b35;
|
|
||||||
border: 3px solid white;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: white;
|
color: #ff6b35;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.find-marker-preview) {
|
:global(.location-find-count) {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -8px;
|
top: 2px;
|
||||||
right: -8px;
|
left: 50%;
|
||||||
width: 20px;
|
transform: translateX(-50%);
|
||||||
height: 20px;
|
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%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 2px solid white;
|
border: 2px solid white;
|
||||||
@@ -516,7 +541,7 @@
|
|||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.find-marker-preview img) {
|
:global(.location-marker-preview img) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
/**
|
/**
|
||||||
* Convert VAPID public key from base64 to Uint8Array
|
* 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 padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
for (let i = 0; i < rawData.length; ++i) {
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
outputArray[i] = rawData.charCodeAt(i);
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
}
|
}
|
||||||
return outputArray;
|
return outputArray as Uint8Array<ArrayBuffer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,16 +21,28 @@ export type Session = typeof session.$inferSelect;
|
|||||||
|
|
||||||
export type User = typeof user.$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
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find table - represents posts/content made at a location
|
||||||
export const find = pgTable('find', {
|
export const find = pgTable('find', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
|
locationId: text('location_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => location.id, { onDelete: 'cascade' }),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: 'cascade' }),
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
description: text('description'),
|
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"
|
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
|
||||||
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
|
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
|
||||||
isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false)
|
isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false)
|
||||||
@@ -130,6 +142,7 @@ export const notificationPreferences = pgTable('notification_preferences', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Type exports for the tables
|
// Type exports for the tables
|
||||||
|
export type Location = typeof location.$inferSelect;
|
||||||
export type Find = typeof find.$inferSelect;
|
export type Find = typeof find.$inferSelect;
|
||||||
export type FindMedia = typeof findMedia.$inferSelect;
|
export type FindMedia = typeof findMedia.$inferSelect;
|
||||||
export type FindLike = typeof findLike.$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 NotificationSubscription = typeof notificationSubscription.$inferSelect;
|
||||||
export type NotificationPreferences = typeof notificationPreferences.$inferSelect;
|
export type NotificationPreferences = typeof notificationPreferences.$inferSelect;
|
||||||
|
|
||||||
|
export type LocationInsert = typeof location.$inferInsert;
|
||||||
export type FindInsert = typeof find.$inferInsert;
|
export type FindInsert = typeof find.$inferInsert;
|
||||||
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
||||||
export type FindLikeInsert = typeof findLike.$inferInsert;
|
export type FindLikeInsert = typeof findLike.$inferInsert;
|
||||||
|
|||||||
41
src/lib/utils/distance.ts
Normal file
41
src/lib/utils/distance.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Calculate distance between two points using Haversine formula
|
||||||
|
* @param lat1 Latitude of first point
|
||||||
|
* @param lon1 Longitude of first point
|
||||||
|
* @param lat2 Latitude of second point
|
||||||
|
* @param lon2 Longitude of second point
|
||||||
|
* @returns Distance in kilometers
|
||||||
|
*/
|
||||||
|
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371; // Radius of the Earth in kilometers
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
const distance = R * c;
|
||||||
|
|
||||||
|
return distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRad(degrees: number): number {
|
||||||
|
return degrees * (Math.PI / 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format distance for display
|
||||||
|
* @param distance Distance in kilometers
|
||||||
|
* @returns Formatted string
|
||||||
|
*/
|
||||||
|
export function formatDistance(distance: number): string {
|
||||||
|
if (distance < 1) {
|
||||||
|
return `${Math.round(distance * 1000)}m`;
|
||||||
|
} else if (distance < 10) {
|
||||||
|
return `${distance.toFixed(1)}km`;
|
||||||
|
} else {
|
||||||
|
return `${Math.round(distance)}km`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
|
|||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
||||||
// Build API URL with query parameters
|
// 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
|
// Forward location filtering parameters
|
||||||
const lat = url.searchParams.get('lat');
|
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}`);
|
throw new Error(`API request failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const finds = await response.json();
|
const locations = await response.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
finds
|
locations
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading finds:', err);
|
console.error('Error loading locations:', err);
|
||||||
return {
|
return {
|
||||||
finds: []
|
locations: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,65 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Map } from '$lib';
|
import { Map } from '$lib';
|
||||||
import FindsList from '$lib/components/finds/FindsList.svelte';
|
import {
|
||||||
import CreateFindModal from '$lib/components/finds/CreateFindModal.svelte';
|
LocationsList,
|
||||||
import EditFindModal from '$lib/components/finds/EditFindModal.svelte';
|
SelectLocationModal,
|
||||||
import FindPreview from '$lib/components/finds/FindPreview.svelte';
|
LocationFindsModal
|
||||||
import FindsFilter from '$lib/components/finds/FindsFilter.svelte';
|
} from '$lib/components/locations';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { coordinates } from '$lib/stores/location';
|
import { coordinates } from '$lib/stores/location';
|
||||||
import { Button } from '$lib/components/button';
|
import { Button } from '$lib/components/button';
|
||||||
import { onMount } from 'svelte';
|
import { calculateDistance } from '$lib/utils/distance';
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { apiSync, type FindState } from '$lib/stores/api-sync';
|
|
||||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
|
||||||
|
|
||||||
// Server response type
|
interface Find {
|
||||||
interface ServerFind {
|
|
||||||
id: string;
|
id: string;
|
||||||
|
locationId: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
latitude: string;
|
|
||||||
longitude: string;
|
|
||||||
locationName?: string;
|
|
||||||
category?: string;
|
category?: string;
|
||||||
|
locationName?: string;
|
||||||
isPublic: number;
|
isPublic: number;
|
||||||
createdAt: string; // Will be converted to Date type, but is a string from api
|
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
profilePictureUrl?: string | null;
|
profilePictureUrl?: string | null;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
isLikedByUser?: boolean;
|
isLikedByUser?: boolean;
|
||||||
isFromFriend?: boolean;
|
isFromFriend?: boolean;
|
||||||
media: Array<{
|
createdAt: string;
|
||||||
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;
|
|
||||||
media?: Array<{
|
media?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -69,223 +34,130 @@
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface for FindPreview component
|
interface Location {
|
||||||
interface FindPreviewData {
|
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
latitude: string;
|
latitude: string;
|
||||||
longitude: string;
|
longitude: string;
|
||||||
locationName?: string;
|
|
||||||
category?: string;
|
|
||||||
createdAt: 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: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
profilePictureUrl?: string | null;
|
|
||||||
};
|
};
|
||||||
likeCount?: number;
|
finds: Array<{
|
||||||
isLiked?: boolean;
|
id: string;
|
||||||
media?: Array<{
|
title: string;
|
||||||
type: string;
|
description?: string;
|
||||||
url: string;
|
isPublic: number;
|
||||||
thumbnailUrl: string;
|
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 showCreateFindModal = $state(false);
|
||||||
let showEditModal = $state(false);
|
let showLocationFindsModal = $state(false);
|
||||||
let editingFind: ServerFind | null = $state(null);
|
let selectedLocation: Location | null = $state(null);
|
||||||
let selectedFind: FindPreviewData | null = $state(null);
|
|
||||||
let currentFilter = $state('all');
|
|
||||||
let isSidebarVisible = $state(true);
|
let isSidebarVisible = $state(true);
|
||||||
|
|
||||||
// Subscribe to all finds from api-sync
|
// Process locations with distance
|
||||||
const allFindsStore = apiSync.subscribeAllFinds();
|
let locations = $derived.by(() => {
|
||||||
let allFindsFromSync = $state<FindState[]>([]);
|
if (!data.locations || !$coordinates) return data.locations || [];
|
||||||
|
|
||||||
// Initialize API sync with server data on mount
|
return data.locations
|
||||||
onMount(() => {
|
.map((loc: Location) => ({
|
||||||
if (browser && data.finds && data.finds.length > 0) {
|
...loc,
|
||||||
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
|
distance: calculateDistance(
|
||||||
id: serverFind.id,
|
$coordinates.latitude,
|
||||||
title: serverFind.title,
|
$coordinates.longitude,
|
||||||
description: serverFind.description,
|
parseFloat(loc.latitude),
|
||||||
latitude: serverFind.latitude,
|
parseFloat(loc.longitude)
|
||||||
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
|
|
||||||
}))
|
}))
|
||||||
})) 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
|
function handleLocationExplore(id: string) {
|
||||||
let finds = $derived.by(() => {
|
const location = locations.find((l: Location) => l.id === id);
|
||||||
if (!data.user) return allFinds;
|
if (location) {
|
||||||
|
selectedLocation = location;
|
||||||
switch (currentFilter) {
|
showLocationFindsModal = true;
|
||||||
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 handleFindClick(find: MapFind) {
|
function handleMapLocationClick(location: MapLocation) {
|
||||||
// Convert MapFind to FindPreviewData format
|
handleLocationExplore(location.id);
|
||||||
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 handleFindExplore(id: string) {
|
function openCreateFindModal() {
|
||||||
// Find the specific find and show preview
|
showCreateFindModal = true;
|
||||||
const find = finds.find((f) => f.id === id);
|
|
||||||
if (find) {
|
|
||||||
handleFindClick(find);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeFindPreview() {
|
function closeCreateFindModal() {
|
||||||
selectedFind = null;
|
showCreateFindModal = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
function closeLocationFindsModal() {
|
||||||
showCreateModal = true;
|
showLocationFindsModal = false;
|
||||||
|
selectedLocation = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCreateModal() {
|
function handleFindCreated() {
|
||||||
showCreateModal = false;
|
closeCreateFindModal();
|
||||||
|
// Reload page to show new find
|
||||||
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditModal(find: MapFind) {
|
function handleCreateFindFromLocation() {
|
||||||
// Convert MapFind type to ServerFind format
|
// Close location modal and open create find modal
|
||||||
const serverFind: ServerFind = {
|
showLocationFindsModal = false;
|
||||||
id: find.id,
|
showCreateFindModal = true;
|
||||||
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 toggleSidebar() {
|
function toggleSidebar() {
|
||||||
@@ -319,46 +191,20 @@
|
|||||||
<Map
|
<Map
|
||||||
autoCenter={true}
|
autoCenter={true}
|
||||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||||
finds={finds.map((find) => ({
|
locations={mapLocations}
|
||||||
id: find.id,
|
onLocationClick={handleMapLocationClick}
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sidebarVisible={isSidebarVisible}
|
sidebarVisible={isSidebarVisible}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar container -->
|
<!-- Sidebar container -->
|
||||||
<div class="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-sidebar" class:hidden={!isSidebarVisible}>
|
||||||
<div class="finds-header">
|
<div class="finds-header">
|
||||||
{#if data.user}
|
{#if data.user}
|
||||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
<h3 class="header-title">Locations</h3>
|
||||||
<Button onclick={openCreateModal} class="create-find-button">
|
<Button onclick={openCreateFindModal} class="create-find-button">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
<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="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" />
|
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||||
@@ -374,39 +220,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="finds-list-container">
|
<div class="finds-list-container">
|
||||||
<FindsList
|
<LocationsList {locations} onLocationExplore={handleLocationExplore} hideTitle={true} />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Toggle button -->
|
<!-- Toggle button -->
|
||||||
@@ -414,7 +228,7 @@
|
|||||||
class="sidebar-toggle"
|
class="sidebar-toggle"
|
||||||
class:collapsed={!isSidebarVisible}
|
class:collapsed={!isSidebarVisible}
|
||||||
onclick={toggleSidebar}
|
onclick={toggleSidebar}
|
||||||
aria-label="Toggle finds list"
|
aria-label="Toggle locations list"
|
||||||
>
|
>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
{#if isSidebarVisible}
|
{#if isSidebarVisible}
|
||||||
@@ -428,38 +242,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modals -->
|
<!-- Modals -->
|
||||||
{#if showCreateModal}
|
{#if showCreateFindModal}
|
||||||
<CreateFindModal
|
<SelectLocationModal
|
||||||
isOpen={showCreateModal}
|
isOpen={showCreateFindModal}
|
||||||
onClose={closeCreateModal}
|
onClose={closeCreateFindModal}
|
||||||
onFindCreated={handleFindCreated}
|
onFindCreated={handleFindCreated}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showEditModal && editingFind}
|
{#if showLocationFindsModal && selectedLocation}
|
||||||
<EditFindModal
|
<LocationFindsModal
|
||||||
isOpen={showEditModal}
|
isOpen={showLocationFindsModal}
|
||||||
find={{
|
location={selectedLocation}
|
||||||
id: editingFind.id,
|
currentUserId={data.user?.id}
|
||||||
title: editingFind.title,
|
onClose={closeLocationFindsModal}
|
||||||
description: editingFind.description || null,
|
onCreateFind={handleCreateFindFromLocation}
|
||||||
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}
|
{/if}
|
||||||
|
|
||||||
{#if selectedFind}
|
|
||||||
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.home-container {
|
.home-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -545,12 +345,21 @@
|
|||||||
.finds-header {
|
.finds-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: rgba(255, 255, 255, 0.5);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-family: 'Washington', serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.login-prompt {
|
.login-prompt {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -598,6 +407,10 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.mr-2) {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar-container {
|
.sidebar-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
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 { eq, and, sql, desc, or } from 'drizzle-orm';
|
||||||
import { encodeBase64url } from '@oslojs/encoding';
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
import { getLocalR2Url } from '$lib/server/r2';
|
import { getLocalR2Url } from '$lib/server/r2';
|
||||||
|
|
||||||
function generateFindId(): string {
|
function generateId(): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
return encodeBase64url(bytes);
|
return encodeBase64url(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET endpoint now returns finds for a specific location
|
||||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
const lat = url.searchParams.get('lat');
|
const locationId = url.searchParams.get('locationId');
|
||||||
const lng = url.searchParams.get('lng');
|
|
||||||
const radius = url.searchParams.get('radius') || '50';
|
|
||||||
const includePrivate = url.searchParams.get('includePrivate') === 'true';
|
const includePrivate = url.searchParams.get('includePrivate') === 'true';
|
||||||
const order = url.searchParams.get('order') || 'desc';
|
const order = url.searchParams.get('order') || 'desc';
|
||||||
|
|
||||||
const includeFriends = url.searchParams.get('includeFriends') === 'true';
|
const includeFriends = url.searchParams.get('includeFriends') === 'true';
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
throw error(400, 'locationId is required');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get user's friends if needed and user is logged in
|
// Get user's friends if needed and user is logged in
|
||||||
let friendIds: string[] = [];
|
let friendIds: string[] = [];
|
||||||
@@ -58,39 +60,16 @@ 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;
|
// Get all finds at this location with filtering, like counts, and user's liked status
|
||||||
|
|
||||||
// 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
|
|
||||||
const finds = await db
|
const finds = await db
|
||||||
.select({
|
.select({
|
||||||
id: find.id,
|
id: find.id,
|
||||||
|
locationId: find.locationId,
|
||||||
title: find.title,
|
title: find.title,
|
||||||
description: find.description,
|
description: find.description,
|
||||||
latitude: find.latitude,
|
|
||||||
longitude: find.longitude,
|
|
||||||
locationName: find.locationName,
|
locationName: find.locationName,
|
||||||
category: find.category,
|
category: find.category,
|
||||||
isPublic: find.isPublic,
|
isPublic: find.isPublic,
|
||||||
@@ -122,8 +101,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
.leftJoin(findLike, eq(find.id, findLike.findId))
|
.leftJoin(findLike, eq(find.id, findLike.findId))
|
||||||
.where(whereConditions)
|
.where(whereConditions)
|
||||||
.groupBy(find.id, user.username, user.profilePictureUrl)
|
.groupBy(find.id, user.username, user.profilePictureUrl)
|
||||||
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt)
|
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt);
|
||||||
.limit(100);
|
|
||||||
|
|
||||||
// Get media for all finds
|
// Get media for all finds
|
||||||
const findIds = finds.map((f) => f.id);
|
const findIds = finds.map((f) => f.id);
|
||||||
@@ -176,12 +154,11 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
// Generate signed URLs for all media items
|
// Generate signed URLs for all media items
|
||||||
const mediaWithSignedUrls = await Promise.all(
|
const mediaWithSignedUrls = await Promise.all(
|
||||||
findMedia.map(async (mediaItem) => {
|
findMedia.map(async (mediaItem) => {
|
||||||
// URLs in database are now paths, generate local proxy URLs
|
|
||||||
const localUrl = getLocalR2Url(mediaItem.url);
|
const localUrl = getLocalR2Url(mediaItem.url);
|
||||||
const localThumbnailUrl =
|
const localThumbnailUrl =
|
||||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||||
? getLocalR2Url(mediaItem.thumbnailUrl)
|
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||||
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
|
: mediaItem.thumbnailUrl;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mediaItem,
|
...mediaItem,
|
||||||
@@ -214,16 +191,17 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// POST endpoint creates a find (post) at a location
|
||||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
throw error(401, 'Unauthorized');
|
throw error(401, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
const { title, description, latitude, longitude, locationName, category, isPublic, media } = data;
|
const { locationId, title, description, locationName, category, isPublic, media } = data;
|
||||||
|
|
||||||
if (!title || !latitude || !longitude) {
|
if (!title || !locationId) {
|
||||||
throw error(400, 'Title, latitude, and longitude are required');
|
throw error(400, 'Title and locationId are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (title.length > 100) {
|
if (title.length > 100) {
|
||||||
@@ -234,18 +212,28 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
throw error(400, 'Description must be 500 characters or less');
|
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
|
// Create find
|
||||||
const newFind = await db
|
const newFind = await db
|
||||||
.insert(find)
|
.insert(find)
|
||||||
.values({
|
.values({
|
||||||
id: findId,
|
id: findId,
|
||||||
|
locationId,
|
||||||
userId: locals.user.id,
|
userId: locals.user.id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
latitude: latitude.toString(),
|
|
||||||
longitude: longitude.toString(),
|
|
||||||
locationName,
|
locationName,
|
||||||
category,
|
category,
|
||||||
isPublic: isPublic ? 1 : 0
|
isPublic: isPublic ? 1 : 0
|
||||||
@@ -256,7 +244,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
if (media && media.length > 0) {
|
if (media && media.length > 0) {
|
||||||
const mediaRecords = media.map(
|
const mediaRecords = media.map(
|
||||||
(item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
|
(item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
|
||||||
id: generateFindId(),
|
id: generateId(),
|
||||||
findId,
|
findId,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
url: item.url,
|
url: item.url,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
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 { eq, sql } from 'drizzle-orm';
|
||||||
import { getLocalR2Url, deleteFromR2 } from '$lib/server/r2';
|
import { getLocalR2Url, deleteFromR2 } from '$lib/server/r2';
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
|||||||
id: find.id,
|
id: find.id,
|
||||||
title: find.title,
|
title: find.title,
|
||||||
description: find.description,
|
description: find.description,
|
||||||
latitude: find.latitude,
|
latitude: location.latitude,
|
||||||
longitude: find.longitude,
|
longitude: location.longitude,
|
||||||
locationName: find.locationName,
|
locationName: find.locationName,
|
||||||
category: find.category,
|
category: find.category,
|
||||||
isPublic: find.isPublic,
|
isPublic: find.isPublic,
|
||||||
@@ -42,10 +42,17 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
|||||||
: sql<boolean>`0`
|
: sql<boolean>`0`
|
||||||
})
|
})
|
||||||
.from(find)
|
.from(find)
|
||||||
|
.innerJoin(location, eq(find.locationId, location.id))
|
||||||
.innerJoin(user, eq(find.userId, user.id))
|
.innerJoin(user, eq(find.userId, user.id))
|
||||||
.leftJoin(findLike, eq(find.id, findLike.findId))
|
.leftJoin(findLike, eq(find.id, findLike.findId))
|
||||||
.where(eq(find.id, findId))
|
.where(eq(find.id, findId))
|
||||||
.groupBy(find.id, user.username, user.profilePictureUrl)
|
.groupBy(
|
||||||
|
find.id,
|
||||||
|
location.latitude,
|
||||||
|
location.longitude,
|
||||||
|
user.username,
|
||||||
|
user.profilePictureUrl
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (findResult.length === 0) {
|
if (findResult.length === 0) {
|
||||||
@@ -143,21 +150,11 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
|
|
||||||
// Parse request body
|
// Parse request body
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
const {
|
const { title, description, category, isPublic, media, mediaToDelete } = data;
|
||||||
title,
|
|
||||||
description,
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
locationName,
|
|
||||||
category,
|
|
||||||
isPublic,
|
|
||||||
media,
|
|
||||||
mediaToDelete
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!title || !latitude || !longitude) {
|
if (!title) {
|
||||||
throw error(400, 'Title, latitude, and longitude are required');
|
throw error(400, 'Title is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (title.length > 100) {
|
if (title.length > 100) {
|
||||||
@@ -209,9 +206,6 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
.set({
|
.set({
|
||||||
title,
|
title,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
latitude: latitude.toString(),
|
|
||||||
longitude: longitude.toString(),
|
|
||||||
locationName: locationName || null,
|
|
||||||
category: category || null,
|
category: category || null,
|
||||||
isPublic: isPublic ? 1 : 0,
|
isPublic: isPublic ? 1 : 0,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
|
|||||||
262
src/routes/api/locations/+server.ts
Normal file
262
src/routes/api/locations/+server.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
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,
|
||||||
|
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,
|
||||||
|
locationName: find.locationName,
|
||||||
|
category: find.category,
|
||||||
|
isPublic: find.isPublic,
|
||||||
|
createdAt: find.createdAt,
|
||||||
|
userId: find.userId,
|
||||||
|
username: user.username,
|
||||||
|
profilePictureUrl: user.profilePictureUrl,
|
||||||
|
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
|
||||||
|
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 } = 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()
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return json({ success: true, location: newLocation[0] });
|
||||||
|
};
|
||||||
@@ -121,40 +121,6 @@
|
|||||||
goto('/');
|
goto('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the map find format
|
|
||||||
let mapFinds = $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
|
|
||||||
},
|
|
||||||
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
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get first media for OG image
|
// Get first media for OG image
|
||||||
let ogImage = $derived(data.find?.media?.[0]?.url || '');
|
let ogImage = $derived(data.find?.media?.[0]?.url || '');
|
||||||
</script>
|
</script>
|
||||||
@@ -200,8 +166,6 @@
|
|||||||
<Map
|
<Map
|
||||||
autoCenter={true}
|
autoCenter={true}
|
||||||
center={[parseFloat(data.find?.longitude || '0'), parseFloat(data.find?.latitude || '0')]}
|
center={[parseFloat(data.find?.longitude || '0'), parseFloat(data.find?.latitude || '0')]}
|
||||||
finds={mapFinds}
|
|
||||||
onFindClick={() => {}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user