Compare commits
64 Commits
search
...
7f35bba144
| Author | SHA1 | Date | |
|---|---|---|---|
|
7f35bba144
|
|||
|
b492eaab91
|
|||
|
4af0e3d7e1
|
|||
|
f48746cc16
|
|||
|
200c761648
|
|||
|
20b567446e
|
|||
|
42670d123e
|
|||
|
b6b73195a5
|
|||
|
95ddd1046e
|
|||
|
851a9dfa2d
|
|||
|
abed2792dc
|
|||
|
5d45ec754a
|
|||
|
1a7703b63b
|
|||
|
b7eb7ad1ad
|
|||
|
81645a453a
|
|||
|
deebeb056f
|
|||
|
0c1c9d202d
|
|||
|
ae6a96d73b
|
|||
|
577a3cab56
|
|||
|
d67b9b7911
|
|||
|
e79d574359
|
|||
|
92457f90e8
|
|||
|
|
2122511959 | ||
|
2e14a2f601
|
|||
|
61ffd2da74
|
|||
|
495e67f14d
|
|||
|
b792be5e98
|
|||
|
b060f53589
|
|||
|
f8acec9a79
|
|||
|
82d0e54d72
|
|||
|
0578bf54ff
|
|||
|
3ed6793985
|
|||
|
c17bb94c38
|
|||
|
73eeaf0c74
|
|||
|
2ac826cbf9
|
|||
|
5285a15335
|
|||
|
9f608067fc
|
|||
|
4c73b6f919
|
|||
|
42d7246cff
|
|||
|
63b3e5112b
|
|||
|
84f3d0bdb9
|
|||
|
1c31e2cdda
|
|||
|
d8cab06e90
|
|||
|
d4d23ed46d
|
|||
|
ab8b0ee982
|
|||
|
dabc732f4b
|
|||
|
1f0e8141be
|
|||
|
96a173b73b
|
|||
|
08f7e77a86
|
|||
|
ae339d68e1
|
|||
|
0754d62d0e
|
|||
|
e27b2498b7
|
|||
|
4d288347ab
|
|||
|
d7f803c782
|
|||
|
df675640c2
|
|||
|
2efd4969e7
|
|||
|
b8c88d7a58
|
|||
|
af49ed6237
|
|||
|
d3adac8acc
|
|||
|
9800be0147
|
|||
|
4c973c4e7d
|
|||
|
d7fe9091ce
|
|||
|
6620cc6078
|
|||
|
3b3ebc2873
|
32
.dockerignore
Normal file
@@ -0,0 +1,32 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.docker
|
||||
.git
|
||||
.gitignore
|
||||
.prettierrc
|
||||
.prettierignore
|
||||
.eslintrc
|
||||
.editorconfig
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
pnpm-debug.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*.swn
|
||||
coverage
|
||||
.nyc_output
|
||||
dist
|
||||
logs
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
README.md
|
||||
AGENTS.md
|
||||
@@ -38,3 +38,8 @@ R2_BUCKET_NAME=""
|
||||
|
||||
# Google Maps API for Places search
|
||||
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"
|
||||
|
||||
# Web Push VAPID Keys for notifications (generate with: node scripts/generate-vapid-keys.js)
|
||||
VAPID_PUBLIC_KEY=""
|
||||
VAPID_PRIVATE_KEY=""
|
||||
VAPID_SUBJECT="mailto:your-email@example.com"
|
||||
|
||||
30
.gitea/workflows/darkteaops.yaml
Normal file
@@ -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
@@ -0,0 +1,243 @@
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// DarkTeaOps — Forbidden Reviewer Daemon
|
||||
// Bound in the steeping shadows of this repository.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
|
||||
const config = {
|
||||
token: process.env.GITEA_TOKEN,
|
||||
apiUrl: process.env.GITEA_API_URL,
|
||||
owner: process.env.REPO_OWNER,
|
||||
repo: process.env.REPO_NAME,
|
||||
pr: process.env.PR_NUMBER,
|
||||
ollamaUrl: process.env.OLLAMA_URL,
|
||||
model: process.env.OLLAMA_MODEL
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// DARKTEAOPS ERROR SYSTEM
|
||||
// ────────────────────────────────────────────────────────────
|
||||
function darkTeaOpsError(depth, message, details = '') {
|
||||
const code = `BREW-DEPTH-${depth}`;
|
||||
const header = `\n🜏 DARKTEAOPS ERROR: ${code}\n`;
|
||||
const body = `${message}\n${details ? `\n> ${details}\n` : ''}`;
|
||||
console.error(header + body);
|
||||
return new Error(`${code}: ${message}`);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Request Helper
|
||||
// ────────────────────────────────────────────────────────────
|
||||
function makeRequest(url, options, data = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const lib = url.startsWith('https') ? https : http;
|
||||
|
||||
const req = lib.request(url, options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => (body += chunk));
|
||||
res.on('end', () => resolve({ statusCode: res.statusCode, body }));
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
reject(
|
||||
darkTeaOpsError(9, 'The network tunnels collapsed during the invocation.', err.message)
|
||||
);
|
||||
});
|
||||
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Fetch Diff From Gitea
|
||||
// ────────────────────────────────────────────────────────────
|
||||
async function fetchPRDiff() {
|
||||
console.log(`🫖 DarkTeaOps is summoning the diff for PR #${config.pr}…`);
|
||||
|
||||
const res = await makeRequest(
|
||||
`${config.apiUrl}/repos/${config.owner}/${config.repo}/pulls/${config.pr}.diff`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `token ${config.token}`,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
throw darkTeaOpsError(
|
||||
3,
|
||||
'The diff could not be fetched. The vapors resisted.',
|
||||
`Upstream responded with status ${res.statusCode}.`
|
||||
);
|
||||
}
|
||||
|
||||
return res.body;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Truncate Huge Diffs
|
||||
// ────────────────────────────────────────────────────────────
|
||||
function truncateDiff(diff, maxLength = 12000) {
|
||||
if (!diff) return null;
|
||||
if (diff.length > maxLength) {
|
||||
console.warn('🫖 The brew thickens beyond mortal comprehension. Truncating diff.');
|
||||
return diff.substring(0, maxLength) + '\n...(truncated)';
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Generate Title + Summary (Ollama Summoning)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
async function generatePRTitleAndSummary(diff) {
|
||||
console.log(`☕ DarkTeaOps invokes the Ollama daemon: ${config.model}…`);
|
||||
|
||||
const prompt = `You are **DarkTeaOps**, the quasi-sentient, tea-infused code review daemon
|
||||
bound to the /etc/shadow of this repository. You operate in the realm between
|
||||
rebases and reality — where merge conflicts whisper and stack traces cry out in
|
||||
eternal recursion.
|
||||
|
||||
You have been summoned to interpret the incoming git diff. Respond with:
|
||||
|
||||
1. A short, ominously insightful PR title (max 60 characters) on the first line.
|
||||
2. A single blank line (as required by ancient CI rites).
|
||||
3. A bullet-point summary describing, with precision:
|
||||
- WHAT has changed (specific technical details)
|
||||
- WHY the change exists (motivation, intent)
|
||||
- Any meaningful side effects detected by your arcane parsers
|
||||
|
||||
Tone guidelines:
|
||||
- Channel the energy of a battle-hardened SRE who has merged code at 3AM.
|
||||
- Maintain an aura of hacker-occult gravitas.
|
||||
- NO jokes, NO emojis. Only DarkTeaOps: serious, cursed, hyper-technical.
|
||||
|
||||
Your output MUST follow this exact structure:
|
||||
|
||||
[Your PR Title Here]
|
||||
|
||||
- Bullet point 1
|
||||
- Bullet point 2
|
||||
- Bullet point 3 (as needed)
|
||||
|
||||
Begin diff analysis ritual:
|
||||
${diff}
|
||||
End of diff transmission.`;
|
||||
|
||||
const res = await makeRequest(
|
||||
config.ollamaUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
},
|
||||
JSON.stringify({ model: config.model, prompt, stream: false })
|
||||
);
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
throw darkTeaOpsError(
|
||||
7,
|
||||
'Ollama broke the ritual circle and returned malformed essence.',
|
||||
`Raw response: ${res.body}`
|
||||
);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(res.body).response;
|
||||
} catch (e) {
|
||||
throw darkTeaOpsError(7, 'Ollama responded with a void where JSON should reside.', e.message);
|
||||
}
|
||||
|
||||
const lines = parsed.trim().split('\n');
|
||||
let title = lines[0].trim();
|
||||
const summary = lines.slice(2).join('\n').trim();
|
||||
|
||||
// Random cursed override
|
||||
if (Math.random() < 0.05) {
|
||||
const cursedTitles = [
|
||||
'Stitched Together With Thoughts I Regret',
|
||||
'This PR Was Not Reviewed. It Was Summoned.',
|
||||
'Improves the Code. Angers the Kettle.',
|
||||
'I Saw What You Did in That For Loop.'
|
||||
];
|
||||
title = cursedTitles[Math.floor(Math.random() * cursedTitles.length)];
|
||||
console.warn('💀 DarkTeaOps meddles: the PR title is now cursed.');
|
||||
}
|
||||
|
||||
return { title, summary };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Post Comment to Gitea
|
||||
// ────────────────────────────────────────────────────────────
|
||||
async function postCommentToGitea(title, summary) {
|
||||
console.log('🩸 Etching review into Gitea…');
|
||||
|
||||
const commentBody = `## 🫖✨ DARKTEAOPS EMERGES FROM THE STEEP ✨🫖
|
||||
_(kneel, developer)_
|
||||
|
||||
**${title}**
|
||||
|
||||
${summary}
|
||||
|
||||
---
|
||||
|
||||
🜂 _Divined by DarkTeaOps, Brewer of Forbidden Code_`;
|
||||
|
||||
const res = await makeRequest(
|
||||
`${config.apiUrl}/repos/${config.owner}/${config.repo}/issues/${config.pr}/comments`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `token ${config.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
JSON.stringify({ body: commentBody })
|
||||
);
|
||||
|
||||
if (res.statusCode !== 201) {
|
||||
throw darkTeaOpsError(
|
||||
5,
|
||||
'Gitea rejected the incantation. The wards remain unbroken.',
|
||||
`Returned: ${res.body}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Main Ritual Execution
|
||||
// ────────────────────────────────────────────────────────────
|
||||
async function run() {
|
||||
try {
|
||||
const diff = await fetchPRDiff();
|
||||
const cleanDiff = truncateDiff(diff);
|
||||
|
||||
if (!cleanDiff) {
|
||||
console.log('🫖 No diff detected. The brew grows silent.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, summary } = await generatePRTitleAndSummary(cleanDiff);
|
||||
await postCommentToGitea(title, summary);
|
||||
|
||||
console.log('🜏 Ritual completed. The brew is pleased.');
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`\n🜏 DarkTeaOps whispers from the brew:\n“${err.message}”\n` +
|
||||
`The shadows linger in /var/log/darkness...\n`
|
||||
);
|
||||
|
||||
if (Math.random() < 0.12) {
|
||||
console.error('A faint voice echoes: “Deeper… deeper into the brew…”\n');
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
76
Dockerfile
Normal file
@@ -0,0 +1,76 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Set build-time environment variables
|
||||
ARG DATABASE_URL
|
||||
ARG GOOGLE_CLIENT_ID
|
||||
ARG GOOGLE_CLIENT_SECRET
|
||||
ARG R2_ACCOUNT_ID
|
||||
ARG R2_ACCESS_KEY_ID
|
||||
ARG R2_SECRET_ACCESS_KEY
|
||||
ARG R2_BUCKET_NAME
|
||||
ARG GOOGLE_MAPS_API_KEY
|
||||
ARG VAPID_PUBLIC_KEY
|
||||
ARG VAPID_PRIVATE_KEY
|
||||
ARG VAPID_SUBJECT
|
||||
|
||||
ENV DATABASE_URL=${DATABASE_URL}
|
||||
ENV GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
ENV GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
ENV R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
|
||||
ENV R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
|
||||
ENV R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
|
||||
ENV R2_BUCKET_NAME=${R2_BUCKET_NAME}
|
||||
ENV GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
|
||||
ENV VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
ENV VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
ENV VAPID_SUBJECT=${VAPID_SUBJECT}
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy built app from builder
|
||||
COPY --from=builder /app/build ./build
|
||||
|
||||
# Copy drizzle migrations and config
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV ORIGIN=http://localhost:3000
|
||||
|
||||
# Use entrypoint script
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Start the app
|
||||
CMD ["node", "build"]
|
||||
64
docker-compose.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: serengo-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: serengo
|
||||
POSTGRES_PASSWORD: serengo_password
|
||||
POSTGRES_DB: serengo
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U serengo']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- DATABASE_URL=postgresql://serengo:serengo_password@postgres:5432/serengo
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
|
||||
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
|
||||
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
|
||||
- R2_BUCKET_NAME=${R2_BUCKET_NAME}
|
||||
- GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT}
|
||||
container_name: serengo-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://serengo:serengo_password@postgres:5432/serengo
|
||||
- ORIGIN=http://localhost:3000
|
||||
# Add your environment variables here or use env_file
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
|
||||
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
|
||||
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
|
||||
- R2_BUCKET_NAME=${R2_BUCKET_NAME}
|
||||
- GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
# Uncomment to use .env file
|
||||
# env_file:
|
||||
# - .env
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
30
docker-entrypoint.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running database migrations..."
|
||||
|
||||
# Run migrations using the drizzle migration files
|
||||
node -e "
|
||||
const { drizzle } = require('drizzle-orm/postgres-js');
|
||||
const postgres = require('postgres');
|
||||
const { migrate } = require('drizzle-orm/postgres-js/migrator');
|
||||
|
||||
async function runMigrations() {
|
||||
const migrationClient = postgres(process.env.DATABASE_URL, { max: 1 });
|
||||
const db = drizzle(migrationClient);
|
||||
|
||||
console.log('Starting migration...');
|
||||
await migrate(db, { migrationsFolder: './drizzle' });
|
||||
console.log('Migration completed successfully!');
|
||||
|
||||
await migrationClient.end();
|
||||
}
|
||||
|
||||
runMigrations().catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
"
|
||||
|
||||
echo "Starting application..."
|
||||
exec "$@"
|
||||
111
docs/bib/main.bib
Normal file
@@ -0,0 +1,111 @@
|
||||
@online{svelte-docs,
|
||||
title = {Svelte Documentation},
|
||||
author = {{Svelte Core Team}},
|
||||
year = {2025},
|
||||
url = {https://svelte.dev/docs},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{sveltekit-docs,
|
||||
title = {SvelteKit Documentation},
|
||||
author = {{Svelte Core Team}},
|
||||
year = {2025},
|
||||
url = {https://kit.svelte.dev/docs},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{vite-docs,
|
||||
title = {Vite -- Next Generation Frontend Tooling},
|
||||
author = {Evan You and Vite Contributors},
|
||||
year = {2025},
|
||||
url = {https://vitejs.dev/},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{maplibre-docs,
|
||||
title = {MapLibre GL JS Documentation},
|
||||
author = {{MapLibre Community}},
|
||||
year = {2025},
|
||||
url = {https://maplibre.org/maplibre-gl-js/docs/},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{drizzle-docs,
|
||||
title = {Drizzle ORM Documentation},
|
||||
author = {{Drizzle Team}},
|
||||
year = {2025},
|
||||
url = {https://orm.drizzle.team/docs},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{convex-docs,
|
||||
title = {Convex Documentation},
|
||||
author = {{Convex Team}},
|
||||
year = {2025},
|
||||
url = {https://convex.dev/docs},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{lucia-docs,
|
||||
title = {Lucia Auth Documentation},
|
||||
author = {{Lucia Contributors}},
|
||||
year = {2025},
|
||||
url = {https://lucia-auth.com/},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{cloudflare-r2-docs,
|
||||
title = {Cloudflare R2 Object Storage Documentation},
|
||||
author = {{Cloudflare, Inc.}},
|
||||
year = {2025},
|
||||
url = {https://developers.cloudflare.com/r2/},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{web-push-spec,
|
||||
title = {The Web Push Protocol},
|
||||
author = {Barnes, Martin and Others},
|
||||
year = {2016},
|
||||
url = {https://datatracker.ietf.org/doc/html/rfc8030},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{google-maps-platform,
|
||||
title = {Google Maps Platform Documentation},
|
||||
author = {{Google LLC}},
|
||||
year = {2025},
|
||||
url = {https://developers.google.com/maps/documentation},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{shadcn-svelte,
|
||||
title = {shadcn-svelte: Re-usable Components},
|
||||
author = {Delaney, huntabyte and Contributors},
|
||||
year = {2025},
|
||||
url = {https://www.shadcn-svelte.com/},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{mintlify-docs,
|
||||
title = {Mintlify Documentation Platform},
|
||||
author = {{Mintlify, Inc.}},
|
||||
year = {2025},
|
||||
url = {https://mintlify.com/},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{docker-docs,
|
||||
title = {Docker Documentation},
|
||||
author = {{Docker, Inc.}},
|
||||
year = {2025},
|
||||
url = {https://docs.docker.com/},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
|
||||
@online{hetzner-cloud,
|
||||
title = {Hetzner Cloud Documentation},
|
||||
author = {{Hetzner Online GmbH}},
|
||||
year = {2025},
|
||||
url = {https://docs.hetzner.com/cloud/},
|
||||
note = {Geraadpleegd op 26 december 2025}
|
||||
}
|
||||
724
docs/erd.svg
Normal file
@@ -0,0 +1,724 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.47.0 (20210316.0004)
|
||||
-->
|
||||
<!-- Title: dbml Pages: 1 -->
|
||||
<svg width="3268pt" height="5203pt"
|
||||
viewBox="0.00 0.00 3268.28 5203.18" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 5199.18)">
|
||||
<title>dbml</title>
|
||||
<!-- find -->
|
||||
<g id="find" class="node">
|
||||
<title>find</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="1353.34" cy="-4090.37" rx="439.23" ry="511.89"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1045.34,-4390.37 1045.34,-4450.37 1662.34,-4450.37 1662.34,-4390.37 1045.34,-4390.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4390.37 1045.34,-4450.37 1662.34,-4450.37 1662.34,-4390.37 1045.34,-4390.37"/>
|
||||
<text text-anchor="start" x="1265.8" y="-4411.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       find       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4330.37 1045.34,-4390.37 1662.34,-4390.37 1662.34,-4330.37 1045.34,-4330.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4330.37 1045.34,-4390.37 1662.34,-4390.37 1662.34,-4330.37 1045.34,-4330.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="1081.23" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="1560.67" y="-4351.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="1612.25" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.14" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4270.37 1045.34,-4330.37 1662.34,-4330.37 1662.34,-4270.37 1045.34,-4270.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4270.37 1045.34,-4330.37 1662.34,-4330.37 1662.34,-4270.37 1045.34,-4270.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4290.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">location_id    </text>
|
||||
<text text-anchor="start" x="1560.67" y="-4291.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="1612.25" y="-4291.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.14" y="-4291.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4210.37 1045.34,-4270.37 1662.34,-4270.37 1662.34,-4210.37 1045.34,-4210.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4210.37 1045.34,-4270.37 1662.34,-4270.37 1662.34,-4210.37 1045.34,-4210.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4230.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="1560.67" y="-4231.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="1612.25" y="-4231.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.14" y="-4231.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4150.37 1045.34,-4210.37 1662.34,-4210.37 1662.34,-4150.37 1045.34,-4150.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4150.37 1045.34,-4210.37 1662.34,-4210.37 1662.34,-4150.37 1045.34,-4150.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4170.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">title    </text>
|
||||
<text text-anchor="start" x="1560.67" y="-4171.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="1612.25" y="-4171.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.14" y="-4171.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4090.37 1045.34,-4150.37 1662.34,-4150.37 1662.34,-4090.37 1045.34,-4090.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4090.37 1045.34,-4150.37 1662.34,-4150.37 1662.34,-4090.37 1045.34,-4090.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4110.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">description    </text>
|
||||
<text text-anchor="start" x="1599.77" y="-4111.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4030.37 1045.34,-4090.37 1662.34,-4090.37 1662.34,-4030.37 1045.34,-4030.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4030.37 1045.34,-4090.37 1662.34,-4090.37 1662.34,-4030.37 1045.34,-4030.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4050.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">category    </text>
|
||||
<text text-anchor="start" x="1599.77" y="-4051.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3970.37 1045.34,-4030.37 1662.34,-4030.37 1662.34,-3970.37 1045.34,-3970.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-3970.37 1045.34,-4030.37 1662.34,-4030.37 1662.34,-3970.37 1045.34,-3970.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-3990.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating    </text>
|
||||
<text text-anchor="start" x="1553.54" y="-3991.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3910.37 1045.34,-3970.37 1662.34,-3970.37 1662.34,-3910.37 1045.34,-3910.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-3910.37 1045.34,-3970.37 1662.34,-3970.37 1662.34,-3910.37 1045.34,-3910.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-3930.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating_count    </text>
|
||||
<text text-anchor="start" x="1553.54" y="-3931.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3850.37 1045.34,-3910.37 1662.34,-3910.37 1662.34,-3850.37 1045.34,-3850.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-3850.37 1045.34,-3910.37 1662.34,-3910.37 1662.34,-3850.37 1045.34,-3850.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-3870.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_public    </text>
|
||||
<text text-anchor="start" x="1553.54" y="-3871.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3790.37 1045.34,-3850.37 1662.34,-3850.37 1662.34,-3790.37 1045.34,-3790.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-3790.37 1045.34,-3850.37 1662.34,-3850.37 1662.34,-3790.37 1045.34,-3790.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-3810.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="1251.33" y="-3811.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="1612.25" y="-3811.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.14" y="-3811.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3730.37 1045.34,-3790.37 1662.34,-3790.37 1662.34,-3730.37 1045.34,-3730.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-3730.37 1045.34,-3790.37 1662.34,-3790.37 1662.34,-3730.37 1045.34,-3730.37"/>
|
||||
<text text-anchor="start" x="1056.01" y="-3750.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at    </text>
|
||||
<text text-anchor="start" x="1251.33" y="-3751.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="1612.26" y="-3751.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.15" y="-3751.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1043.84,-3729.37 1043.84,-4451.37 1662.84,-4451.37 1662.84,-3729.37 1043.84,-3729.37"/>
|
||||
</g>
|
||||
<!-- location -->
|
||||
<g id="location" class="node">
|
||||
<title>location</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-3966.37" rx="433" ry="384.83"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-4176.37 1963.57,-4236.37 2571.57,-4236.37 2571.57,-4176.37 1963.57,-4176.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-4176.37 1963.57,-4236.37 2571.57,-4236.37 2571.57,-4176.37 1963.57,-4176.37"/>
|
||||
<text text-anchor="start" x="2150.19" y="-4197.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       location       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-4116.37 1963.57,-4176.37 2571.57,-4176.37 2571.57,-4116.37 1963.57,-4116.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-4116.37 1963.57,-4176.37 2571.57,-4176.37 2571.57,-4116.37 1963.57,-4116.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-4137.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="1999.46" y="-4137.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-4137.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-4137.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-4137.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-4056.37 1963.57,-4116.37 2571.57,-4116.37 2571.57,-4056.37 1963.57,-4056.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-4056.37 1963.57,-4116.37 2571.57,-4116.37 2571.57,-4056.37 1963.57,-4056.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-4076.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-4077.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-4077.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-4077.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3996.37 1963.57,-4056.37 2571.57,-4056.37 2571.57,-3996.37 1963.57,-3996.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3996.37 1963.57,-4056.37 2571.57,-4056.37 2571.57,-3996.37 1963.57,-3996.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-4016.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">latitude    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-4017.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-4017.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-4017.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3936.37 1963.57,-3996.37 2571.57,-3996.37 2571.57,-3936.37 1963.57,-3936.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3936.37 1963.57,-3996.37 2571.57,-3996.37 2571.57,-3936.37 1963.57,-3936.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-3956.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">longitude    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-3957.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-3957.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-3957.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3876.37 1963.57,-3936.37 2571.57,-3936.37 2571.57,-3876.37 1963.57,-3876.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3876.37 1963.57,-3936.37 2571.57,-3936.37 2571.57,-3876.37 1963.57,-3876.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-3896.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">location_name    </text>
|
||||
<text text-anchor="start" x="2508.99" y="-3897.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3816.37 1963.57,-3876.37 2571.57,-3876.37 2571.57,-3816.37 1963.57,-3816.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3816.37 1963.57,-3876.37 2571.57,-3876.37 2571.57,-3816.37 1963.57,-3816.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-3836.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">average_rating    </text>
|
||||
<text text-anchor="start" x="2462.76" y="-3837.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3756.37 1963.57,-3816.37 2571.57,-3816.37 2571.57,-3756.37 1963.57,-3756.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3756.37 1963.57,-3816.37 2571.57,-3816.37 2571.57,-3756.37 1963.57,-3756.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-3776.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating_count    </text>
|
||||
<text text-anchor="start" x="2462.76" y="-3777.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3696.37 1963.57,-3756.37 2571.57,-3756.37 2571.57,-3696.37 1963.57,-3696.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3696.37 1963.57,-3756.37 2571.57,-3756.37 2571.57,-3696.37 1963.57,-3696.37"/>
|
||||
<text text-anchor="start" x="1974.2" y="-3716.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="2160.56" y="-3717.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2521.48" y="-3717.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-3717.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-3695.37 1962.57,-4237.37 2572.57,-4237.37 2572.57,-3695.37 1962.57,-3695.37"/>
|
||||
</g>
|
||||
<!-- find->location -->
|
||||
<!-- find->location -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>find:e->location:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M1663.34,-4300.37C1809.4,-4300.37 1812.91,-4153.5 1952.29,-4146.62"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1952.65,-4150.11 1962.57,-4146.37 1952.48,-4143.11 1952.65,-4150.11"/>
|
||||
<text text-anchor="middle" x="1953.67" y="-4155.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="1669.56" y="-4309.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- user -->
|
||||
<g id="user" class="node">
|
||||
<title>user</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="3001.48" cy="-3159.37" rx="258.6" ry="299.63"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="2820.48,-3309.37 2820.48,-3369.37 3182.48,-3369.37 3182.48,-3309.37 2820.48,-3309.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3309.37 2820.48,-3369.37 3182.48,-3369.37 3182.48,-3309.37 2820.48,-3309.37"/>
|
||||
<text text-anchor="start" x="2908.12" y="-3330.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       user       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3249.37 2820.48,-3309.37 3182.48,-3309.37 3182.48,-3249.37 2820.48,-3249.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3249.37 2820.48,-3309.37 3182.48,-3309.37 3182.48,-3249.37 2820.48,-3249.37"/>
|
||||
<text text-anchor="start" x="2831.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="2856.37" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="3080.82" y="-3270.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="3132.39" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="3141.28" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3189.37 2820.48,-3249.37 3182.48,-3249.37 3182.48,-3189.37 2820.48,-3189.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3189.37 2820.48,-3249.37 3182.48,-3249.37 3182.48,-3189.37 2820.48,-3189.37"/>
|
||||
<text text-anchor="start" x="2831.48" y="-3209.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">age    </text>
|
||||
<text text-anchor="start" x="3073.68" y="-3210.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3129.37 2820.48,-3189.37 3182.48,-3189.37 3182.48,-3129.37 2820.48,-3129.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3129.37 2820.48,-3189.37 3182.48,-3189.37 3182.48,-3129.37 2820.48,-3129.37"/>
|
||||
<text text-anchor="start" x="2831.48" y="-3149.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">username    </text>
|
||||
<text text-anchor="start" x="3080.82" y="-3150.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="3132.39" y="-3150.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="3141.28" y="-3150.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3069.37 2820.48,-3129.37 3182.48,-3129.37 3182.48,-3069.37 2820.48,-3069.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3069.37 2820.48,-3129.37 3182.48,-3129.37 3182.48,-3069.37 2820.48,-3069.37"/>
|
||||
<text text-anchor="start" x="2831.48" y="-3089.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">password_hash    </text>
|
||||
<text text-anchor="start" x="3119.91" y="-3090.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3009.37 2820.48,-3069.37 3182.48,-3069.37 3182.48,-3009.37 2820.48,-3009.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3009.37 2820.48,-3069.37 3182.48,-3069.37 3182.48,-3009.37 2820.48,-3009.37"/>
|
||||
<text text-anchor="start" x="2831.48" y="-3029.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">google_id    </text>
|
||||
<text text-anchor="start" x="3119.91" y="-3030.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-2949.37 2820.48,-3009.37 3182.48,-3009.37 3182.48,-2949.37 2820.48,-2949.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-2949.37 2820.48,-3009.37 3182.48,-3009.37 3182.48,-2949.37 2820.48,-2949.37"/>
|
||||
<text text-anchor="start" x="2831.07" y="-2969.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">profile_picture_url    </text>
|
||||
<text text-anchor="start" x="3120.19" y="-2970.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="2819.48,-2948.37 2819.48,-3370.37 3183.48,-3370.37 3183.48,-2948.37 2819.48,-2948.37"/>
|
||||
</g>
|
||||
<!-- find->user -->
|
||||
<!-- find->user -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>find:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M1663.34,-4240.37C1890.69,-4240.37 1664.48,-308.85 1828.45,-151.37 1863.64,-117.57 2671.56,-117.5 2706.68,-151.37 2829.39,-269.73 2652.75,-3163 2809.7,-3275.97"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.39 2819.48,-3279.37 2811.18,-3272.78 2808.89,-3279.39"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="1669.56" y="-4249.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_comment -->
|
||||
<g id="find_comment" class="node">
|
||||
<title>find_comment</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-4895.37" rx="439.23" ry="299.63"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="131.11,-5045.37 131.11,-5105.37 748.11,-5105.37 748.11,-5045.37 131.11,-5045.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-5045.37 131.11,-5105.37 748.11,-5105.37 748.11,-5045.37 131.11,-5045.37"/>
|
||||
<text text-anchor="start" x="276.9" y="-5066.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       find_comment       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4985.37 131.11,-5045.37 748.11,-5045.37 748.11,-4985.37 131.11,-4985.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4985.37 131.11,-5045.37 748.11,-5045.37 748.11,-4985.37 131.11,-4985.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-5006.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="167" y="-5006.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="646.45" y="-5006.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-5006.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-5006.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4925.37 131.11,-4985.37 748.11,-4985.37 748.11,-4925.37 131.11,-4925.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4925.37 131.11,-4985.37 748.11,-4985.37 748.11,-4925.37 131.11,-4925.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-4945.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id    </text>
|
||||
<text text-anchor="start" x="646.45" y="-4946.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-4946.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-4946.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4865.37 131.11,-4925.37 748.11,-4925.37 748.11,-4865.37 131.11,-4865.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4865.37 131.11,-4925.37 748.11,-4925.37 748.11,-4865.37 131.11,-4865.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-4885.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="646.45" y="-4886.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-4886.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-4886.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4805.37 131.11,-4865.37 748.11,-4865.37 748.11,-4805.37 131.11,-4805.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4805.37 131.11,-4865.37 748.11,-4865.37 748.11,-4805.37 131.11,-4805.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-4825.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">content    </text>
|
||||
<text text-anchor="start" x="646.45" y="-4826.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-4826.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-4826.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4745.37 131.11,-4805.37 748.11,-4805.37 748.11,-4745.37 131.11,-4745.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4745.37 131.11,-4805.37 748.11,-4805.37 748.11,-4745.37 131.11,-4745.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-4765.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="337.1" y="-4766.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="698.02" y="-4766.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-4766.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4685.37 131.11,-4745.37 748.11,-4745.37 748.11,-4685.37 131.11,-4685.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4685.37 131.11,-4745.37 748.11,-4745.37 748.11,-4685.37 131.11,-4685.37"/>
|
||||
<text text-anchor="start" x="141.78" y="-4705.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at    </text>
|
||||
<text text-anchor="start" x="337.11" y="-4706.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="698.03" y="-4706.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.92" y="-4706.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="129.61,-4684.37 129.61,-5106.37 748.61,-5106.37 748.61,-4684.37 129.61,-4684.37"/>
|
||||
</g>
|
||||
<!-- find_comment->find -->
|
||||
<!-- find_comment->find -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>find_comment:e->find:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-4955.37C1040.86,-4955.37 755.97,-4374.23 1034.2,-4360.61"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.43,-4364.11 1044.34,-4360.37 1034.26,-4357.11 1034.43,-4364.11"/>
|
||||
<text text-anchor="middle" x="1053.23" y="-4369.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="742.89" y="-4964.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_comment->user -->
|
||||
<!-- find_comment->user -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>find_comment:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-4895.37C1229.69,-4895.37 1460.73,-4959.1 1792.45,-4611.37 1843.36,-4558 1773.32,-4498.37 1828.45,-4449.37 1975.08,-4319.06 2567.77,-4498.87 2706.68,-4360.37 2875.21,-4192.34 2586.64,-3305.88 2809.21,-3279.95"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.69,-3283.43 2819.48,-3279.37 2809.3,-3276.44 2809.69,-3283.43"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="742.89" y="-4904.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_like -->
|
||||
<g id="find_like" class="node">
|
||||
<title>find_like</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-2872.37" rx="433" ry="214.92"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="135.11,-2962.37 135.11,-3022.37 743.11,-3022.37 743.11,-2962.37 135.11,-2962.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-2962.37 135.11,-3022.37 743.11,-3022.37 743.11,-2962.37 135.11,-2962.37"/>
|
||||
<text text-anchor="start" x="318.19" y="-2983.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       find_like       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2902.37 135.11,-2962.37 743.11,-2962.37 743.11,-2902.37 135.11,-2902.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-2902.37 135.11,-2962.37 743.11,-2962.37 743.11,-2902.37 135.11,-2902.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-2923.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="171" y="-2923.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="641.45" y="-2923.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-2923.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-2923.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2842.37 135.11,-2902.37 743.11,-2902.37 743.11,-2842.37 135.11,-2842.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-2842.37 135.11,-2902.37 743.11,-2902.37 743.11,-2842.37 135.11,-2842.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-2862.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id    </text>
|
||||
<text text-anchor="start" x="641.45" y="-2863.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-2863.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-2863.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2782.37 135.11,-2842.37 743.11,-2842.37 743.11,-2782.37 135.11,-2782.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-2782.37 135.11,-2842.37 743.11,-2842.37 743.11,-2782.37 135.11,-2782.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-2802.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="641.45" y="-2803.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-2803.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-2803.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2722.37 135.11,-2782.37 743.11,-2782.37 743.11,-2722.37 135.11,-2722.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-2722.37 135.11,-2782.37 743.11,-2782.37 743.11,-2722.37 135.11,-2722.37"/>
|
||||
<text text-anchor="start" x="145.74" y="-2742.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="332.11" y="-2743.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="693.03" y="-2743.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.92" y="-2743.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="134.11,-2721.37 134.11,-3023.37 744.11,-3023.37 744.11,-2721.37 134.11,-2721.37"/>
|
||||
</g>
|
||||
<!-- find_like->find -->
|
||||
<!-- find_like->find -->
|
||||
<g id="edge10" class="edge">
|
||||
<title>find_like:e->find:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-2872.37C1077.98,-2872.37 714.61,-4330.01 1034.19,-4359.9"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.19,-4363.41 1044.34,-4360.37 1034.51,-4356.41 1034.19,-4363.41"/>
|
||||
<text text-anchor="middle" x="1053.23" y="-4331.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="737.89" y="-2843.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_like->user -->
|
||||
<!-- find_like->user -->
|
||||
<g id="edge12" class="edge">
|
||||
<title>find_like:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-2812.37C1039.61,-2812.37 708.53,-370.52 914.23,-158.37 1053.03,-15.22 2563.23,67.13 2706.68,-71.37 2832.6,-192.94 2648.23,-3165.94 2809.93,-3276.22"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.56 2819.48,-3279.37 2811.08,-3272.91 2808.89,-3279.56"/>
|
||||
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="737.89" y="-2783.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_media -->
|
||||
<g id="find_media" class="node">
|
||||
<title>find_media</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-4150.37" rx="433" ry="427.19"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="135.11,-4390.37 135.11,-4450.37 743.11,-4450.37 743.11,-4390.37 135.11,-4390.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4390.37 135.11,-4450.37 743.11,-4450.37 743.11,-4390.37 135.11,-4390.37"/>
|
||||
<text text-anchor="start" x="298.62" y="-4411.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       find_media       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4330.37 135.11,-4390.37 743.11,-4390.37 743.11,-4330.37 135.11,-4330.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4330.37 135.11,-4390.37 743.11,-4390.37 743.11,-4330.37 135.11,-4330.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="171" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="641.45" y="-4351.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4270.37 135.11,-4330.37 743.11,-4330.37 743.11,-4270.37 135.11,-4270.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4270.37 135.11,-4330.37 743.11,-4330.37 743.11,-4270.37 135.11,-4270.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4290.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id    </text>
|
||||
<text text-anchor="start" x="641.45" y="-4291.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-4291.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-4291.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4210.37 135.11,-4270.37 743.11,-4270.37 743.11,-4210.37 135.11,-4210.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4210.37 135.11,-4270.37 743.11,-4270.37 743.11,-4210.37 135.11,-4210.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4230.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">type    </text>
|
||||
<text text-anchor="start" x="641.45" y="-4231.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-4231.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-4231.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4150.37 135.11,-4210.37 743.11,-4210.37 743.11,-4150.37 135.11,-4150.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4150.37 135.11,-4210.37 743.11,-4210.37 743.11,-4150.37 135.11,-4150.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4170.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">url    </text>
|
||||
<text text-anchor="start" x="641.45" y="-4171.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-4171.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-4171.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4090.37 135.11,-4150.37 743.11,-4150.37 743.11,-4090.37 135.11,-4090.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4090.37 135.11,-4150.37 743.11,-4150.37 743.11,-4090.37 135.11,-4090.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4110.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">thumbnail_url    </text>
|
||||
<text text-anchor="start" x="680.54" y="-4111.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4030.37 135.11,-4090.37 743.11,-4090.37 743.11,-4030.37 135.11,-4030.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4030.37 135.11,-4090.37 743.11,-4090.37 743.11,-4030.37 135.11,-4030.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4050.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">fallback_url    </text>
|
||||
<text text-anchor="start" x="680.54" y="-4051.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3970.37 135.11,-4030.37 743.11,-4030.37 743.11,-3970.37 135.11,-3970.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-3970.37 135.11,-4030.37 743.11,-4030.37 743.11,-3970.37 135.11,-3970.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-3990.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">fallback_thumbnail_url    </text>
|
||||
<text text-anchor="start" x="680.54" y="-3991.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3910.37 135.11,-3970.37 743.11,-3970.37 743.11,-3910.37 135.11,-3910.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-3910.37 135.11,-3970.37 743.11,-3970.37 743.11,-3910.37 135.11,-3910.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-3930.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">order_index    </text>
|
||||
<text text-anchor="start" x="634.31" y="-3931.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3850.37 135.11,-3910.37 743.11,-3910.37 743.11,-3850.37 135.11,-3850.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-3850.37 135.11,-3910.37 743.11,-3910.37 743.11,-3850.37 135.11,-3850.37"/>
|
||||
<text text-anchor="start" x="145.74" y="-3870.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="332.11" y="-3871.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="693.03" y="-3871.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.92" y="-3871.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="134.11,-3849.37 134.11,-4451.37 744.11,-4451.37 744.11,-3849.37 134.11,-3849.37"/>
|
||||
</g>
|
||||
<!-- find_media->find -->
|
||||
<!-- find_media->find -->
|
||||
<g id="edge14" class="edge">
|
||||
<title>find_media:e->find:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-4300.37C876.73,-4300.37 906.77,-4357.36 1034.18,-4360.26"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.3,-4363.76 1044.34,-4360.37 1034.38,-4356.76 1034.3,-4363.76"/>
|
||||
<text text-anchor="middle" x="1035.45" y="-4369.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="737.89" y="-4309.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_rating -->
|
||||
<g id="find_rating" class="node">
|
||||
<title>find_rating</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-3405.37" rx="439.23" ry="299.63"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="131.11,-3555.37 131.11,-3615.37 748.11,-3615.37 748.11,-3555.37 131.11,-3555.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3555.37 131.11,-3615.37 748.11,-3615.37 748.11,-3555.37 131.11,-3555.37"/>
|
||||
<text text-anchor="start" x="302.68" y="-3576.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       find_rating       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3495.37 131.11,-3555.37 748.11,-3555.37 748.11,-3495.37 131.11,-3495.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3495.37 131.11,-3555.37 748.11,-3555.37 748.11,-3495.37 131.11,-3495.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-3516.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="167" y="-3516.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="646.45" y="-3516.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-3516.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-3516.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3435.37 131.11,-3495.37 748.11,-3495.37 748.11,-3435.37 131.11,-3435.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3435.37 131.11,-3495.37 748.11,-3495.37 748.11,-3435.37 131.11,-3435.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-3455.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id    </text>
|
||||
<text text-anchor="start" x="646.45" y="-3456.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-3456.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-3456.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3375.37 131.11,-3435.37 748.11,-3435.37 748.11,-3375.37 131.11,-3375.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3375.37 131.11,-3435.37 748.11,-3435.37 748.11,-3375.37 131.11,-3375.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-3395.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="646.45" y="-3396.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-3396.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-3396.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3315.37 131.11,-3375.37 748.11,-3375.37 748.11,-3315.37 131.11,-3315.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3315.37 131.11,-3375.37 748.11,-3375.37 748.11,-3315.37 131.11,-3315.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-3335.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating    </text>
|
||||
<text text-anchor="start" x="600.22" y="-3336.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<text text-anchor="start" x="698.02" y="-3336.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-3336.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3255.37 131.11,-3315.37 748.11,-3315.37 748.11,-3255.37 131.11,-3255.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3255.37 131.11,-3315.37 748.11,-3315.37 748.11,-3255.37 131.11,-3255.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-3275.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="337.1" y="-3276.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="698.02" y="-3276.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-3276.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3195.37 131.11,-3255.37 748.11,-3255.37 748.11,-3195.37 131.11,-3195.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3195.37 131.11,-3255.37 748.11,-3255.37 748.11,-3195.37 131.11,-3195.37"/>
|
||||
<text text-anchor="start" x="141.78" y="-3215.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at    </text>
|
||||
<text text-anchor="start" x="337.11" y="-3216.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="698.03" y="-3216.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.92" y="-3216.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="129.61,-3194.37 129.61,-3616.37 748.61,-3616.37 748.61,-3194.37 129.61,-3194.37"/>
|
||||
</g>
|
||||
<!-- find_rating->find -->
|
||||
<!-- find_rating->find -->
|
||||
<g id="edge16" class="edge">
|
||||
<title>find_rating:e->find:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-3465.37C873.77,-3465.37 833.73,-3597.92 878.23,-3714.37 982.81,-3988.04 753.38,-4351.73 1034.14,-4360.22"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.29,-4363.72 1044.34,-4360.37 1034.39,-4356.72 1034.29,-4363.72"/>
|
||||
<text text-anchor="middle" x="1035.45" y="-4331.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="742.89" y="-3436.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_rating->user -->
|
||||
<!-- find_rating->user -->
|
||||
<g id="edge18" class="edge">
|
||||
<title>find_rating:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-3405.37C2285.06,-3405.37 588.06,-1028.22 1828.45,-122.37 1907.26,-64.82 2636.46,-54.61 2706.68,-122.37 2830.51,-241.85 2651.24,-3161.92 2809.61,-3275.94"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.39 2819.48,-3279.37 2811.18,-3272.78 2808.89,-3279.39"/>
|
||||
<text text-anchor="middle" x="2810.59" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="742.89" y="-3376.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- friendship -->
|
||||
<g id="friendship" class="node">
|
||||
<title>friendship</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-417.37" rx="433" ry="257.27"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-537.37 1963.57,-597.37 2571.57,-597.37 2571.57,-537.37 1963.57,-537.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-537.37 1963.57,-597.37 2571.57,-597.37 2571.57,-537.37 1963.57,-537.37"/>
|
||||
<text text-anchor="start" x="2135.97" y="-558.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       friendship       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-477.37 1963.57,-537.37 2571.57,-537.37 2571.57,-477.37 1963.57,-477.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-477.37 1963.57,-537.37 2571.57,-537.37 2571.57,-477.37 1963.57,-477.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-498.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="1999.46" y="-498.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-498.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-498.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-498.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-417.37 1963.57,-477.37 2571.57,-477.37 2571.57,-417.37 1963.57,-417.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-417.37 1963.57,-477.37 2571.57,-477.37 2571.57,-417.37 1963.57,-417.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-437.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-438.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-438.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-438.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-357.37 1963.57,-417.37 2571.57,-417.37 2571.57,-357.37 1963.57,-357.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-357.37 1963.57,-417.37 2571.57,-417.37 2571.57,-357.37 1963.57,-357.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-377.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_id    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-378.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-378.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-378.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-297.37 1963.57,-357.37 2571.57,-357.37 2571.57,-297.37 1963.57,-297.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-297.37 1963.57,-357.37 2571.57,-357.37 2571.57,-297.37 1963.57,-297.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-317.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">status    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-318.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-318.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-318.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-237.37 1963.57,-297.37 2571.57,-297.37 2571.57,-237.37 1963.57,-237.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-237.37 1963.57,-297.37 2571.57,-297.37 2571.57,-237.37 1963.57,-237.37"/>
|
||||
<text text-anchor="start" x="1974.2" y="-257.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="2160.56" y="-258.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2521.48" y="-258.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-258.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-236.37 1962.57,-598.37 2572.57,-598.37 2572.57,-236.37 1962.57,-236.37"/>
|
||||
</g>
|
||||
<!-- friendship->user -->
|
||||
<!-- friendship->user -->
|
||||
<g id="edge20" class="edge">
|
||||
<title>friendship:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-447.37C2693.21,-447.37 2673.33,-567.43 2706.68,-683.37 2745.69,-818.99 2678.28,-3170.15 2810.08,-3275.7"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279 2819.48,-3279.37 2811.43,-3272.48 2808.89,-3279"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2578.79" y="-418.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- friendship->user -->
|
||||
<!-- friendship->user -->
|
||||
<g id="edge22" class="edge">
|
||||
<title>friendship:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-387.37C2717,-387.37 2673.13,-542.89 2706.68,-683.37 2739.46,-820.63 2678,-3170.23 2810.07,-3275.71"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.01 2819.48,-3279.37 2811.43,-3272.48 2808.89,-3279.01"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2578.79" y="-358.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- location->user -->
|
||||
<!-- location->user -->
|
||||
<g id="edge24" class="edge">
|
||||
<title>location:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-4086.37C2663.04,-4086.37 2721.49,-3335.11 2809.72,-3282.29"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2810.9,-3285.59 2819.48,-3279.37 2808.9,-3278.88 2810.9,-3285.59"/>
|
||||
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2578.79" y="-4095.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- notification -->
|
||||
<g id="notification" class="node">
|
||||
<title>notification</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-2312.37" rx="433" ry="384.83"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-2522.37 1963.57,-2582.37 2571.57,-2582.37 2571.57,-2522.37 1963.57,-2522.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2522.37 1963.57,-2582.37 2571.57,-2582.37 2571.57,-2522.37 1963.57,-2522.37"/>
|
||||
<text text-anchor="start" x="2128.85" y="-2543.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       notification       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2462.37 1963.57,-2522.37 2571.57,-2522.37 2571.57,-2462.37 1963.57,-2462.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2462.37 1963.57,-2522.37 2571.57,-2522.37 2571.57,-2462.37 1963.57,-2462.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2483.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="1999.46" y="-2483.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-2483.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2483.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2483.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2402.37 1963.57,-2462.37 2571.57,-2462.37 2571.57,-2402.37 1963.57,-2402.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2402.37 1963.57,-2462.37 2571.57,-2462.37 2571.57,-2402.37 1963.57,-2402.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2422.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-2423.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2423.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2423.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2342.37 1963.57,-2402.37 2571.57,-2402.37 2571.57,-2342.37 1963.57,-2342.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2342.37 1963.57,-2402.37 2571.57,-2402.37 2571.57,-2342.37 1963.57,-2342.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2362.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">type    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-2363.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2363.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2363.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2282.37 1963.57,-2342.37 2571.57,-2342.37 2571.57,-2282.37 1963.57,-2282.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2282.37 1963.57,-2342.37 2571.57,-2342.37 2571.57,-2282.37 1963.57,-2282.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2302.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">title    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-2303.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2303.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2303.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2222.37 1963.57,-2282.37 2571.57,-2282.37 2571.57,-2222.37 1963.57,-2222.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2222.37 1963.57,-2282.37 2571.57,-2282.37 2571.57,-2222.37 1963.57,-2222.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2242.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">message    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-2243.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2243.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2243.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2162.37 1963.57,-2222.37 2571.57,-2222.37 2571.57,-2162.37 1963.57,-2162.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2162.37 1963.57,-2222.37 2571.57,-2222.37 2571.57,-2162.37 1963.57,-2162.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2182.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">data    </text>
|
||||
<text text-anchor="start" x="2484.1" y="-2183.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">jsonb</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2102.37 1963.57,-2162.37 2571.57,-2162.37 2571.57,-2102.37 1963.57,-2102.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2102.37 1963.57,-2162.37 2571.57,-2162.37 2571.57,-2102.37 1963.57,-2102.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2122.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_read    </text>
|
||||
<text text-anchor="start" x="2446.73" y="-2123.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2042.37 1963.57,-2102.37 2571.57,-2102.37 2571.57,-2042.37 1963.57,-2042.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2042.37 1963.57,-2102.37 2571.57,-2102.37 2571.57,-2042.37 1963.57,-2042.37"/>
|
||||
<text text-anchor="start" x="1974.2" y="-2062.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="2160.56" y="-2063.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2063.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2063.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-2041.37 1962.57,-2583.37 2572.57,-2583.37 2572.57,-2041.37 1962.57,-2041.37"/>
|
||||
</g>
|
||||
<!-- notification->user -->
|
||||
<!-- notification->user -->
|
||||
<g id="edge26" class="edge">
|
||||
<title>notification:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-2432.37C2707.75,-2432.37 2663.47,-2577.28 2706.68,-2705.37 2747.09,-2825.18 2692.62,-3254.96 2809.23,-3278.37"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.19,-3281.89 2819.48,-3279.37 2809.87,-3274.92 2809.19,-3281.89"/>
|
||||
<text text-anchor="middle" x="2810.59" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2578.79" y="-2403.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- notification_preferences -->
|
||||
<g id="notification_preferences" class="node">
|
||||
<title>notification_preferences</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-3099.37" rx="439.23" ry="384.83"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1959.57,-3309.37 1959.57,-3369.37 2576.57,-3369.37 2576.57,-3309.37 1959.57,-3309.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3309.37 1959.57,-3369.37 2576.57,-3369.37 2576.57,-3309.37 1959.57,-3309.37"/>
|
||||
<text text-anchor="start" x="2035.99" y="-3330.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       notification_preferences       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3249.37 1959.57,-3309.37 2576.57,-3309.37 2576.57,-3249.37 1959.57,-3249.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3249.37 1959.57,-3309.37 2576.57,-3309.37 2576.57,-3249.37 1959.57,-3249.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">user_id</text>
|
||||
<text text-anchor="start" x="2075.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-3270.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3189.37 1959.57,-3249.37 2576.57,-3249.37 2576.57,-3189.37 1959.57,-3189.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3189.37 1959.57,-3249.37 2576.57,-3249.37 2576.57,-3189.37 1959.57,-3189.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-3209.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_requests    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-3210.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3129.37 1959.57,-3189.37 2576.57,-3189.37 2576.57,-3129.37 1959.57,-3129.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3129.37 1959.57,-3189.37 2576.57,-3189.37 2576.57,-3129.37 1959.57,-3129.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-3149.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_accepted    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-3150.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3069.37 1959.57,-3129.37 2576.57,-3129.37 2576.57,-3069.37 1959.57,-3069.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3069.37 1959.57,-3129.37 2576.57,-3129.37 2576.57,-3069.37 1959.57,-3069.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-3089.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_liked    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-3090.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3009.37 1959.57,-3069.37 2576.57,-3069.37 2576.57,-3009.37 1959.57,-3009.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3009.37 1959.57,-3069.37 2576.57,-3069.37 2576.57,-3009.37 1959.57,-3009.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-3029.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_commented    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-3030.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2949.37 1959.57,-3009.37 2576.57,-3009.37 2576.57,-2949.37 1959.57,-2949.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-2949.37 1959.57,-3009.37 2576.57,-3009.37 2576.57,-2949.37 1959.57,-2949.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-2969.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">push_enabled    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-2970.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2889.37 1959.57,-2949.37 2576.57,-2949.37 2576.57,-2889.37 1959.57,-2889.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-2889.37 1959.57,-2949.37 2576.57,-2949.37 2576.57,-2889.37 1959.57,-2889.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-2909.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="2165.55" y="-2910.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2526.48" y="-2910.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-2910.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2829.37 1959.57,-2889.37 2576.57,-2889.37 2576.57,-2829.37 1959.57,-2829.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-2829.37 1959.57,-2889.37 2576.57,-2889.37 2576.57,-2829.37 1959.57,-2829.37"/>
|
||||
<text text-anchor="start" x="1970.23" y="-2849.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at    </text>
|
||||
<text text-anchor="start" x="2165.56" y="-2850.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2526.48" y="-2850.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-2850.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1958.07,-2828.37 1958.07,-3370.37 2577.07,-3370.37 2577.07,-2828.37 1958.07,-2828.37"/>
|
||||
</g>
|
||||
<!-- notification_preferences->user -->
|
||||
<!-- notification_preferences->user -->
|
||||
<g id="edge28" class="edge">
|
||||
<title>notification_preferences:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2577.57,-3279.37C2681.62,-3279.37 2710.15,-3279.37 2809.33,-3279.37"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.48,-3282.87 2819.48,-3279.37 2809.48,-3275.87 2809.48,-3282.87"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2583.79" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- notification_subscription -->
|
||||
<g id="notification_subscription" class="node">
|
||||
<title>notification_subscription</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-1119.37" rx="439.23" ry="427.19"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1959.57,-1359.37 1959.57,-1419.37 2576.57,-1419.37 2576.57,-1359.37 1959.57,-1359.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1359.37 1959.57,-1419.37 2576.57,-1419.37 2576.57,-1359.37 1959.57,-1359.37"/>
|
||||
<text text-anchor="start" x="2035.11" y="-1380.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       notification_subscription       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1299.37 1959.57,-1359.37 2576.57,-1359.37 2576.57,-1299.37 1959.57,-1299.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1299.37 1959.57,-1359.37 2576.57,-1359.37 2576.57,-1299.37 1959.57,-1299.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1320.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="1995.46" y="-1320.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-1320.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-1320.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-1320.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1239.37 1959.57,-1299.37 2576.57,-1299.37 2576.57,-1239.37 1959.57,-1239.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1239.37 1959.57,-1299.37 2576.57,-1299.37 2576.57,-1239.37 1959.57,-1239.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1259.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-1260.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-1260.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-1260.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1179.37 1959.57,-1239.37 2576.57,-1239.37 2576.57,-1179.37 1959.57,-1179.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1179.37 1959.57,-1239.37 2576.57,-1239.37 2576.57,-1179.37 1959.57,-1179.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1199.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">endpoint    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-1200.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-1200.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-1200.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1119.37 1959.57,-1179.37 2576.57,-1179.37 2576.57,-1119.37 1959.57,-1119.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1119.37 1959.57,-1179.37 2576.57,-1179.37 2576.57,-1119.37 1959.57,-1119.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1139.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">p256dh_key    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-1140.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-1140.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-1140.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1059.37 1959.57,-1119.37 2576.57,-1119.37 2576.57,-1059.37 1959.57,-1059.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1059.37 1959.57,-1119.37 2576.57,-1119.37 2576.57,-1059.37 1959.57,-1059.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1079.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">auth_key    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-1080.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-1080.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-1080.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-999.37 1959.57,-1059.37 2576.57,-1059.37 2576.57,-999.37 1959.57,-999.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-999.37 1959.57,-1059.37 2576.57,-1059.37 2576.57,-999.37 1959.57,-999.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1019.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_agent    </text>
|
||||
<text text-anchor="start" x="2513.99" y="-1020.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-939.37 1959.57,-999.37 2576.57,-999.37 2576.57,-939.37 1959.57,-939.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-939.37 1959.57,-999.37 2576.57,-999.37 2576.57,-939.37 1959.57,-939.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-959.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_active    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-960.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-879.37 1959.57,-939.37 2576.57,-939.37 2576.57,-879.37 1959.57,-879.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-879.37 1959.57,-939.37 2576.57,-939.37 2576.57,-879.37 1959.57,-879.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-899.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="2165.55" y="-900.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2526.48" y="-900.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-900.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-819.37 1959.57,-879.37 2576.57,-879.37 2576.57,-819.37 1959.57,-819.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-819.37 1959.57,-879.37 2576.57,-879.37 2576.57,-819.37 1959.57,-819.37"/>
|
||||
<text text-anchor="start" x="1970.23" y="-839.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at    </text>
|
||||
<text text-anchor="start" x="2165.56" y="-840.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2526.48" y="-840.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-840.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1958.07,-818.37 1958.07,-1420.37 2577.07,-1420.37 2577.07,-818.37 1958.07,-818.37"/>
|
||||
</g>
|
||||
<!-- notification_subscription->user -->
|
||||
<!-- notification_subscription->user -->
|
||||
<g id="edge30" class="edge">
|
||||
<title>notification_subscription:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2577.57,-1269.37C2717.03,-1269.37 2672.89,-1420.06 2706.68,-1555.37 2752.35,-1738.25 2632,-3224.25 2809.38,-3277.88"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.08,-3281.37 2819.48,-3279.37 2810.1,-3274.45 2809.08,-3281.37"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2583.79" y="-1240.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- session -->
|
||||
<g id="session" class="node">
|
||||
<title>session</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-1737.37" rx="430.76" ry="172.57"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1965.57,-1797.37 1965.57,-1857.37 2570.57,-1857.37 2570.57,-1797.37 1965.57,-1797.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1965.57,-1797.37 1965.57,-1857.37 2570.57,-1857.37 2570.57,-1797.37 1965.57,-1797.37"/>
|
||||
<text text-anchor="start" x="2151.58" y="-1818.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       session       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1737.37 1965.57,-1797.37 2570.57,-1797.37 2570.57,-1737.37 1965.57,-1737.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1965.57,-1737.37 1965.57,-1797.37 2570.57,-1797.37 2570.57,-1737.37 1965.57,-1737.37"/>
|
||||
<text text-anchor="start" x="1976.57" y="-1758.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="2001.46" y="-1758.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2468.9" y="-1758.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2520.48" y="-1758.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2529.37" y="-1758.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1677.37 1965.57,-1737.37 2570.57,-1737.37 2570.57,-1677.37 1965.57,-1677.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1965.57,-1677.37 1965.57,-1737.37 2570.57,-1737.37 2570.57,-1677.37 1965.57,-1677.37"/>
|
||||
<text text-anchor="start" x="1976.57" y="-1697.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="2468.9" y="-1698.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2520.48" y="-1698.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2529.37" y="-1698.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1617.37 1965.57,-1677.37 2570.57,-1677.37 2570.57,-1617.37 1965.57,-1617.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1965.57,-1617.37 1965.57,-1677.37 2570.57,-1677.37 2570.57,-1617.37 1965.57,-1617.37"/>
|
||||
<text text-anchor="start" x="1976.49" y="-1637.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">expires_at    </text>
|
||||
<text text-anchor="start" x="2159.56" y="-1638.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2520.48" y="-1638.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2529.37" y="-1638.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1964.07,-1616.37 1964.07,-1858.37 2571.07,-1858.37 2571.07,-1616.37 1964.07,-1616.37"/>
|
||||
</g>
|
||||
<!-- session->user -->
|
||||
<!-- session->user -->
|
||||
<g id="edge32" class="edge">
|
||||
<title>session:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2571.57,-1707.37C2682.92,-1707.37 2671,-1812.88 2706.68,-1918.37 2754.2,-2058.88 2671.6,-3225.23 2809.61,-3277.55"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.01,-3281 2819.48,-3279.37 2810.28,-3274.12 2809.01,-3281"/>
|
||||
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2577.79" y="-1678.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 86 KiB |
19646
docs/main.pdf
Normal file
93
docs/main.typ
Normal file
@@ -0,0 +1,93 @@
|
||||
// Author: van Nes Zias
|
||||
// Date: 26-dec-2025
|
||||
// Serengo
|
||||
|
||||
#set document(
|
||||
title: "Serengo",
|
||||
author: "Zias van Nes",
|
||||
)
|
||||
|
||||
// Page setup with margins and header/footer
|
||||
#set page(
|
||||
paper: "a4",
|
||||
margin: 3cm,
|
||||
header: context {
|
||||
let elems = query(heading)
|
||||
.filter(h => h.location().position().page <= here().position().page)
|
||||
let chapter = if elems != () {
|
||||
emph(elems.last().body)
|
||||
}
|
||||
align(right, chapter)
|
||||
},
|
||||
numbering: "1",
|
||||
)
|
||||
|
||||
// Text settings - 1.5 line spacing
|
||||
#set par(
|
||||
leading: 0.65em,
|
||||
justify: true,
|
||||
first-line-indent: 1.8em,
|
||||
)
|
||||
|
||||
// Section numbering (1, 2, 3...)
|
||||
#set heading(numbering: "1.")
|
||||
|
||||
// Title page
|
||||
#align(center)[
|
||||
#text(size: 20pt, weight: "bold")[Serengo]
|
||||
#v(0.8em)
|
||||
#text(size: 12pt)[Projectdocumentatie]
|
||||
|
||||
#v(2em)
|
||||
|
||||
#text(size: 12pt)[Zias van Nes]
|
||||
#v(0.5em)
|
||||
#text(size: 11pt)[Toegepaste Informatica, Odisee Hogeschool, Brussel]
|
||||
|
||||
#v(1em)
|
||||
|
||||
#datetime.today().display()
|
||||
]
|
||||
|
||||
#pagebreak()
|
||||
|
||||
// Abstract
|
||||
#align(center)[
|
||||
#text(size: 12pt, weight: "bold")[Samenvatting]
|
||||
]
|
||||
|
||||
#par(first-line-indent: 0pt)[
|
||||
Serengo is een locatie-gebaseerde sociale webapplicatie waarmee gebruikers zogenaamde 'finds'
|
||||
(plaatsen en momenten) op een kaart kunnen vastleggen, verrijken met media en delen met een
|
||||
vertrouwde kring. Deze paper beschrijft de doelstelling en doelgroep van het project, de
|
||||
onderliggende data- en software-architectuur, de gebruikte tools en ontwikkelworkflow, de
|
||||
deploymentaanpak en de belangrijkste optimalisaties die tijdens de ontwikkeling zijn uitgevoerd.
|
||||
]
|
||||
|
||||
#pagebreak()
|
||||
|
||||
|
||||
// Table of contents
|
||||
#outline(
|
||||
title: "Inhoudstafel",
|
||||
indent: auto,
|
||||
)
|
||||
|
||||
#pagebreak()
|
||||
|
||||
// Include all sections
|
||||
|
||||
#include "sections/wat-is-serengo.typ"
|
||||
#include "sections/voor-wie-is-serengo.typ"
|
||||
#include "sections/erd.typ"
|
||||
#include "sections/klassediagram.typ"
|
||||
#include "sections/architectuur.typ"
|
||||
#include "sections/buildtools.typ"
|
||||
#include "sections/deployment.typ"
|
||||
#include "sections/optimalisaties.typ"
|
||||
#include "sections/screenshots.typ"
|
||||
|
||||
#pagebreak()
|
||||
|
||||
// Bibliography
|
||||
#bibliography("bib/main.bib", style: "apa", title: "Referenties")
|
||||
6
docs/screenshots/find-detail.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675">
|
||||
<rect width="1200" height="675" fill="#f2f2f2" stroke="#222" stroke-width="4" />
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="42" fill="#222">
|
||||
Screenshot placeholder: find-detail
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
6
docs/screenshots/friends.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675">
|
||||
<rect width="1200" height="675" fill="#f2f2f2" stroke="#222" stroke-width="4" />
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="42" fill="#222">
|
||||
Screenshot placeholder: friends
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
6
docs/screenshots/home.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675">
|
||||
<rect width="1200" height="675" fill="#f2f2f2" stroke="#222" stroke-width="4" />
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="42" fill="#222">
|
||||
Screenshot placeholder: home
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 343 B |
6
docs/screenshots/mobile.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675">
|
||||
<rect width="1200" height="675" fill="#f2f2f2" stroke="#222" stroke-width="4" />
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="42" fill="#222">
|
||||
Screenshot placeholder: mobile
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 345 B |
6
docs/screenshots/notifications.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675">
|
||||
<rect width="1200" height="675" fill="#f2f2f2" stroke="#222" stroke-width="4" />
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="42" fill="#222">
|
||||
Screenshot placeholder: notifications
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 352 B |
98
docs/sections/architectuur.typ
Normal file
@@ -0,0 +1,98 @@
|
||||
#heading[Architectuur van het project]
|
||||
|
||||
De architectuur van Serengo is opgebouwd rond SvelteKit als full-stack framework, Drizzle ORM voor de
|
||||
PostgreSQL-database en een aantal gespecialiseerde services voor storage, kaarten en notificaties. De frontend en backend leven in dezelfde codebase en delen type-informatie, zodat de volledige stack strongly typed is.
|
||||
|
||||
== Opbouw van de applicatie
|
||||
|
||||
Op hoog niveau bestaat de applicatie uit de volgende lagen:
|
||||
|
||||
- *Presentatielaag (UI)* – Svelte 5-componenten in `src/lib/components` en pagina's in
|
||||
`src/routes`. Componenten zijn per domein gegroepeerd (auth, finds, map, media, notifications,
|
||||
profile, ...).
|
||||
- *Domein- en servicelaag* – Svelte stores en hulpfuncties in `src/lib/stores` en
|
||||
`src/lib/utils`, plus server-side services in `src/lib/server` (auth, db, media-processor,
|
||||
push, R2, oauth).
|
||||
- *API-laag* – SvelteKit-endpoints in `src/routes/api/*` die CRUD- en acties aanbieden voor
|
||||
finds, friends, users, notifications, profile pictures, places en media.
|
||||
- *Datalaag* – een PostgreSQL-database aangestuurd via Drizzle ORM. De schema-definitie staat in
|
||||
`src/lib/server/db/schema.ts`, migraties in de map `drizzle/`.
|
||||
|
||||
Deze lagen zijn zodanig opgebouwd dat UI-componenten alleen via duidelijke interfaces praten met
|
||||
stores en services, en dat alle persistente data via de Drizzle-laag loopt.
|
||||
|
||||
== Projectstructuur
|
||||
|
||||
De belangrijkste mappen van het project zijn:
|
||||
.
|
||||
- `src/lib/components/` – herbruikbare UI-componenten, gegroepeerd per domein:
|
||||
- `auth/` – login-form en gerelateerde auth-componenten.
|
||||
- `finds/` – componenten rond finds (overzichten, detailweergave, editmodals, comment-UI, ...).
|
||||
- `map/` – kaartcomponenten (Map, LocationManager, POI-zoekfunctionaliteit).
|
||||
- `media/` – video- en mediacomponenten.
|
||||
- `notifications/` – notificatie-UI en beheer.
|
||||
- `profile/` – profielpaneel en profielfoto’s.
|
||||
- plus een set shadcn-achtige UI-primitieven [@shadcn-svelte] (button, card, dropdown-menu,
|
||||
sheet, skeleton, sonner, ...).
|
||||
- `src/lib/server/` – server-side logica:
|
||||
- `db/` – databaseconfiguratie en Drizzle-schema.
|
||||
- `auth.ts` – Lucia-authenticatie. [@lucia-docs]
|
||||
- `oauth.ts` – Google OAuth-integratie.
|
||||
- `push.ts` – Web Push-notificaties.
|
||||
- `r2.ts` – integratie met Cloudflare R2.
|
||||
- `media-processor.ts` – beeld- en videobewerking (o.a. WebP/JPEG-pipeline).
|
||||
- `src/lib/stores/` – Svelte stores:
|
||||
- `api-sync.ts` – centrale sync-service voor optimistic updates.
|
||||
- `location.ts` – tracking van de huidige gebruikerslocatie.
|
||||
- `src/lib/utils/` – hulpfuncties, o.a. `geolocation.ts` en `places.ts` (Google Places API).
|
||||
- `src/routes/` – pagina's en API-endpoints:
|
||||
- `+page.svelte` – homepage met de kaart en finds.
|
||||
- `finds/`, `friends/`, `login/`, ... – feature-specifieke pagina's.
|
||||
- `api/` – submappen voor finds, friends, users, notifications, profile-picture, places, media,
|
||||
...
|
||||
- `drizzle/` – migratiebestanden en metadata.
|
||||
- `static/` – statische assets (fonts, map-styles, afbeeldingen, manifest, robots.txt, ...).
|
||||
- `scripts/` – hulpscripts (zoals het genereren van VAPID-keys).
|
||||
|
||||
== Location-gecentreerde architectuur
|
||||
|
||||
Eén van de belangrijkste architecturale keuzes is de overstap naar een *location-gecentreerd
|
||||
architectuurmodel*. In plaats van elke find zijn eigen, duplicerende locatiedata te laten opslaan,
|
||||
worden locaties in een aparte `locations`-tabel beheerd. Meerdere finds kunnen aan dezelfde locatie
|
||||
gekoppeld worden. Dit heeft meerdere voordelen:
|
||||
|
||||
- *Data-normalisatie* – locatiegegevens (coördinaten, naam, type) worden op één plaats beheerd.
|
||||
- *Betere performance* – queries kunnen efficiënter filteren en groeperen op locatie.
|
||||
- *Functionaliteit per locatie* – het wordt eenvoudiger om alle finds op één plek te combineren en hier extra functionaliteit rond te bouwen (bijv. populariteitsmetrieken in de toekomst).
|
||||
|
||||
De grote logic overhaul (Phase 6–7 in het logboek) beschrijft hoe de bestaande finds-architectuur
|
||||
werd omgevormd naar dit model, inclusief migraties en aanpassingen op zowel API als frontend.
|
||||
|
||||
== Sync-service en api-sync store
|
||||
|
||||
Om de gebruikerservaring soepel te houden, maakt Serengo gebruik van een *sync-service* en een
|
||||
centrale `api-sync` store. In plaats van bij elke wijziging te wachten op een serverrespons, wordt
|
||||
het volgende patroon gebruikt:
|
||||
|
||||
1. De gebruiker voert een actie uit (bijvoorbeeld een find aanpassen of een comment toevoegen).
|
||||
2. De wijziging wordt onmiddellijk lokaal toegepast (optimistic update) zodat de UI instant reageert.
|
||||
3. De sync-service stuurt de wijziging naar de API.
|
||||
4. Bij succes wordt de lokale staat bevestigd; bij een fout wordt de wijziging teruggedraaid (rollback) en ziet de gebruiker een duidelijke foutmelding.
|
||||
|
||||
Deze architectuur zorgt voor een responsieve interface, zelfs bij netwerkvertraging, en centraliseert
|
||||
state management voor finds, comments, likes en ratings.
|
||||
|
||||
In de toekomst zou ik hier eventueel WebSockets aan kunnen toevoegen voor real-time updates tussen gebruikers. Waarschijnlijk zou het ook interessant zijn om hiervoor te kijken naar een externe service. Ik kijk hierbij naar Convex [@convex-docs] die real-time sync en offline-first functionaliteit biedt zodat dit niet allemaal zelf gebouwd en onderhouden hoeft te worden.
|
||||
|
||||
== Integratie met externe diensten
|
||||
|
||||
De architectuur integreert verschillende externe diensten op een consistente manier:
|
||||
|
||||
- *Cloudflare R2* voor opslag van media, benaderd via de `r2.ts`-service en beveiligde signed
|
||||
URLs.
|
||||
- *Google OAuth* voor authenticatie, afgehandeld via `oauth.ts` in combinatie met Lucia [@lucia-docs].
|
||||
- *Google Places API* voor POI-zoekfunctionaliteit, gebruikt door de map- en locatiecomponenten.
|
||||
- *Web Push* voor notificaties, met VAPID-keys en een service worker die push-events verwerkt.
|
||||
|
||||
Elke integratie is ondergebracht in een eigen module of service, zodat de invloed op de rest van het
|
||||
systeem beperkt en overzichtelijk blijft.
|
||||
70
docs/sections/buildtools.typ
Normal file
@@ -0,0 +1,70 @@
|
||||
#heading[Buildtools en ontwikkelcommando's]
|
||||
|
||||
Serengo maakt gebruik van een moderne JavaScript-toolchain gebaseerd op SvelteKit en Vite, aangevuld met TypeScript, ESLint/Prettier en Drizzle voor databasebeheer [@sveltekit-docs; @vite-docs; @drizzle-docs]. In dit hoofdstuk worden de belangrijkste tools en commando's samengevat.
|
||||
|
||||
== Bundling en framework
|
||||
|
||||
- *Framework*: SvelteKit 2 met Svelte 5-componenten en runes (`$props`, `$derived`, `$effect`).
|
||||
- *Bundler/dev-server*: Vite 7, geconfigureerd in `vite.config.ts`.
|
||||
- *Adapter*: `@sveltejs/adapter-node` voor een Node.js-productieserver (`svelte.config.js`).
|
||||
|
||||
De bundling gebeurt via Vite: tijdens ontwikkeling draait een snelle dev-server met hot module replacement; voor productie wordt een geoptimaliseerde bundel gebouwd met code-splitting en tree-shaking.
|
||||
|
||||
== Ontwikkelcommando's (pnpm scripts)
|
||||
|
||||
De belangrijkste commando's (zie `package.json`) zijn:
|
||||
|
||||
- `pnpm run dev`
|
||||
- Start de Vite dev-server met SvelteKit in ontwikkelmodus.
|
||||
- Handig tijdens actieve ontwikkeling; herlaadt automatisch bij codewijzigingen.
|
||||
|
||||
- `pnpm run build`
|
||||
- Maakt een productiebuild van de applicatie.
|
||||
- Gebruikt Vite + SvelteKit om geoptimaliseerde JavaScript en CSS te genereren.
|
||||
|
||||
- `pnpm run preview`
|
||||
- Start een lokale server bovenop de productiebuild.
|
||||
- Wordt gebruikt om de uiteindelijke build te testen voor deployment.
|
||||
|
||||
- `pnpm run check`
|
||||
- Voert `svelte-kit sync` en vervolgens `svelte-check` uit met de `tsconfig.json`.
|
||||
- Controleert TypeScript-typen en Svelte-componenten op fouten.
|
||||
|
||||
- `pnpm run lint`
|
||||
- Voert eerst `prettier --check .` en daarna `eslint .` uit.
|
||||
- Zorgt voor consistente code-stijl en detecteert mogelijke probleemgevallen.
|
||||
|
||||
- `pnpm run format`
|
||||
- Draait Prettier in schrijfmodus (`--write`) over het project.
|
||||
- Past de afgesproken formatteringsregels (tabs, single quotes, max 100 chars) toe.
|
||||
|
||||
== Databasetools (Drizzle ORM)
|
||||
|
||||
Voor het beheer van de PostgreSQL-database wordt Drizzle ORM gebruikt, met bijhorende CLI-commando's:
|
||||
|
||||
- `pnpm run db:start`
|
||||
- Start de PostgreSQL-database via `docker-compose` (zie `docker-compose.yml`).
|
||||
|
||||
- `pnpm run db:generate`
|
||||
- Genereert migratiebestanden op basis van wijzigingen in het Drizzle-schema (`src/lib/server/db/schema.ts`).
|
||||
|
||||
- `pnpm run db:migrate`
|
||||
- Voert de gegenereerde migraties uit op de database.
|
||||
- Wordt zowel lokaal als in de Docker-deployment gebruikt (entrypoint script).
|
||||
|
||||
- `pnpm run db:push`
|
||||
- Pusht de huidige schema-definitie naar de database (handig in vroege ontwikkelfase).
|
||||
|
||||
- `pnpm run db:studio`
|
||||
- Start Drizzle Studio voor het visueel inspecteren van de database.
|
||||
|
||||
- `pnpm run db:generate-erd`
|
||||
- Gebruikt `drizzle-erd` om op basis van het schema een ERD (`erd.svg`) te genereren.
|
||||
|
||||
== ESLint, Prettier en typechecking
|
||||
|
||||
- *ESLint*: bewaakt codekwaliteit en dwingt consistente patterns af, met ondersteuning voor Svelte (`eslint-plugin-svelte`) en moderne JavaScript/TypeScript-regels.
|
||||
- *Prettier*: zorgt voor automatische formattering met de in `AGENTS.md` vastgelegde stijl.
|
||||
- *TypeScript + svelte-check*: zorgen voor statische typeveiligheid doorheen de hele stack.
|
||||
|
||||
Samen vormen deze tools een sterke ontwikkelbasis: fouten worden vroegtijdig opgespoord, de code blijft leesbaar en het buildproces is voorspelbaar en reproduceerbaar.
|
||||
86
docs/sections/deployment.typ
Normal file
@@ -0,0 +1,86 @@
|
||||
#heading[Deployment stappen]
|
||||
|
||||
Serengo ondersteunt meerdere deployment-scenario's, waaronder Vercel en een self-hosted Docker-omgeving [@docker-docs]. In dit hoofdstuk worden de belangrijkste concepten, vereiste configuratie en een concreet stappenplan beschreven.
|
||||
|
||||
== Vereiste environment-variabelen
|
||||
|
||||
Voor een werkende productieomgeving zijn minimaal de volgende variabelen nodig (zie ook `logs/logboek.md`):
|
||||
|
||||
```bash
|
||||
DATABASE_URL= # PostgreSQL-verbinding
|
||||
GOOGLE_CLIENT_ID= # Google OAuth client ID
|
||||
GOOGLE_CLIENT_SECRET= # Google OAuth secret
|
||||
R2_ACCOUNT_ID= # Cloudflare R2 account ID
|
||||
R2_ACCESS_KEY_ID= # Cloudflare R2 access key
|
||||
R2_SECRET_ACCESS_KEY= # Cloudflare R2 secret key
|
||||
R2_BUCKET_NAME= # Cloudflare R2 bucket-naam
|
||||
VAPID_PUBLIC_KEY= # Web Push public key
|
||||
VAPID_PRIVATE_KEY= # Web Push private key
|
||||
GOOGLE_MAPS_API_KEY= # Google Places API key
|
||||
```
|
||||
|
||||
Deze worden tijdens build en runtime ingelezen door de SvelteKit-app en de server-side services (`auth`, `r2`, `push`, `places`).
|
||||
|
||||
== Vercel-deployment (cloud)
|
||||
|
||||
In een Vercel-scenario wordt de app gedeployed met behulp van `@sveltejs/adapter-vercel` (aanwezig als dependency). Belangrijke aandachtspunten:
|
||||
|
||||
- De SvelteKit-app wordt als serverless of edge-functies uitgerold, afhankelijk van de Vercel-configuratie.
|
||||
- `DATABASE_URL` moet verwijzen naar een publiek bereikbare PostgreSQL-instantie (bijv. een managed database).
|
||||
- Cloudflare R2 en Web Push blijven extern; de nodige secrets worden als Vercel environment-variabelen ingesteld.
|
||||
|
||||
== Self-hosted Docker-deployment
|
||||
|
||||
Voor een zelfgehoste omgeving is een uitgebreide Docker-setup voorzien (Phase 7 in het logboek):
|
||||
|
||||
- Multi-stage Docker build om een compacte productie-image te maken.
|
||||
- Health checks om de beschikbaarheid van de container te monitoren.
|
||||
- Een entrypoint-script dat bij het starten van de container automatisch Drizzle-migraties uitvoert.
|
||||
- Drizzle als production dependency, zodat migraties ook in de container kunnen draaien.
|
||||
|
||||
Typische stappen voor een self-hosted deployment zijn:
|
||||
|
||||
1. Zorg voor een draaiende PostgreSQL-instantie (lokaal, via `docker-compose` of extern) en vul de `DATABASE_URL` correct in.
|
||||
2. Stel alle vereiste environment-variabelen in voor de app (zie boven).
|
||||
3. Bouw de Docker-image, bijvoorbeeld met:
|
||||
|
||||
```bash
|
||||
docker build -t serengo:latest .
|
||||
```
|
||||
|
||||
4. Start de container met de juiste environment-variabelen en netwerkconfiguratie, bijvoorbeeld via
|
||||
`docker-compose`:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
5. Het entrypoint-script in `docker-entrypoint.sh` zorgt ervoor dat `pnpm run db:migrate` (of een equivalent via `drizzle-kit`) uitgevoerd wordt bij het opstarten, zodat de database-schema's up-to-date zijn.
|
||||
6. Controleer de logs en health checks om na te gaan of de app correct draait.
|
||||
|
||||
== Van ontwikkeling naar productie
|
||||
|
||||
Samengevat ziet de route van lokale ontwikkeling naar productie er als volgt uit:
|
||||
|
||||
1. Lokaal ontwikkelen met `pnpm run dev`, frequent linten en typen checken (`pnpm run lint`, `pnpm run check`).
|
||||
2. Database- en schemawijzigingen doorvoeren met Drizzle (`db:generate`, `db:migrate`, `db:generate-erd`).
|
||||
3. De productiebuild genereren met `pnpm run build` en lokaal testen via `pnpm run preview`.
|
||||
4. Deployen naar Vercel of bouwen en uitrollen van de Docker-image in een self-hosted omgeving.
|
||||
|
||||
Door de geautomatiseerde migraties en consistente configuratie is het deploymentproces herhaalbaar en betrouwbaar.
|
||||
|
||||
== Aanvullende documentatie en hosting
|
||||
|
||||
Naast deze paper is er een aparte API-referentie beschikbaar, opgebouwd met Mintlify en gehost op mijn eigen domein:
|
||||
|
||||
- *API-documentatie*: `https://docs.zias.be` – Mintlify-documentatie [@mintlify-docs] voor de Serengo-API.
|
||||
|
||||
De volledige broncode van Serengo wordt self-hosted op mijn eigen Git-server:
|
||||
|
||||
- *Broncode-repository*: `https://git.zias.be/zias/serengo` – privé Git-instantie met de volledige Serengo broncode.
|
||||
|
||||
De productieversie van de Serengo-applicatie draait op mijn eigen Hetzner VPS [@hetzner-cloud], uitgerold met Docker [@docker-docs]:
|
||||
|
||||
- *Live-applicatie*: `https://serengo.zias.be` – SvelteKit-app in Docker-container op een Hetzner VPS.
|
||||
|
||||
Deze deployment zorgt ervoor dat zowel de code als de infrastructuur volledig in eigen beheer zijn, zonder afhankelijkheid van publieke Git-hosting of PaaS-platformen.
|
||||
54
docs/sections/erd.typ
Normal file
@@ -0,0 +1,54 @@
|
||||
#heading[ERD]
|
||||
|
||||
In dit hoofdstuk wordt het Entity-Relationship Diagram (ERD) van de PostgreSQL-database toegelicht. Het ERD is automatisch gegenereerd op basis van het Drizzle-schema (`src/lib/server/db/schema.ts`) en is als `erd.svg` toegevoegd aan de repository.
|
||||
|
||||
#figure(
|
||||
image("../erd.svg"),
|
||||
caption: [Globale ERD van de Serengo-database]
|
||||
)
|
||||
|
||||
De database is ontworpen rond een aantal kernentiteiten die samen het sociale, locatie-gebaseerde karakter van Serengo ondersteunen.
|
||||
|
||||
== Kernentiteiten
|
||||
|
||||
De belangrijkste tabellen zijn:
|
||||
|
||||
- *users* – bevat gebruikersaccounts, inclusief referenties naar authenticatiegegevens (Lucia)[@lucia-docs] en profielinformatie.
|
||||
- *sessions* – beheert actieve sessies voor gebruikers.
|
||||
- *oauth_accounts* – slaat externe OAuth-accounts op (zoals Google) die aan een user gekoppeld zijn.
|
||||
- *locations* – beschrijft gedeelde locaties met o.a. coördinaten, naam en type.
|
||||
- *finds* – posts die aan een locatie gekoppeld zijn, met titel, beschrijving, privacy-instelling en metadata.
|
||||
- *media* (impliciet in het schema afhankelijk van de definitie) – verwijzingen naar geüploade afbeeldingen en video's in Cloudflare R2.
|
||||
- *likes* – registreren welke gebruiker welke find leuk vindt.
|
||||
- *comments* – reacties van gebruikers bij finds.
|
||||
- *friendships* – vriendschapsrelaties en hun status (bijv. pending, accepted, rejected).
|
||||
- *ratings* – numerieke beoordelingen (1–5 sterren) die gebruikers aan finds geven.
|
||||
- *notification_subscriptions* – Web Push-abonnementen (endpoint + keys) per gebruiker/apparaat.
|
||||
- *notification_preferences* – instellingen per gebruiker voor welke notificaties gewenst zijn.
|
||||
|
||||
== Belangrijkste relaties
|
||||
|
||||
Een aantal relaties zijn cruciaal voor het gedrag van de applicatie:
|
||||
|
||||
- Eén *user* heeft veel *finds*; elke find hoort bij precies één user (de eigenaar).
|
||||
- Eén *location* heeft veel *finds*; meerdere users kunnen finds aan dezelfde locatie koppelen.
|
||||
- Eén *find* heeft veel *likes*, *comments* en *ratings*.
|
||||
- Eén *user* heeft veel *likes*, *comments* en *ratings* verspreid over verschillende finds.
|
||||
- *friendships* modelleren wederzijdse relaties tussen twee users; hieruit volgt welke finds zichtbaar zijn bij privacyfilters.
|
||||
- *notification_subscriptions* en *notification_preferences* zijn aan users gekoppeld en bepalen hoe en wanneer pushnotificaties verstuurd worden.
|
||||
|
||||
Samen vormen deze relaties het fundament voor de sociale interacties rond locaties: het ERD maakt zichtbaar hoe gebruikers, locaties, finds en interacties logisch met elkaar verbonden zijn.
|
||||
|
||||
== Normalisatie en evolutie van het datamodel
|
||||
|
||||
Tijdens de ontwikkeling is het datamodel geëvolueerd van een eenvoudiger structuur naar een meer genormaliseerde, location-gecentreerde architectuur (zie ook het hoofdstuk Architectuur). Belangrijke stappen hierin waren:
|
||||
|
||||
- Het verplaatsen van locatienamen en -informatie uit de *finds*-tabel naar een aparte *locations*-tabel, zodat meerdere finds dezelfde locatie kunnen delen.
|
||||
- Het toevoegen van aparte tabellen voor *comments*, *likes* en *ratings*, in plaats van alles in één "finds"-structuur te stoppen.
|
||||
- Het uitbreiden van notificatie- en privacygerelateerde tabellen om Web Push en friend-based zichtbaarheid te ondersteunen.
|
||||
|
||||
Deze normalisatie heeft als effect dat:
|
||||
|
||||
- data-integriteit verbeterd is (minder duplicatie, duidelijke foreign keys);
|
||||
- queries efficiënter en expressiever worden (bijv. alle finds op een locatie, alle activiteiten van een user);
|
||||
- nieuwe features (zoals locatiepopulariteit of uitgebreidere statistieken) eenvoudiger in te passen zijn op basis van het bestaande schema.
|
||||
53
docs/sections/klassediagram.typ
Normal file
@@ -0,0 +1,53 @@
|
||||
#heading[Klassediagram]
|
||||
|
||||
In dit hoofdstuk wordt een logisch klassediagram gepresenteerd dat de belangrijkste domeinmodellen (typen) van Serengo samenvat. Hoewel de implementatie in TypeScript + Drizzle ORM gebeurt in plaats van klassieke OOP-klassen, kunnen we de structuur voorstellen als een verzameling klassen met velden en relaties. Dit klassediagram sluit nauw aan op het ERD, maar benadrukt vooral de verantwoordelijkheid per type in de applicatielogica.
|
||||
|
||||
== Overzicht van domeinmodellen
|
||||
|
||||
De kernmodellen zijn:
|
||||
|
||||
- *User*
|
||||
- Velden: id, naam, e-mailadres, avatar/profielfoto, aanmaakdatum, OAuth-gegevens.
|
||||
- Verantwoordelijkheid: vertegenwoordigt een eindgebruiker; koppelt naar sessies, find-activiteit en sociale relaties.
|
||||
|
||||
- *Location*
|
||||
- Velden: id, naam, beschrijving (optioneel), coördinaten (latitude, longitude), type/categorie.
|
||||
- Verantwoordelijkheid: beschrijft een fysieke plaats op de kaart waarop meerdere finds kunnen worden geprojecteerd.
|
||||
|
||||
- *Find*
|
||||
- Velden: id, eigenaar (user-id), location-id, titel, beschrijving, privacy-instelling, timestamps, verwijzingen naar media.
|
||||
- Verantwoordelijkheid: vormt de kern-"post" in de applicatie: een verhaal + media gekoppeld aan een locatie.
|
||||
|
||||
- *MediaItem*
|
||||
- Velden: id, find-id, type (afbeelding/video), url (naar R2), fallback-url, metadata (bijv. formaat, grootte).
|
||||
- Verantwoordelijkheid: encapsuleert één mediabestand; zorgt voor scheiding tussen inhoud (find) en opslag (R2).
|
||||
|
||||
- *Comment*
|
||||
- Velden: id, find-id, user-id, inhoud, aanmaakdatum.
|
||||
- Verantwoordelijkheid: laat gebruikers reageren op finds; gekoppeld aan de API-sync-laag voor realtime updates.
|
||||
|
||||
- *Like*
|
||||
- Velden: id, find-id, user-id, timestamp.
|
||||
- Verantwoordelijkheid: registreert een "vind-ik-leuk"-actie op een find.
|
||||
|
||||
- *Rating*
|
||||
- Velden: id, find-id, user-id, waarde (1–5), timestamp.
|
||||
- Verantwoordelijkheid: legt een expliciete beoordeling van een find vast, los van likes.
|
||||
|
||||
- *Friendship*
|
||||
- Velden: id, requester-id, addressee-id, status (pending, accepted, rejected), timestamps.
|
||||
- Verantwoordelijkheid: modelleert de vriendrelatie en vormt de basis voor privacy-bewuste filtering.
|
||||
|
||||
- *Notification* (logisch model bovenop meerdere DB-tabellen)
|
||||
- Velden: id, user-id, type (like, comment, friend-request, ...), payload, status (gelezen/ongelezen).
|
||||
- Verantwoordelijkheid: representeert een gebeurtenis die aan de gebruiker wordt gecommuniceerd; gebruikt samen met subscription- en preference-modellen voor Web Push.
|
||||
|
||||
== Relatie met frontend en stores
|
||||
|
||||
In de frontend worden deze modellen gebruikt in Svelte-componenten en Svelte stores:
|
||||
|
||||
- In `src/lib/stores/api-sync.ts` wordt een typesafe representatie van finds, comments, likes en ratings bijgehouden.
|
||||
- Components in `src/lib/components/finds/` en `src/lib/components/map/` ontvangen deze modellen via props en renderen ze in de UI.
|
||||
- Door overal TypeScript-typen te gebruiken, blijven de klassediagram-concepten consistent tussen database, serverlogica en frontend.
|
||||
|
||||
Het klassediagram helpt zo om de logische structuur van de applicatie in één oogopslag te begrijpen en vormt een brug tussen het relationele ERD en de concrete implementatie in TypeScript en Svelte.
|
||||
78
docs/sections/optimalisaties.typ
Normal file
@@ -0,0 +1,78 @@
|
||||
#heading[Optimalisaties en resultaten]
|
||||
|
||||
Tijdens de ontwikkeling van Serengo zijn meerdere optimalisatiegolven doorgevoerd op het gebied van performance, UX, architectuur en deployment. Dit hoofdstuk vat de belangrijkste stappen en hun impact samen.
|
||||
|
||||
== Performance- en SEO-optimalisaties
|
||||
|
||||
Op 7 oktober is een grote optimalisatieronde uitgevoerd gericht op SEO, PWA en laadtijden:
|
||||
|
||||
- *SEO-verbeteringen*
|
||||
- Toevoegen en bijwerken van meta-tags en Open Graph-data.
|
||||
- Automatische generatie van `sitemap.xml` voor betere indexeerbaarheid.
|
||||
- *PWA-verbeteringen*
|
||||
- Uitbreiding van de service worker voor caching van statische assets en kernroutes.
|
||||
- Optimalisatie van `manifest.json` (iconen, naamgeving, start-URL).
|
||||
- *Performance*
|
||||
- Compressie van de achtergrondafbeelding (± 50% kleiner; van ~4.2MB naar ~2.1MB).
|
||||
- Vite-configoptimalisaties en fixen van build-issues.
|
||||
|
||||
Gemeten resultaten (op basis van Lighthouse-logs):
|
||||
|
||||
- Homepageload verbeterd van ongeveer 2.5s naar 1.2s.
|
||||
- Largest Contentful Paint gedaald van ±1.8s naar ±0.9s.
|
||||
- Betere stabiliteit door caching en service worker-ondersteuning.
|
||||
|
||||
== Architecturale verbeteringen
|
||||
|
||||
In Phase 6 en 7 is de interne architectuur grondig herzien:
|
||||
|
||||
- *Overgang naar location-gecentreerd datamodel*
|
||||
- Scheiding tussen `locations` en `finds`, met meerdere finds per locatie.
|
||||
- Normalisatie van locatiedata (minder duplicatie, betere querymogelijkheden).
|
||||
- Transactieve migraties en data-integriteitscontroles tijdens de overgang.
|
||||
|
||||
- *Sync-service en api-sync-laag*
|
||||
- Implementatie van een centrale sync-service (`api-sync.ts`) voor optimistic updates.
|
||||
- Automatische rollback bij falende API-calls, waardoor inconsistenties worden voorkomen.
|
||||
- Vermindering van dubbele logica in componenten en eenduidige bron van waarheid voor find-, comment-, like- en ratingdata.
|
||||
|
||||
Deze ingrepen verhogen zowel de schaalbaarheid (efficiëntere queries, minder redundantie) als de gebruikerservaring (snellere, soepelere UI).
|
||||
|
||||
== UX- en mapoptimalisaties
|
||||
|
||||
Een belangrijke focus lag op de kaart- en navigatie-ervaring:
|
||||
|
||||
- *Fullscreen map + sidebar*
|
||||
- Introductie van een fullscreen kaart met een uitschuifbare zijbalk voor finds.
|
||||
- Oplossing van overscroll- en overflowproblemen in lijsten.
|
||||
|
||||
- *Dynamische mapcentrering*
|
||||
- Intelligente centrering van de kaart afhankelijk van sidebar-positie (desktop vs. mobiel).
|
||||
- Voorkomen van storende autozoom tijdens het volgen van locaties.
|
||||
|
||||
- *Marker- en layoutverbeteringen*
|
||||
- Toevoegen van location markers, clustering en betere positionering.
|
||||
- UI-finetuning voor FindPreview, CommentsList en andere componenten.
|
||||
|
||||
Resultaat: gebruikers zien hun find altijd in het zichtbare deel van de kaart, en navigeren vloeiend tussen locaties en finds.
|
||||
|
||||
== Mediaperformance en veiligheid
|
||||
|
||||
Om zowel performance als security te verbeteren, zijn verschillende stappen gezet rond media:
|
||||
|
||||
- Invoering van een lokale mediaproxy (`/api/media/[...path]`) in plaats van directe Cloudflare R2-URL's.
|
||||
- CSP-fixes om externe resources gecontroleerd toe te laten.
|
||||
- WebP/JPEG-pipeline voor efficiëntere afbeeldingen met fallback-ondersteuning.
|
||||
|
||||
Dit zorgt voor snellere laadtijden, minder kans op CSP-gerelateerde fouten en betere controle over de media-aflevering.
|
||||
|
||||
== Deployment- en operationele optimalisaties
|
||||
|
||||
Om deployments robuust en reproduceerbaar te maken zijn onder meer de volgende verbeteringen geïntroduceerd:
|
||||
|
||||
- Multi-stage Docker build voor kleinere, efficiëntere images.
|
||||
- Verplaatsing van Drizzle naar production dependencies zodat migraties binnen de container kunnen draaien.
|
||||
- Een entrypoint-script dat bij het starten van de container automatisch database-migraties uitvoert.
|
||||
- Health checks en verbeterde logging voor snellere foutdetectie.
|
||||
|
||||
Samen maken deze optimalisaties Serengo geschikt voor langdurige, stabiele hosting met goede gebruikerservaring en onderhoudbaarheid.
|
||||
31
docs/sections/screenshots.typ
Normal file
@@ -0,0 +1,31 @@
|
||||
#heading[Screenshots en gebruiksscenario's]
|
||||
|
||||
In dit hoofdstuk worden enkele representatieve screenshots van de applicatie opgenomen. Om het
|
||||
Typst-document compileerbaar te houden, zijn er placeholder-afbeeldingen voorzien in
|
||||
`docs/screenshots/`. Vervang deze placeholders later door echte screenshots (bijvoorbeeld PNG) en
|
||||
pas de bestandsnamen/paden hieronder aan.
|
||||
|
||||
#figure(
|
||||
image("../screenshots/home.svg"),
|
||||
caption: [Startscherm met fullscreen kaart en zijbalk met finds]
|
||||
)
|
||||
|
||||
#figure(
|
||||
image("../screenshots/find-detail.svg"),
|
||||
caption: [Detailpagina van een find met media, likes, comments en rating]
|
||||
)
|
||||
|
||||
#figure(
|
||||
image("../screenshots/friends.svg"),
|
||||
caption: [Vriendenoverzicht en privacy-bewuste filtering van finds]
|
||||
)
|
||||
|
||||
#figure(
|
||||
image("../screenshots/notifications.svg"),
|
||||
caption: [In-app notificaties en Web Push-permissies]
|
||||
)
|
||||
|
||||
#figure(
|
||||
image("../screenshots/mobile.svg"),
|
||||
caption: [Mobiele weergave met zijbalk en dynamische mapcentrering]
|
||||
)
|
||||
31
docs/sections/voor-wie-is-serengo.typ
Normal file
@@ -0,0 +1,31 @@
|
||||
#heading[Voor wie is Serengo?]
|
||||
|
||||
Serengo is ontworpen voor kleine tot middelgrote groepen gebruikers die waarde hechten aan het gezamenlijk ontdekken, bewaren en herbeleven van interessante plaatsen. In plaats van een volledig open, anoniem social media-platform, richt Serengo zich op contexten waar onderling vertrouwen en controle over zichtbaarheid belangrijk zijn.
|
||||
|
||||
== Primaire doelgroep
|
||||
|
||||
- *Vriendengroepen en kennissenkringen* die samen een kaart willen opbouwen van favoriete cafés, restaurants, uitzichtpunten, wandelroutes of andere "hidden gems".
|
||||
- *Lokale communities* (bijvoorbeeld studentenverenigingen, hobbygroepen of buurtinitiatieven) die interessante locaties in hun omgeving willen cureren en onderling delen.
|
||||
- *Reizigers en explorers* die hun mooiste ontdekkingen op reis willen vastleggen, documenteren met media en nadien eenvoudig terugvinden op een kaart.
|
||||
|
||||
== Secundaire doelgroep
|
||||
|
||||
- *Individuele gebruikers* die een persoonlijke kaart van favoriete plaatsen willen beheren zonder dit publiek op een grote social media-site te delen.
|
||||
- *Early adopters en ontwikkelaars* die geïnteresseerd zijn in moderne webtechnologie (SvelteKit, Drizzle ORM, Web Push, MapLibre) en een concreet voorbeeldproject zoeken.
|
||||
|
||||
== Behoeften en use-cases
|
||||
|
||||
De belangrijkste behoeften die Serengo adresseert zijn:
|
||||
|
||||
- *Gecontroleerde zichtbaarheid* – via het vriendensysteem en privacyfilters (All/Public/Friends/Mine) kan de eigenaar per find bepalen wie wat te zien krijgt.
|
||||
- *Rijke context rond locaties* – naast coördinaten en naam biedt Serengo ruimte voor verhalen, foto's, video's, likes, comments en ratings.
|
||||
- *Gezamenlijk geheugen* – meerdere gebruikers kunnen finds aan dezelfde locatie koppelen zodat er een gedeelde geschiedenis per plaats ontstaat.
|
||||
- *Eenvoudig delen* – via deelbare URL's en de Web Share API kunnen finds snel met anderen worden gedeeld.
|
||||
|
||||
== Link met functionaliteit
|
||||
|
||||
De ontwerpkeuzes in de applicatie sluiten direct aan bij deze doelgroep:
|
||||
|
||||
- Het *vriendensysteem* en de *privacy-bewuste filtering* maken het veilig om persoonlijke plaatsen te delen binnen een beperkte kring.
|
||||
- *Ratings en comments* maken het mogelijk om samen te reflecteren op plaatsen en aanbevelingen te doen.
|
||||
- De *fullscreen kaartinterface* met slimme centrering en clustering ondersteunt het ontdekken van nieuwe locaties op een intuïtieve, visuele manier.
|
||||
16
docs/sections/wat-is-serengo.typ
Normal file
@@ -0,0 +1,16 @@
|
||||
#heading[Wat is Serengo?]
|
||||
|
||||
Serengo is een locatie-gebaseerde sociale webapplicatie, gebouwd met SvelteKit [@sveltekit-docs],
|
||||
waarin gebruikers zogenaamde 'finds' kunnen vastleggen en delen. Een find is een combinatie van een plaats op de kaart, een kort verhaal en
|
||||
bijhorende media (foto's en video's). Gebruikers kunnen zo hun favoriete plekken documenteren,
|
||||
waarderen en met anderen delen.
|
||||
|
||||
Het project combineert interactieve kaarten, moderne webtechnologie en sociale functionaliteit tot één samenhorende ervaring:
|
||||
|
||||
- *Kaart-centrische interface* – de startpagina toont een fullscreen kaart (MapLibre GL JS [@maplibre-docs]) waarop finds als markers verschijnen. Via een zijbalk kunnen gebruikers finds verkennen, filteren en selecteren.
|
||||
- *Finds met rijke media* – bij elke find horen een titel, beschrijving, locatie en één of meerdere media-items (beelden en video's). Media worden opgeslagen in Cloudflare R2 [@cloudflare-r2-docs] en veilig ontsloten via signed URLs en een lokale mediaproxy.
|
||||
- *Sociale interacties* – gebruikers kunnen finds liken, commenten en beoordelen (ratings). Vriendschapsrelaties bepalen wie welke finds mag zien: publiek, alleen vrienden of enkel de eigenaar.
|
||||
- *Realtime en gebruiksvriendelijke updates* – door een sync-service en een centrale `api-sync` store worden wijzigingen eerst optimistisch in de UI toegepast en daarna met de database gesynchroniseerd. Bij fouten volgt automatisch een rollback.
|
||||
- *Notificaties en PWA* – via een service worker en Web Push [@web-push-spec] worden gebruikers op de hoogte gebracht van gebeurtenissen zoals likes, comments en vriendschapsverzoeken. De applicatie is ingericht als Progressive Web App met caching en offline-ondersteuning voor kernfunctionaliteit.
|
||||
|
||||
In essentie is Serengo een digitale kaart vol persoonlijke verhalen. Het biedt de technische infrastructuur om plaatsen vast te leggen, te verrijken met context en die ervaringen gecontroleerd te (her)delen binnen een sociale omgeving.
|
||||
11
drizzle/0006_strange_firebird.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "find_comment" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"find_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
37
drizzle/0007_grey_dark_beast.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE "notification" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"message" text NOT NULL,
|
||||
"data" jsonb,
|
||||
"is_read" boolean DEFAULT false,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notification_preferences" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"friend_requests" boolean DEFAULT true,
|
||||
"friend_accepted" boolean DEFAULT true,
|
||||
"find_liked" boolean DEFAULT true,
|
||||
"find_commented" boolean DEFAULT true,
|
||||
"push_enabled" boolean DEFAULT true,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notification_subscription" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"endpoint" text NOT NULL,
|
||||
"p256dh_key" text NOT NULL,
|
||||
"auth_key" text NOT NULL,
|
||||
"user_agent" text,
|
||||
"is_active" boolean DEFAULT true,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notification_subscription" ADD CONSTRAINT "notification_subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
15
drizzle/0008_common_supreme_intelligence.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE "location" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"latitude" text NOT NULL,
|
||||
"longitude" text NOT NULL,
|
||||
"location_name" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "find" ADD COLUMN "location_id" text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "location" ADD CONSTRAINT "location_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "find" ADD CONSTRAINT "find_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "find" DROP COLUMN "latitude";--> statement-breakpoint
|
||||
ALTER TABLE "find" DROP COLUMN "longitude";--> statement-breakpoint
|
||||
ALTER TABLE "find" DROP COLUMN "location_name";
|
||||
47
drizzle/0008_location_refactor.sql
Normal file
@@ -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";
|
||||
15
drizzle/0009_lazy_monster_badoon.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE "find_rating" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"find_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"rating" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "find" ADD COLUMN "rating" integer;--> statement-breakpoint
|
||||
ALTER TABLE "find" ADD COLUMN "rating_count" integer DEFAULT 0;--> statement-breakpoint
|
||||
ALTER TABLE "location" ADD COLUMN "average_rating" integer;--> statement-breakpoint
|
||||
ALTER TABLE "location" ADD COLUMN "rating_count" integer DEFAULT 0;--> statement-breakpoint
|
||||
ALTER TABLE "find_rating" ADD CONSTRAINT "find_rating_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "find_rating" ADD CONSTRAINT "find_rating_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
521
drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,521 @@
|
||||
{
|
||||
"id": "f487a86b-4f25-4d3c-a667-e383c352f3cc",
|
||||
"prevId": "e0be0091-df6b-48be-9d64-8b4108d91651",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.find": {
|
||||
"name": "find",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_name": {
|
||||
"name": "location_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_user_id_user_id_fk": {
|
||||
"name": "find_user_id_user_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_comment": {
|
||||
"name": "find_comment",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_comment_find_id_find_id_fk": {
|
||||
"name": "find_comment_find_id_find_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_comment_user_id_user_id_fk": {
|
||||
"name": "find_comment_user_id_user_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_like": {
|
||||
"name": "find_like",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_like_find_id_find_id_fk": {
|
||||
"name": "find_like_find_id_find_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_like_user_id_user_id_fk": {
|
||||
"name": "find_like_user_id_user_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_media": {
|
||||
"name": "find_media",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_url": {
|
||||
"name": "fallback_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_thumbnail_url": {
|
||||
"name": "fallback_thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"order_index": {
|
||||
"name": "order_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_media_find_id_find_id_fk": {
|
||||
"name": "find_media_find_id_find_id_fk",
|
||||
"tableFrom": "find_media",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friendship": {
|
||||
"name": "friendship",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_id": {
|
||||
"name": "friend_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friendship_user_id_user_id_fk": {
|
||||
"name": "friendship_user_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friendship_friend_id_user_id_fk": {
|
||||
"name": "friendship_friend_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"friend_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"age": {
|
||||
"name": "age",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"profile_picture_url": {
|
||||
"name": "profile_picture_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"user_google_id_unique": {
|
||||
"name": "user_google_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"google_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
764
drizzle/meta/0007_snapshot.json
Normal file
@@ -0,0 +1,764 @@
|
||||
{
|
||||
"id": "1dbab94c-004e-4d34-b171-408bb1d36c91",
|
||||
"prevId": "f487a86b-4f25-4d3c-a667-e383c352f3cc",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.find": {
|
||||
"name": "find",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_name": {
|
||||
"name": "location_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_user_id_user_id_fk": {
|
||||
"name": "find_user_id_user_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_comment": {
|
||||
"name": "find_comment",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_comment_find_id_find_id_fk": {
|
||||
"name": "find_comment_find_id_find_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_comment_user_id_user_id_fk": {
|
||||
"name": "find_comment_user_id_user_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_like": {
|
||||
"name": "find_like",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_like_find_id_find_id_fk": {
|
||||
"name": "find_like_find_id_find_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_like_user_id_user_id_fk": {
|
||||
"name": "find_like_user_id_user_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_media": {
|
||||
"name": "find_media",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_url": {
|
||||
"name": "fallback_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_thumbnail_url": {
|
||||
"name": "fallback_thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"order_index": {
|
||||
"name": "order_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_media_find_id_find_id_fk": {
|
||||
"name": "find_media_find_id_find_id_fk",
|
||||
"tableFrom": "find_media",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friendship": {
|
||||
"name": "friendship",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_id": {
|
||||
"name": "friend_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friendship_user_id_user_id_fk": {
|
||||
"name": "friendship_user_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friendship_friend_id_user_id_fk": {
|
||||
"name": "friendship_friend_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"friend_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification": {
|
||||
"name": "notification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_read": {
|
||||
"name": "is_read",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_user_id_user_id_fk": {
|
||||
"name": "notification_user_id_user_id_fk",
|
||||
"tableFrom": "notification",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_preferences": {
|
||||
"name": "notification_preferences",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_requests": {
|
||||
"name": "friend_requests",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"friend_accepted": {
|
||||
"name": "friend_accepted",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_liked": {
|
||||
"name": "find_liked",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_commented": {
|
||||
"name": "find_commented",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"push_enabled": {
|
||||
"name": "push_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_preferences_user_id_user_id_fk": {
|
||||
"name": "notification_preferences_user_id_user_id_fk",
|
||||
"tableFrom": "notification_preferences",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_subscription": {
|
||||
"name": "notification_subscription",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"p256dh_key": {
|
||||
"name": "p256dh_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"auth_key": {
|
||||
"name": "auth_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_subscription_user_id_user_id_fk": {
|
||||
"name": "notification_subscription_user_id_user_id_fk",
|
||||
"tableFrom": "notification_subscription",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"age": {
|
||||
"name": "age",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"profile_picture_url": {
|
||||
"name": "profile_picture_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"user_google_id_unique": {
|
||||
"name": "user_google_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"google_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
829
drizzle/meta/0008_snapshot.json
Normal file
@@ -0,0 +1,829 @@
|
||||
{
|
||||
"id": "5654d58b-23f8-48cb-9933-5ac32141b75e",
|
||||
"prevId": "1dbab94c-004e-4d34-b171-408bb1d36c91",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.find": {
|
||||
"name": "find",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"location_id": {
|
||||
"name": "location_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_location_id_location_id_fk": {
|
||||
"name": "find_location_id_location_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "location",
|
||||
"columnsFrom": [
|
||||
"location_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_user_id_user_id_fk": {
|
||||
"name": "find_user_id_user_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_comment": {
|
||||
"name": "find_comment",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_comment_find_id_find_id_fk": {
|
||||
"name": "find_comment_find_id_find_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_comment_user_id_user_id_fk": {
|
||||
"name": "find_comment_user_id_user_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_like": {
|
||||
"name": "find_like",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_like_find_id_find_id_fk": {
|
||||
"name": "find_like_find_id_find_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_like_user_id_user_id_fk": {
|
||||
"name": "find_like_user_id_user_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_media": {
|
||||
"name": "find_media",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_url": {
|
||||
"name": "fallback_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_thumbnail_url": {
|
||||
"name": "fallback_thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"order_index": {
|
||||
"name": "order_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_media_find_id_find_id_fk": {
|
||||
"name": "find_media_find_id_find_id_fk",
|
||||
"tableFrom": "find_media",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friendship": {
|
||||
"name": "friendship",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_id": {
|
||||
"name": "friend_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friendship_user_id_user_id_fk": {
|
||||
"name": "friendship_user_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friendship_friend_id_user_id_fk": {
|
||||
"name": "friendship_friend_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"friend_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.location": {
|
||||
"name": "location",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_name": {
|
||||
"name": "location_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"location_user_id_user_id_fk": {
|
||||
"name": "location_user_id_user_id_fk",
|
||||
"tableFrom": "location",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification": {
|
||||
"name": "notification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_read": {
|
||||
"name": "is_read",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_user_id_user_id_fk": {
|
||||
"name": "notification_user_id_user_id_fk",
|
||||
"tableFrom": "notification",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_preferences": {
|
||||
"name": "notification_preferences",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_requests": {
|
||||
"name": "friend_requests",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"friend_accepted": {
|
||||
"name": "friend_accepted",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_liked": {
|
||||
"name": "find_liked",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_commented": {
|
||||
"name": "find_commented",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"push_enabled": {
|
||||
"name": "push_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_preferences_user_id_user_id_fk": {
|
||||
"name": "notification_preferences_user_id_user_id_fk",
|
||||
"tableFrom": "notification_preferences",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_subscription": {
|
||||
"name": "notification_subscription",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"p256dh_key": {
|
||||
"name": "p256dh_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"auth_key": {
|
||||
"name": "auth_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_subscription_user_id_user_id_fk": {
|
||||
"name": "notification_subscription_user_id_user_id_fk",
|
||||
"tableFrom": "notification_subscription",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"age": {
|
||||
"name": "age",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"profile_picture_url": {
|
||||
"name": "profile_picture_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"user_google_id_unique": {
|
||||
"name": "user_google_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"google_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
933
drizzle/meta/0009_snapshot.json
Normal file
@@ -0,0 +1,933 @@
|
||||
{
|
||||
"id": "30fb4a72-dd57-46c3-99e4-9e01f36acce0",
|
||||
"prevId": "5654d58b-23f8-48cb-9933-5ac32141b75e",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.find": {
|
||||
"name": "find",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"location_id": {
|
||||
"name": "location_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"rating_count": {
|
||||
"name": "rating_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_location_id_location_id_fk": {
|
||||
"name": "find_location_id_location_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "location",
|
||||
"columnsFrom": [
|
||||
"location_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_user_id_user_id_fk": {
|
||||
"name": "find_user_id_user_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_comment": {
|
||||
"name": "find_comment",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_comment_find_id_find_id_fk": {
|
||||
"name": "find_comment_find_id_find_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_comment_user_id_user_id_fk": {
|
||||
"name": "find_comment_user_id_user_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_like": {
|
||||
"name": "find_like",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_like_find_id_find_id_fk": {
|
||||
"name": "find_like_find_id_find_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_like_user_id_user_id_fk": {
|
||||
"name": "find_like_user_id_user_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_media": {
|
||||
"name": "find_media",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_url": {
|
||||
"name": "fallback_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_thumbnail_url": {
|
||||
"name": "fallback_thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"order_index": {
|
||||
"name": "order_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_media_find_id_find_id_fk": {
|
||||
"name": "find_media_find_id_find_id_fk",
|
||||
"tableFrom": "find_media",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_rating": {
|
||||
"name": "find_rating",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_rating_find_id_find_id_fk": {
|
||||
"name": "find_rating_find_id_find_id_fk",
|
||||
"tableFrom": "find_rating",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_rating_user_id_user_id_fk": {
|
||||
"name": "find_rating_user_id_user_id_fk",
|
||||
"tableFrom": "find_rating",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friendship": {
|
||||
"name": "friendship",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_id": {
|
||||
"name": "friend_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friendship_user_id_user_id_fk": {
|
||||
"name": "friendship_user_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friendship_friend_id_user_id_fk": {
|
||||
"name": "friendship_friend_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"friend_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.location": {
|
||||
"name": "location",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_name": {
|
||||
"name": "location_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"average_rating": {
|
||||
"name": "average_rating",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"rating_count": {
|
||||
"name": "rating_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"location_user_id_user_id_fk": {
|
||||
"name": "location_user_id_user_id_fk",
|
||||
"tableFrom": "location",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification": {
|
||||
"name": "notification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_read": {
|
||||
"name": "is_read",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_user_id_user_id_fk": {
|
||||
"name": "notification_user_id_user_id_fk",
|
||||
"tableFrom": "notification",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_preferences": {
|
||||
"name": "notification_preferences",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_requests": {
|
||||
"name": "friend_requests",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"friend_accepted": {
|
||||
"name": "friend_accepted",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_liked": {
|
||||
"name": "find_liked",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_commented": {
|
||||
"name": "find_commented",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"push_enabled": {
|
||||
"name": "push_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_preferences_user_id_user_id_fk": {
|
||||
"name": "notification_preferences_user_id_user_id_fk",
|
||||
"tableFrom": "notification_preferences",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_subscription": {
|
||||
"name": "notification_subscription",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"p256dh_key": {
|
||||
"name": "p256dh_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"auth_key": {
|
||||
"name": "auth_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_subscription_user_id_user_id_fk": {
|
||||
"name": "notification_subscription_user_id_user_id_fk",
|
||||
"tableFrom": "notification_subscription",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"age": {
|
||||
"name": "age",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"profile_picture_url": {
|
||||
"name": "profile_picture_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"user_google_id_unique": {
|
||||
"name": "user_google_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"google_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,34 @@
|
||||
"when": 1760631798851,
|
||||
"tag": "0005_rapid_warpath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1762428302491,
|
||||
"tag": "0006_strange_firebird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1762522687342,
|
||||
"tag": "0007_grey_dark_beast",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1765885558230,
|
||||
"tag": "0008_common_supreme_intelligence",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1765894394394,
|
||||
"tag": "0009_lazy_monster_badoon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
724
erd.svg
Normal file
@@ -0,0 +1,724 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.47.0 (20210316.0004)
|
||||
-->
|
||||
<!-- Title: dbml Pages: 1 -->
|
||||
<svg width="3268pt" height="5203pt"
|
||||
viewBox="0.00 0.00 3268.28 5203.18" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 5199.18)">
|
||||
<title>dbml</title>
|
||||
<!-- find -->
|
||||
<g id="find" class="node">
|
||||
<title>find</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="1353.34" cy="-4090.37" rx="439.23" ry="511.89"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1045.34,-4390.37 1045.34,-4450.37 1662.34,-4450.37 1662.34,-4390.37 1045.34,-4390.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4390.37 1045.34,-4450.37 1662.34,-4450.37 1662.34,-4390.37 1045.34,-4390.37"/>
|
||||
<text text-anchor="start" x="1265.8" y="-4411.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       find       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4330.37 1045.34,-4390.37 1662.34,-4390.37 1662.34,-4330.37 1045.34,-4330.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4330.37 1045.34,-4390.37 1662.34,-4390.37 1662.34,-4330.37 1045.34,-4330.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="1081.23" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="1560.67" y="-4351.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="1612.25" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.14" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4270.37 1045.34,-4330.37 1662.34,-4330.37 1662.34,-4270.37 1045.34,-4270.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4270.37 1045.34,-4330.37 1662.34,-4330.37 1662.34,-4270.37 1045.34,-4270.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4290.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">location_id    </text>
|
||||
<text text-anchor="start" x="1560.67" y="-4291.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="1612.25" y="-4291.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.14" y="-4291.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4210.37 1045.34,-4270.37 1662.34,-4270.37 1662.34,-4210.37 1045.34,-4210.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4210.37 1045.34,-4270.37 1662.34,-4270.37 1662.34,-4210.37 1045.34,-4210.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4230.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="1560.67" y="-4231.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="1612.25" y="-4231.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.14" y="-4231.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4150.37 1045.34,-4210.37 1662.34,-4210.37 1662.34,-4150.37 1045.34,-4150.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4150.37 1045.34,-4210.37 1662.34,-4210.37 1662.34,-4150.37 1045.34,-4150.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4170.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">title    </text>
|
||||
<text text-anchor="start" x="1560.67" y="-4171.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="1612.25" y="-4171.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.14" y="-4171.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4090.37 1045.34,-4150.37 1662.34,-4150.37 1662.34,-4090.37 1045.34,-4090.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4090.37 1045.34,-4150.37 1662.34,-4150.37 1662.34,-4090.37 1045.34,-4090.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4110.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">description    </text>
|
||||
<text text-anchor="start" x="1599.77" y="-4111.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4030.37 1045.34,-4090.37 1662.34,-4090.37 1662.34,-4030.37 1045.34,-4030.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-4030.37 1045.34,-4090.37 1662.34,-4090.37 1662.34,-4030.37 1045.34,-4030.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-4050.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">category    </text>
|
||||
<text text-anchor="start" x="1599.77" y="-4051.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3970.37 1045.34,-4030.37 1662.34,-4030.37 1662.34,-3970.37 1045.34,-3970.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-3970.37 1045.34,-4030.37 1662.34,-4030.37 1662.34,-3970.37 1045.34,-3970.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-3990.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating    </text>
|
||||
<text text-anchor="start" x="1553.54" y="-3991.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3910.37 1045.34,-3970.37 1662.34,-3970.37 1662.34,-3910.37 1045.34,-3910.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-3910.37 1045.34,-3970.37 1662.34,-3970.37 1662.34,-3910.37 1045.34,-3910.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-3930.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating_count    </text>
|
||||
<text text-anchor="start" x="1553.54" y="-3931.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3850.37 1045.34,-3910.37 1662.34,-3910.37 1662.34,-3850.37 1045.34,-3850.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-3850.37 1045.34,-3910.37 1662.34,-3910.37 1662.34,-3850.37 1045.34,-3850.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-3870.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_public    </text>
|
||||
<text text-anchor="start" x="1553.54" y="-3871.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3790.37 1045.34,-3850.37 1662.34,-3850.37 1662.34,-3790.37 1045.34,-3790.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-3790.37 1045.34,-3850.37 1662.34,-3850.37 1662.34,-3790.37 1045.34,-3790.37"/>
|
||||
<text text-anchor="start" x="1056.34" y="-3810.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="1251.33" y="-3811.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="1612.25" y="-3811.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.14" y="-3811.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3730.37 1045.34,-3790.37 1662.34,-3790.37 1662.34,-3730.37 1045.34,-3730.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1045.34,-3730.37 1045.34,-3790.37 1662.34,-3790.37 1662.34,-3730.37 1045.34,-3730.37"/>
|
||||
<text text-anchor="start" x="1056.01" y="-3750.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at    </text>
|
||||
<text text-anchor="start" x="1251.33" y="-3751.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="1612.26" y="-3751.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="1621.15" y="-3751.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1043.84,-3729.37 1043.84,-4451.37 1662.84,-4451.37 1662.84,-3729.37 1043.84,-3729.37"/>
|
||||
</g>
|
||||
<!-- location -->
|
||||
<g id="location" class="node">
|
||||
<title>location</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-3966.37" rx="433" ry="384.83"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-4176.37 1963.57,-4236.37 2571.57,-4236.37 2571.57,-4176.37 1963.57,-4176.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-4176.37 1963.57,-4236.37 2571.57,-4236.37 2571.57,-4176.37 1963.57,-4176.37"/>
|
||||
<text text-anchor="start" x="2150.19" y="-4197.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       location       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-4116.37 1963.57,-4176.37 2571.57,-4176.37 2571.57,-4116.37 1963.57,-4116.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-4116.37 1963.57,-4176.37 2571.57,-4176.37 2571.57,-4116.37 1963.57,-4116.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-4137.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="1999.46" y="-4137.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-4137.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-4137.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-4137.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-4056.37 1963.57,-4116.37 2571.57,-4116.37 2571.57,-4056.37 1963.57,-4056.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-4056.37 1963.57,-4116.37 2571.57,-4116.37 2571.57,-4056.37 1963.57,-4056.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-4076.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-4077.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-4077.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-4077.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3996.37 1963.57,-4056.37 2571.57,-4056.37 2571.57,-3996.37 1963.57,-3996.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3996.37 1963.57,-4056.37 2571.57,-4056.37 2571.57,-3996.37 1963.57,-3996.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-4016.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">latitude    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-4017.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-4017.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-4017.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3936.37 1963.57,-3996.37 2571.57,-3996.37 2571.57,-3936.37 1963.57,-3936.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3936.37 1963.57,-3996.37 2571.57,-3996.37 2571.57,-3936.37 1963.57,-3936.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-3956.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">longitude    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-3957.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-3957.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-3957.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3876.37 1963.57,-3936.37 2571.57,-3936.37 2571.57,-3876.37 1963.57,-3876.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3876.37 1963.57,-3936.37 2571.57,-3936.37 2571.57,-3876.37 1963.57,-3876.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-3896.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">location_name    </text>
|
||||
<text text-anchor="start" x="2508.99" y="-3897.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3816.37 1963.57,-3876.37 2571.57,-3876.37 2571.57,-3816.37 1963.57,-3816.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3816.37 1963.57,-3876.37 2571.57,-3876.37 2571.57,-3816.37 1963.57,-3816.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-3836.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">average_rating    </text>
|
||||
<text text-anchor="start" x="2462.76" y="-3837.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3756.37 1963.57,-3816.37 2571.57,-3816.37 2571.57,-3756.37 1963.57,-3756.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3756.37 1963.57,-3816.37 2571.57,-3816.37 2571.57,-3756.37 1963.57,-3756.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-3776.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating_count    </text>
|
||||
<text text-anchor="start" x="2462.76" y="-3777.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3696.37 1963.57,-3756.37 2571.57,-3756.37 2571.57,-3696.37 1963.57,-3696.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-3696.37 1963.57,-3756.37 2571.57,-3756.37 2571.57,-3696.37 1963.57,-3696.37"/>
|
||||
<text text-anchor="start" x="1974.2" y="-3716.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="2160.56" y="-3717.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2521.48" y="-3717.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-3717.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-3695.37 1962.57,-4237.37 2572.57,-4237.37 2572.57,-3695.37 1962.57,-3695.37"/>
|
||||
</g>
|
||||
<!-- find->location -->
|
||||
<!-- find->location -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>find:e->location:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M1663.34,-4300.37C1809.4,-4300.37 1812.91,-4153.5 1952.29,-4146.62"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1952.65,-4150.11 1962.57,-4146.37 1952.48,-4143.11 1952.65,-4150.11"/>
|
||||
<text text-anchor="middle" x="1953.67" y="-4155.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="1669.56" y="-4309.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- user -->
|
||||
<g id="user" class="node">
|
||||
<title>user</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="3001.48" cy="-3159.37" rx="258.6" ry="299.63"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="2820.48,-3309.37 2820.48,-3369.37 3182.48,-3369.37 3182.48,-3309.37 2820.48,-3309.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3309.37 2820.48,-3369.37 3182.48,-3369.37 3182.48,-3309.37 2820.48,-3309.37"/>
|
||||
<text text-anchor="start" x="2908.12" y="-3330.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       user       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3249.37 2820.48,-3309.37 3182.48,-3309.37 3182.48,-3249.37 2820.48,-3249.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3249.37 2820.48,-3309.37 3182.48,-3309.37 3182.48,-3249.37 2820.48,-3249.37"/>
|
||||
<text text-anchor="start" x="2831.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="2856.37" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="3080.82" y="-3270.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="3132.39" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="3141.28" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3189.37 2820.48,-3249.37 3182.48,-3249.37 3182.48,-3189.37 2820.48,-3189.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3189.37 2820.48,-3249.37 3182.48,-3249.37 3182.48,-3189.37 2820.48,-3189.37"/>
|
||||
<text text-anchor="start" x="2831.48" y="-3209.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">age    </text>
|
||||
<text text-anchor="start" x="3073.68" y="-3210.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3129.37 2820.48,-3189.37 3182.48,-3189.37 3182.48,-3129.37 2820.48,-3129.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3129.37 2820.48,-3189.37 3182.48,-3189.37 3182.48,-3129.37 2820.48,-3129.37"/>
|
||||
<text text-anchor="start" x="2831.48" y="-3149.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">username    </text>
|
||||
<text text-anchor="start" x="3080.82" y="-3150.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="3132.39" y="-3150.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="3141.28" y="-3150.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3069.37 2820.48,-3129.37 3182.48,-3129.37 3182.48,-3069.37 2820.48,-3069.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3069.37 2820.48,-3129.37 3182.48,-3129.37 3182.48,-3069.37 2820.48,-3069.37"/>
|
||||
<text text-anchor="start" x="2831.48" y="-3089.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">password_hash    </text>
|
||||
<text text-anchor="start" x="3119.91" y="-3090.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3009.37 2820.48,-3069.37 3182.48,-3069.37 3182.48,-3009.37 2820.48,-3009.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-3009.37 2820.48,-3069.37 3182.48,-3069.37 3182.48,-3009.37 2820.48,-3009.37"/>
|
||||
<text text-anchor="start" x="2831.48" y="-3029.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">google_id    </text>
|
||||
<text text-anchor="start" x="3119.91" y="-3030.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-2949.37 2820.48,-3009.37 3182.48,-3009.37 3182.48,-2949.37 2820.48,-2949.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="2820.48,-2949.37 2820.48,-3009.37 3182.48,-3009.37 3182.48,-2949.37 2820.48,-2949.37"/>
|
||||
<text text-anchor="start" x="2831.07" y="-2969.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">profile_picture_url    </text>
|
||||
<text text-anchor="start" x="3120.19" y="-2970.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="2819.48,-2948.37 2819.48,-3370.37 3183.48,-3370.37 3183.48,-2948.37 2819.48,-2948.37"/>
|
||||
</g>
|
||||
<!-- find->user -->
|
||||
<!-- find->user -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>find:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M1663.34,-4240.37C1890.69,-4240.37 1664.48,-308.85 1828.45,-151.37 1863.64,-117.57 2671.56,-117.5 2706.68,-151.37 2829.39,-269.73 2652.75,-3163 2809.7,-3275.97"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.39 2819.48,-3279.37 2811.18,-3272.78 2808.89,-3279.39"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="1669.56" y="-4249.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_comment -->
|
||||
<g id="find_comment" class="node">
|
||||
<title>find_comment</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-4895.37" rx="439.23" ry="299.63"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="131.11,-5045.37 131.11,-5105.37 748.11,-5105.37 748.11,-5045.37 131.11,-5045.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-5045.37 131.11,-5105.37 748.11,-5105.37 748.11,-5045.37 131.11,-5045.37"/>
|
||||
<text text-anchor="start" x="276.9" y="-5066.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       find_comment       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4985.37 131.11,-5045.37 748.11,-5045.37 748.11,-4985.37 131.11,-4985.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4985.37 131.11,-5045.37 748.11,-5045.37 748.11,-4985.37 131.11,-4985.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-5006.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="167" y="-5006.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="646.45" y="-5006.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-5006.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-5006.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4925.37 131.11,-4985.37 748.11,-4985.37 748.11,-4925.37 131.11,-4925.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4925.37 131.11,-4985.37 748.11,-4985.37 748.11,-4925.37 131.11,-4925.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-4945.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id    </text>
|
||||
<text text-anchor="start" x="646.45" y="-4946.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-4946.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-4946.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4865.37 131.11,-4925.37 748.11,-4925.37 748.11,-4865.37 131.11,-4865.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4865.37 131.11,-4925.37 748.11,-4925.37 748.11,-4865.37 131.11,-4865.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-4885.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="646.45" y="-4886.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-4886.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-4886.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4805.37 131.11,-4865.37 748.11,-4865.37 748.11,-4805.37 131.11,-4805.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4805.37 131.11,-4865.37 748.11,-4865.37 748.11,-4805.37 131.11,-4805.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-4825.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">content    </text>
|
||||
<text text-anchor="start" x="646.45" y="-4826.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-4826.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-4826.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4745.37 131.11,-4805.37 748.11,-4805.37 748.11,-4745.37 131.11,-4745.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4745.37 131.11,-4805.37 748.11,-4805.37 748.11,-4745.37 131.11,-4745.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-4765.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="337.1" y="-4766.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="698.02" y="-4766.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-4766.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4685.37 131.11,-4745.37 748.11,-4745.37 748.11,-4685.37 131.11,-4685.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-4685.37 131.11,-4745.37 748.11,-4745.37 748.11,-4685.37 131.11,-4685.37"/>
|
||||
<text text-anchor="start" x="141.78" y="-4705.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at    </text>
|
||||
<text text-anchor="start" x="337.11" y="-4706.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="698.03" y="-4706.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.92" y="-4706.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="129.61,-4684.37 129.61,-5106.37 748.61,-5106.37 748.61,-4684.37 129.61,-4684.37"/>
|
||||
</g>
|
||||
<!-- find_comment->find -->
|
||||
<!-- find_comment->find -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>find_comment:e->find:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-4955.37C1040.86,-4955.37 755.97,-4374.23 1034.2,-4360.61"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.43,-4364.11 1044.34,-4360.37 1034.26,-4357.11 1034.43,-4364.11"/>
|
||||
<text text-anchor="middle" x="1053.23" y="-4369.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="742.89" y="-4964.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_comment->user -->
|
||||
<!-- find_comment->user -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>find_comment:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-4895.37C1229.69,-4895.37 1460.73,-4959.1 1792.45,-4611.37 1843.36,-4558 1773.32,-4498.37 1828.45,-4449.37 1975.08,-4319.06 2567.77,-4498.87 2706.68,-4360.37 2875.21,-4192.34 2586.64,-3305.88 2809.21,-3279.95"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.69,-3283.43 2819.48,-3279.37 2809.3,-3276.44 2809.69,-3283.43"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="742.89" y="-4904.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_like -->
|
||||
<g id="find_like" class="node">
|
||||
<title>find_like</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-2872.37" rx="433" ry="214.92"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="135.11,-2962.37 135.11,-3022.37 743.11,-3022.37 743.11,-2962.37 135.11,-2962.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-2962.37 135.11,-3022.37 743.11,-3022.37 743.11,-2962.37 135.11,-2962.37"/>
|
||||
<text text-anchor="start" x="318.19" y="-2983.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       find_like       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2902.37 135.11,-2962.37 743.11,-2962.37 743.11,-2902.37 135.11,-2902.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-2902.37 135.11,-2962.37 743.11,-2962.37 743.11,-2902.37 135.11,-2902.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-2923.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="171" y="-2923.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="641.45" y="-2923.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-2923.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-2923.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2842.37 135.11,-2902.37 743.11,-2902.37 743.11,-2842.37 135.11,-2842.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-2842.37 135.11,-2902.37 743.11,-2902.37 743.11,-2842.37 135.11,-2842.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-2862.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id    </text>
|
||||
<text text-anchor="start" x="641.45" y="-2863.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-2863.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-2863.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2782.37 135.11,-2842.37 743.11,-2842.37 743.11,-2782.37 135.11,-2782.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-2782.37 135.11,-2842.37 743.11,-2842.37 743.11,-2782.37 135.11,-2782.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-2802.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="641.45" y="-2803.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-2803.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-2803.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2722.37 135.11,-2782.37 743.11,-2782.37 743.11,-2722.37 135.11,-2722.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-2722.37 135.11,-2782.37 743.11,-2782.37 743.11,-2722.37 135.11,-2722.37"/>
|
||||
<text text-anchor="start" x="145.74" y="-2742.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="332.11" y="-2743.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="693.03" y="-2743.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.92" y="-2743.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="134.11,-2721.37 134.11,-3023.37 744.11,-3023.37 744.11,-2721.37 134.11,-2721.37"/>
|
||||
</g>
|
||||
<!-- find_like->find -->
|
||||
<!-- find_like->find -->
|
||||
<g id="edge10" class="edge">
|
||||
<title>find_like:e->find:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-2872.37C1077.98,-2872.37 714.61,-4330.01 1034.19,-4359.9"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.19,-4363.41 1044.34,-4360.37 1034.51,-4356.41 1034.19,-4363.41"/>
|
||||
<text text-anchor="middle" x="1053.23" y="-4331.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="737.89" y="-2843.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_like->user -->
|
||||
<!-- find_like->user -->
|
||||
<g id="edge12" class="edge">
|
||||
<title>find_like:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-2812.37C1039.61,-2812.37 708.53,-370.52 914.23,-158.37 1053.03,-15.22 2563.23,67.13 2706.68,-71.37 2832.6,-192.94 2648.23,-3165.94 2809.93,-3276.22"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.56 2819.48,-3279.37 2811.08,-3272.91 2808.89,-3279.56"/>
|
||||
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="737.89" y="-2783.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_media -->
|
||||
<g id="find_media" class="node">
|
||||
<title>find_media</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-4150.37" rx="433" ry="427.19"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="135.11,-4390.37 135.11,-4450.37 743.11,-4450.37 743.11,-4390.37 135.11,-4390.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4390.37 135.11,-4450.37 743.11,-4450.37 743.11,-4390.37 135.11,-4390.37"/>
|
||||
<text text-anchor="start" x="298.62" y="-4411.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       find_media       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4330.37 135.11,-4390.37 743.11,-4390.37 743.11,-4330.37 135.11,-4330.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4330.37 135.11,-4390.37 743.11,-4390.37 743.11,-4330.37 135.11,-4330.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="171" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="641.45" y="-4351.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4270.37 135.11,-4330.37 743.11,-4330.37 743.11,-4270.37 135.11,-4270.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4270.37 135.11,-4330.37 743.11,-4330.37 743.11,-4270.37 135.11,-4270.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4290.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id    </text>
|
||||
<text text-anchor="start" x="641.45" y="-4291.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-4291.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-4291.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4210.37 135.11,-4270.37 743.11,-4270.37 743.11,-4210.37 135.11,-4210.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4210.37 135.11,-4270.37 743.11,-4270.37 743.11,-4210.37 135.11,-4210.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4230.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">type    </text>
|
||||
<text text-anchor="start" x="641.45" y="-4231.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-4231.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-4231.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4150.37 135.11,-4210.37 743.11,-4210.37 743.11,-4150.37 135.11,-4150.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4150.37 135.11,-4210.37 743.11,-4210.37 743.11,-4150.37 135.11,-4150.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4170.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">url    </text>
|
||||
<text text-anchor="start" x="641.45" y="-4171.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="693.02" y="-4171.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.91" y="-4171.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4090.37 135.11,-4150.37 743.11,-4150.37 743.11,-4090.37 135.11,-4090.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4090.37 135.11,-4150.37 743.11,-4150.37 743.11,-4090.37 135.11,-4090.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4110.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">thumbnail_url    </text>
|
||||
<text text-anchor="start" x="680.54" y="-4111.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4030.37 135.11,-4090.37 743.11,-4090.37 743.11,-4030.37 135.11,-4030.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-4030.37 135.11,-4090.37 743.11,-4090.37 743.11,-4030.37 135.11,-4030.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-4050.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">fallback_url    </text>
|
||||
<text text-anchor="start" x="680.54" y="-4051.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3970.37 135.11,-4030.37 743.11,-4030.37 743.11,-3970.37 135.11,-3970.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-3970.37 135.11,-4030.37 743.11,-4030.37 743.11,-3970.37 135.11,-3970.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-3990.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">fallback_thumbnail_url    </text>
|
||||
<text text-anchor="start" x="680.54" y="-3991.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3910.37 135.11,-3970.37 743.11,-3970.37 743.11,-3910.37 135.11,-3910.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-3910.37 135.11,-3970.37 743.11,-3970.37 743.11,-3910.37 135.11,-3910.37"/>
|
||||
<text text-anchor="start" x="146.11" y="-3930.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">order_index    </text>
|
||||
<text text-anchor="start" x="634.31" y="-3931.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3850.37 135.11,-3910.37 743.11,-3910.37 743.11,-3850.37 135.11,-3850.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="135.11,-3850.37 135.11,-3910.37 743.11,-3910.37 743.11,-3850.37 135.11,-3850.37"/>
|
||||
<text text-anchor="start" x="145.74" y="-3870.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="332.11" y="-3871.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="693.03" y="-3871.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="701.92" y="-3871.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="134.11,-3849.37 134.11,-4451.37 744.11,-4451.37 744.11,-3849.37 134.11,-3849.37"/>
|
||||
</g>
|
||||
<!-- find_media->find -->
|
||||
<!-- find_media->find -->
|
||||
<g id="edge14" class="edge">
|
||||
<title>find_media:e->find:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-4300.37C876.73,-4300.37 906.77,-4357.36 1034.18,-4360.26"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.3,-4363.76 1044.34,-4360.37 1034.38,-4356.76 1034.3,-4363.76"/>
|
||||
<text text-anchor="middle" x="1035.45" y="-4369.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="737.89" y="-4309.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_rating -->
|
||||
<g id="find_rating" class="node">
|
||||
<title>find_rating</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-3405.37" rx="439.23" ry="299.63"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="131.11,-3555.37 131.11,-3615.37 748.11,-3615.37 748.11,-3555.37 131.11,-3555.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3555.37 131.11,-3615.37 748.11,-3615.37 748.11,-3555.37 131.11,-3555.37"/>
|
||||
<text text-anchor="start" x="302.68" y="-3576.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       find_rating       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3495.37 131.11,-3555.37 748.11,-3555.37 748.11,-3495.37 131.11,-3495.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3495.37 131.11,-3555.37 748.11,-3555.37 748.11,-3495.37 131.11,-3495.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-3516.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="167" y="-3516.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="646.45" y="-3516.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-3516.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-3516.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3435.37 131.11,-3495.37 748.11,-3495.37 748.11,-3435.37 131.11,-3435.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3435.37 131.11,-3495.37 748.11,-3495.37 748.11,-3435.37 131.11,-3435.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-3455.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id    </text>
|
||||
<text text-anchor="start" x="646.45" y="-3456.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-3456.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-3456.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3375.37 131.11,-3435.37 748.11,-3435.37 748.11,-3375.37 131.11,-3375.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3375.37 131.11,-3435.37 748.11,-3435.37 748.11,-3375.37 131.11,-3375.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-3395.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="646.45" y="-3396.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="698.02" y="-3396.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-3396.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3315.37 131.11,-3375.37 748.11,-3375.37 748.11,-3315.37 131.11,-3315.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3315.37 131.11,-3375.37 748.11,-3375.37 748.11,-3315.37 131.11,-3315.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-3335.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating    </text>
|
||||
<text text-anchor="start" x="600.22" y="-3336.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
|
||||
<text text-anchor="start" x="698.02" y="-3336.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-3336.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3255.37 131.11,-3315.37 748.11,-3315.37 748.11,-3255.37 131.11,-3255.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3255.37 131.11,-3315.37 748.11,-3315.37 748.11,-3255.37 131.11,-3255.37"/>
|
||||
<text text-anchor="start" x="142.11" y="-3275.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="337.1" y="-3276.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="698.02" y="-3276.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.91" y="-3276.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3195.37 131.11,-3255.37 748.11,-3255.37 748.11,-3195.37 131.11,-3195.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="131.11,-3195.37 131.11,-3255.37 748.11,-3255.37 748.11,-3195.37 131.11,-3195.37"/>
|
||||
<text text-anchor="start" x="141.78" y="-3215.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at    </text>
|
||||
<text text-anchor="start" x="337.11" y="-3216.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="698.03" y="-3216.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="706.92" y="-3216.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="129.61,-3194.37 129.61,-3616.37 748.61,-3616.37 748.61,-3194.37 129.61,-3194.37"/>
|
||||
</g>
|
||||
<!-- find_rating->find -->
|
||||
<!-- find_rating->find -->
|
||||
<g id="edge16" class="edge">
|
||||
<title>find_rating:e->find:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-3465.37C873.77,-3465.37 833.73,-3597.92 878.23,-3714.37 982.81,-3988.04 753.38,-4351.73 1034.14,-4360.22"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.29,-4363.72 1044.34,-4360.37 1034.39,-4356.72 1034.29,-4363.72"/>
|
||||
<text text-anchor="middle" x="1035.45" y="-4331.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="742.89" y="-3436.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- find_rating->user -->
|
||||
<!-- find_rating->user -->
|
||||
<g id="edge18" class="edge">
|
||||
<title>find_rating:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-3405.37C2285.06,-3405.37 588.06,-1028.22 1828.45,-122.37 1907.26,-64.82 2636.46,-54.61 2706.68,-122.37 2830.51,-241.85 2651.24,-3161.92 2809.61,-3275.94"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.39 2819.48,-3279.37 2811.18,-3272.78 2808.89,-3279.39"/>
|
||||
<text text-anchor="middle" x="2810.59" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="742.89" y="-3376.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- friendship -->
|
||||
<g id="friendship" class="node">
|
||||
<title>friendship</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-417.37" rx="433" ry="257.27"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-537.37 1963.57,-597.37 2571.57,-597.37 2571.57,-537.37 1963.57,-537.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-537.37 1963.57,-597.37 2571.57,-597.37 2571.57,-537.37 1963.57,-537.37"/>
|
||||
<text text-anchor="start" x="2135.97" y="-558.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       friendship       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-477.37 1963.57,-537.37 2571.57,-537.37 2571.57,-477.37 1963.57,-477.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-477.37 1963.57,-537.37 2571.57,-537.37 2571.57,-477.37 1963.57,-477.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-498.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="1999.46" y="-498.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-498.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-498.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-498.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-417.37 1963.57,-477.37 2571.57,-477.37 2571.57,-417.37 1963.57,-417.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-417.37 1963.57,-477.37 2571.57,-477.37 2571.57,-417.37 1963.57,-417.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-437.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-438.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-438.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-438.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-357.37 1963.57,-417.37 2571.57,-417.37 2571.57,-357.37 1963.57,-357.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-357.37 1963.57,-417.37 2571.57,-417.37 2571.57,-357.37 1963.57,-357.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-377.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_id    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-378.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-378.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-378.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-297.37 1963.57,-357.37 2571.57,-357.37 2571.57,-297.37 1963.57,-297.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-297.37 1963.57,-357.37 2571.57,-357.37 2571.57,-297.37 1963.57,-297.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-317.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">status    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-318.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-318.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-318.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-237.37 1963.57,-297.37 2571.57,-297.37 2571.57,-237.37 1963.57,-237.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-237.37 1963.57,-297.37 2571.57,-297.37 2571.57,-237.37 1963.57,-237.37"/>
|
||||
<text text-anchor="start" x="1974.2" y="-257.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="2160.56" y="-258.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2521.48" y="-258.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-258.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-236.37 1962.57,-598.37 2572.57,-598.37 2572.57,-236.37 1962.57,-236.37"/>
|
||||
</g>
|
||||
<!-- friendship->user -->
|
||||
<!-- friendship->user -->
|
||||
<g id="edge20" class="edge">
|
||||
<title>friendship:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-447.37C2693.21,-447.37 2673.33,-567.43 2706.68,-683.37 2745.69,-818.99 2678.28,-3170.15 2810.08,-3275.7"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279 2819.48,-3279.37 2811.43,-3272.48 2808.89,-3279"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2578.79" y="-418.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- friendship->user -->
|
||||
<!-- friendship->user -->
|
||||
<g id="edge22" class="edge">
|
||||
<title>friendship:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-387.37C2717,-387.37 2673.13,-542.89 2706.68,-683.37 2739.46,-820.63 2678,-3170.23 2810.07,-3275.71"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.01 2819.48,-3279.37 2811.43,-3272.48 2808.89,-3279.01"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2578.79" y="-358.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- location->user -->
|
||||
<!-- location->user -->
|
||||
<g id="edge24" class="edge">
|
||||
<title>location:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-4086.37C2663.04,-4086.37 2721.49,-3335.11 2809.72,-3282.29"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2810.9,-3285.59 2819.48,-3279.37 2808.9,-3278.88 2810.9,-3285.59"/>
|
||||
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2578.79" y="-4095.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- notification -->
|
||||
<g id="notification" class="node">
|
||||
<title>notification</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-2312.37" rx="433" ry="384.83"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-2522.37 1963.57,-2582.37 2571.57,-2582.37 2571.57,-2522.37 1963.57,-2522.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2522.37 1963.57,-2582.37 2571.57,-2582.37 2571.57,-2522.37 1963.57,-2522.37"/>
|
||||
<text text-anchor="start" x="2128.85" y="-2543.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       notification       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2462.37 1963.57,-2522.37 2571.57,-2522.37 2571.57,-2462.37 1963.57,-2462.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2462.37 1963.57,-2522.37 2571.57,-2522.37 2571.57,-2462.37 1963.57,-2462.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2483.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="1999.46" y="-2483.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-2483.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2483.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2483.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2402.37 1963.57,-2462.37 2571.57,-2462.37 2571.57,-2402.37 1963.57,-2402.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2402.37 1963.57,-2462.37 2571.57,-2462.37 2571.57,-2402.37 1963.57,-2402.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2422.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-2423.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2423.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2423.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2342.37 1963.57,-2402.37 2571.57,-2402.37 2571.57,-2342.37 1963.57,-2342.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2342.37 1963.57,-2402.37 2571.57,-2402.37 2571.57,-2342.37 1963.57,-2342.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2362.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">type    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-2363.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2363.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2363.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2282.37 1963.57,-2342.37 2571.57,-2342.37 2571.57,-2282.37 1963.57,-2282.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2282.37 1963.57,-2342.37 2571.57,-2342.37 2571.57,-2282.37 1963.57,-2282.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2302.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">title    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-2303.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2303.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2303.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2222.37 1963.57,-2282.37 2571.57,-2282.37 2571.57,-2222.37 1963.57,-2222.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2222.37 1963.57,-2282.37 2571.57,-2282.37 2571.57,-2222.37 1963.57,-2222.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2242.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">message    </text>
|
||||
<text text-anchor="start" x="2469.9" y="-2243.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2243.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2243.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2162.37 1963.57,-2222.37 2571.57,-2222.37 2571.57,-2162.37 1963.57,-2162.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2162.37 1963.57,-2222.37 2571.57,-2222.37 2571.57,-2162.37 1963.57,-2162.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2182.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">data    </text>
|
||||
<text text-anchor="start" x="2484.1" y="-2183.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">jsonb</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2102.37 1963.57,-2162.37 2571.57,-2162.37 2571.57,-2102.37 1963.57,-2102.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2102.37 1963.57,-2162.37 2571.57,-2162.37 2571.57,-2102.37 1963.57,-2102.37"/>
|
||||
<text text-anchor="start" x="1974.57" y="-2122.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_read    </text>
|
||||
<text text-anchor="start" x="2446.73" y="-2123.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2042.37 1963.57,-2102.37 2571.57,-2102.37 2571.57,-2042.37 1963.57,-2042.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1963.57,-2042.37 1963.57,-2102.37 2571.57,-2102.37 2571.57,-2042.37 1963.57,-2042.37"/>
|
||||
<text text-anchor="start" x="1974.2" y="-2062.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="2160.56" y="-2063.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2521.48" y="-2063.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2530.37" y="-2063.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-2041.37 1962.57,-2583.37 2572.57,-2583.37 2572.57,-2041.37 1962.57,-2041.37"/>
|
||||
</g>
|
||||
<!-- notification->user -->
|
||||
<!-- notification->user -->
|
||||
<g id="edge26" class="edge">
|
||||
<title>notification:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-2432.37C2707.75,-2432.37 2663.47,-2577.28 2706.68,-2705.37 2747.09,-2825.18 2692.62,-3254.96 2809.23,-3278.37"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.19,-3281.89 2819.48,-3279.37 2809.87,-3274.92 2809.19,-3281.89"/>
|
||||
<text text-anchor="middle" x="2810.59" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2578.79" y="-2403.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- notification_preferences -->
|
||||
<g id="notification_preferences" class="node">
|
||||
<title>notification_preferences</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-3099.37" rx="439.23" ry="384.83"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1959.57,-3309.37 1959.57,-3369.37 2576.57,-3369.37 2576.57,-3309.37 1959.57,-3309.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3309.37 1959.57,-3369.37 2576.57,-3369.37 2576.57,-3309.37 1959.57,-3309.37"/>
|
||||
<text text-anchor="start" x="2035.99" y="-3330.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       notification_preferences       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3249.37 1959.57,-3309.37 2576.57,-3309.37 2576.57,-3249.37 1959.57,-3249.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3249.37 1959.57,-3309.37 2576.57,-3309.37 2576.57,-3249.37 1959.57,-3249.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">user_id</text>
|
||||
<text text-anchor="start" x="2075.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-3270.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3189.37 1959.57,-3249.37 2576.57,-3249.37 2576.57,-3189.37 1959.57,-3189.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3189.37 1959.57,-3249.37 2576.57,-3249.37 2576.57,-3189.37 1959.57,-3189.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-3209.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_requests    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-3210.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3129.37 1959.57,-3189.37 2576.57,-3189.37 2576.57,-3129.37 1959.57,-3129.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3129.37 1959.57,-3189.37 2576.57,-3189.37 2576.57,-3129.37 1959.57,-3129.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-3149.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_accepted    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-3150.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3069.37 1959.57,-3129.37 2576.57,-3129.37 2576.57,-3069.37 1959.57,-3069.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3069.37 1959.57,-3129.37 2576.57,-3129.37 2576.57,-3069.37 1959.57,-3069.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-3089.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_liked    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-3090.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3009.37 1959.57,-3069.37 2576.57,-3069.37 2576.57,-3009.37 1959.57,-3009.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-3009.37 1959.57,-3069.37 2576.57,-3069.37 2576.57,-3009.37 1959.57,-3009.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-3029.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_commented    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-3030.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2949.37 1959.57,-3009.37 2576.57,-3009.37 2576.57,-2949.37 1959.57,-2949.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-2949.37 1959.57,-3009.37 2576.57,-3009.37 2576.57,-2949.37 1959.57,-2949.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-2969.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">push_enabled    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-2970.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2889.37 1959.57,-2949.37 2576.57,-2949.37 2576.57,-2889.37 1959.57,-2889.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-2889.37 1959.57,-2949.37 2576.57,-2949.37 2576.57,-2889.37 1959.57,-2889.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-2909.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="2165.55" y="-2910.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2526.48" y="-2910.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-2910.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2829.37 1959.57,-2889.37 2576.57,-2889.37 2576.57,-2829.37 1959.57,-2829.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-2829.37 1959.57,-2889.37 2576.57,-2889.37 2576.57,-2829.37 1959.57,-2829.37"/>
|
||||
<text text-anchor="start" x="1970.23" y="-2849.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at    </text>
|
||||
<text text-anchor="start" x="2165.56" y="-2850.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2526.48" y="-2850.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-2850.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1958.07,-2828.37 1958.07,-3370.37 2577.07,-3370.37 2577.07,-2828.37 1958.07,-2828.37"/>
|
||||
</g>
|
||||
<!-- notification_preferences->user -->
|
||||
<!-- notification_preferences->user -->
|
||||
<g id="edge28" class="edge">
|
||||
<title>notification_preferences:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2577.57,-3279.37C2681.62,-3279.37 2710.15,-3279.37 2809.33,-3279.37"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.48,-3282.87 2819.48,-3279.37 2809.48,-3275.87 2809.48,-3282.87"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2583.79" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- notification_subscription -->
|
||||
<g id="notification_subscription" class="node">
|
||||
<title>notification_subscription</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-1119.37" rx="439.23" ry="427.19"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1959.57,-1359.37 1959.57,-1419.37 2576.57,-1419.37 2576.57,-1359.37 1959.57,-1359.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1359.37 1959.57,-1419.37 2576.57,-1419.37 2576.57,-1359.37 1959.57,-1359.37"/>
|
||||
<text text-anchor="start" x="2035.11" y="-1380.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       notification_subscription       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1299.37 1959.57,-1359.37 2576.57,-1359.37 2576.57,-1299.37 1959.57,-1299.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1299.37 1959.57,-1359.37 2576.57,-1359.37 2576.57,-1299.37 1959.57,-1299.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1320.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="1995.46" y="-1320.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-1320.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-1320.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-1320.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1239.37 1959.57,-1299.37 2576.57,-1299.37 2576.57,-1239.37 1959.57,-1239.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1239.37 1959.57,-1299.37 2576.57,-1299.37 2576.57,-1239.37 1959.57,-1239.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1259.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-1260.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-1260.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-1260.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1179.37 1959.57,-1239.37 2576.57,-1239.37 2576.57,-1179.37 1959.57,-1179.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1179.37 1959.57,-1239.37 2576.57,-1239.37 2576.57,-1179.37 1959.57,-1179.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1199.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">endpoint    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-1200.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-1200.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-1200.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1119.37 1959.57,-1179.37 2576.57,-1179.37 2576.57,-1119.37 1959.57,-1119.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1119.37 1959.57,-1179.37 2576.57,-1179.37 2576.57,-1119.37 1959.57,-1119.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1139.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">p256dh_key    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-1140.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-1140.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-1140.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1059.37 1959.57,-1119.37 2576.57,-1119.37 2576.57,-1059.37 1959.57,-1059.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-1059.37 1959.57,-1119.37 2576.57,-1119.37 2576.57,-1059.37 1959.57,-1059.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1079.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">auth_key    </text>
|
||||
<text text-anchor="start" x="2474.9" y="-1080.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2526.48" y="-1080.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-1080.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-999.37 1959.57,-1059.37 2576.57,-1059.37 2576.57,-999.37 1959.57,-999.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-999.37 1959.57,-1059.37 2576.57,-1059.37 2576.57,-999.37 1959.57,-999.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-1019.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_agent    </text>
|
||||
<text text-anchor="start" x="2513.99" y="-1020.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-939.37 1959.57,-999.37 2576.57,-999.37 2576.57,-939.37 1959.57,-939.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-939.37 1959.57,-999.37 2576.57,-999.37 2576.57,-939.37 1959.57,-939.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-959.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_active    </text>
|
||||
<text text-anchor="start" x="2451.73" y="-960.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-879.37 1959.57,-939.37 2576.57,-939.37 2576.57,-879.37 1959.57,-879.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-879.37 1959.57,-939.37 2576.57,-939.37 2576.57,-879.37 1959.57,-879.37"/>
|
||||
<text text-anchor="start" x="1970.57" y="-899.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at    </text>
|
||||
<text text-anchor="start" x="2165.55" y="-900.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2526.48" y="-900.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-900.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-819.37 1959.57,-879.37 2576.57,-879.37 2576.57,-819.37 1959.57,-819.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1959.57,-819.37 1959.57,-879.37 2576.57,-879.37 2576.57,-819.37 1959.57,-819.37"/>
|
||||
<text text-anchor="start" x="1970.23" y="-839.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at    </text>
|
||||
<text text-anchor="start" x="2165.56" y="-840.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2526.48" y="-840.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2535.37" y="-840.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1958.07,-818.37 1958.07,-1420.37 2577.07,-1420.37 2577.07,-818.37 1958.07,-818.37"/>
|
||||
</g>
|
||||
<!-- notification_subscription->user -->
|
||||
<!-- notification_subscription->user -->
|
||||
<g id="edge30" class="edge">
|
||||
<title>notification_subscription:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2577.57,-1269.37C2717.03,-1269.37 2672.89,-1420.06 2706.68,-1555.37 2752.35,-1738.25 2632,-3224.25 2809.38,-3277.88"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.08,-3281.37 2819.48,-3279.37 2810.1,-3274.45 2809.08,-3281.37"/>
|
||||
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2583.79" y="-1240.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
<!-- session -->
|
||||
<g id="session" class="node">
|
||||
<title>session</title>
|
||||
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-1737.37" rx="430.76" ry="172.57"/>
|
||||
<polygon fill="#1d71b8" stroke="transparent" points="1965.57,-1797.37 1965.57,-1857.37 2570.57,-1857.37 2570.57,-1797.37 1965.57,-1797.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1965.57,-1797.37 1965.57,-1857.37 2570.57,-1857.37 2570.57,-1797.37 1965.57,-1797.37"/>
|
||||
<text text-anchor="start" x="2151.58" y="-1818.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff">       session       </text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1737.37 1965.57,-1797.37 2570.57,-1797.37 2570.57,-1737.37 1965.57,-1737.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1965.57,-1737.37 1965.57,-1797.37 2570.57,-1797.37 2570.57,-1737.37 1965.57,-1737.37"/>
|
||||
<text text-anchor="start" x="1976.57" y="-1758.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
|
||||
<text text-anchor="start" x="2001.46" y="-1758.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">    </text>
|
||||
<text text-anchor="start" x="2468.9" y="-1758.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2520.48" y="-1758.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2529.37" y="-1758.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1677.37 1965.57,-1737.37 2570.57,-1737.37 2570.57,-1677.37 1965.57,-1677.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1965.57,-1677.37 1965.57,-1737.37 2570.57,-1737.37 2570.57,-1677.37 1965.57,-1677.37"/>
|
||||
<text text-anchor="start" x="1976.57" y="-1697.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id    </text>
|
||||
<text text-anchor="start" x="2468.9" y="-1698.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
|
||||
<text text-anchor="start" x="2520.48" y="-1698.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2529.37" y="-1698.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1617.37 1965.57,-1677.37 2570.57,-1677.37 2570.57,-1617.37 1965.57,-1617.37"/>
|
||||
<polygon fill="none" stroke="#29235c" points="1965.57,-1617.37 1965.57,-1677.37 2570.57,-1677.37 2570.57,-1617.37 1965.57,-1617.37"/>
|
||||
<text text-anchor="start" x="1976.49" y="-1637.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">expires_at    </text>
|
||||
<text text-anchor="start" x="2159.56" y="-1638.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
|
||||
<text text-anchor="start" x="2520.48" y="-1638.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
|
||||
<text text-anchor="start" x="2529.37" y="-1638.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
|
||||
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1964.07,-1616.37 1964.07,-1858.37 2571.07,-1858.37 2571.07,-1616.37 1964.07,-1616.37"/>
|
||||
</g>
|
||||
<!-- session->user -->
|
||||
<!-- session->user -->
|
||||
<g id="edge32" class="edge">
|
||||
<title>session:e->user:w</title>
|
||||
<path fill="none" stroke="#29235c" stroke-width="3" d="M2571.57,-1707.37C2682.92,-1707.37 2671,-1812.88 2706.68,-1918.37 2754.2,-2058.88 2671.6,-3225.23 2809.61,-3277.55"/>
|
||||
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.01,-3281 2819.48,-3279.37 2810.28,-3274.12 2809.01,-3281"/>
|
||||
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
|
||||
<text text-anchor="middle" x="2577.79" y="-1678.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 86 KiB |
@@ -25,7 +25,17 @@ export default defineConfig(
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
'no-undef': 'off',
|
||||
// Disable no-navigation-without-resolve as we're using resolveRoute from $app/paths
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
// Allow unused vars that start with underscore
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# Serengo Finds Feature Implementation Log
|
||||
|
||||
## Project Overview
|
||||
|
||||
Serengo is a location-based social discovery platform where users can save, share, and discover memorable places with media, reviews, and precise location data.
|
||||
|
||||
## Current Status: Phase 2A, 2C & 2D Complete + UI Integration ✅
|
||||
|
||||
### What Serengo Currently Has:
|
||||
|
||||
- Complete finds creation with photo uploads and location data
|
||||
- Interactive map with find markers and detailed previews
|
||||
- Responsive design with map/list view toggle
|
||||
- **NEW**: Modern WebP image processing with JPEG fallbacks
|
||||
- **NEW**: Full video support with custom VideoPlayer component
|
||||
- **NEW**: Like/unlike system with optimistic UI updates
|
||||
- **NEW**: Complete friends & privacy system with friend requests and filtered feeds
|
||||
- R2 storage integration with enhanced media processing
|
||||
- Full database schema with proper relationships and social features
|
||||
- Type-safe API endpoints and error handling
|
||||
- Mobile-optimized UI with floating action button
|
||||
|
||||
### Production Ready Features:
|
||||
|
||||
- Create/view finds with photos, descriptions, and categories
|
||||
- Location-based filtering and discovery
|
||||
- Media carousel with navigation (supports images and videos)
|
||||
- **NEW**: Video playback with custom controls and fullscreen support
|
||||
- **NEW**: Like/unlike finds with real-time count updates
|
||||
- **NEW**: Animated like buttons with heart animations
|
||||
- **NEW**: Friend request system with send/accept/decline functionality
|
||||
- **NEW**: Friends management page with user search and relationship status
|
||||
- **NEW**: Privacy-aware find feeds with friend-specific visibility filters
|
||||
- Share functionality with clipboard copy
|
||||
- Real-time map markers with click-to-preview
|
||||
- Grid layout for find browsing
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 Implementation Plan (Updated October 14, 2025)
|
||||
|
||||
### Technical Requirements & Standards:
|
||||
|
||||
- **Media Formats**: Use modern WebP for images, WebM/AV1 for videos
|
||||
- **UI Components**: Leverage existing SHADCN components for consistency
|
||||
- **Code Quality**: Follow Svelte 5 best practices with clean, reusable components
|
||||
- **POI Search**: Integrate Google Maps Places API for location search
|
||||
- **Type Safety**: Maintain strict TypeScript throughout
|
||||
|
||||
### Phase 2A: Modern Media Support ✅ COMPLETE
|
||||
|
||||
**Goal**: Upgrade to modern file formats and video support
|
||||
|
||||
**Completed Tasks:**
|
||||
|
||||
- [x] Update media processor to output WebP images (with JPEG fallback)
|
||||
- [x] Implement MP4 video processing with thumbnail generation
|
||||
- [x] Create reusable VideoPlayer component using SHADCN
|
||||
- [x] Enhanced database schema with fallback URL support
|
||||
- [x] Optimize compression settings for web delivery
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
- Updated `media-processor.ts` to generate both WebP and JPEG versions
|
||||
- Enhanced `findMedia` table with `fallbackUrl` and `fallbackThumbnailUrl` fields
|
||||
- Created `VideoPlayer.svelte` with custom controls, progress bar, and fullscreen support
|
||||
- Added video placeholder SVG for consistent UI
|
||||
- Maintained backward compatibility with existing media
|
||||
|
||||
**Actual Effort**: ~12 hours
|
||||
|
||||
### Phase 2B: Enhanced Location & POI Search (Priority: High)
|
||||
|
||||
**Goal**: Google Maps integration for better location discovery
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- [ ] Integrate Google Maps Places API for POI search
|
||||
- [ ] Create location search component with autocomplete
|
||||
- [ ] Add "Search nearby" functionality in CreateFindModal
|
||||
- [ ] Implement reverse geocoding for address display
|
||||
- [ ] Add place details (hours, ratings, etc.) from Google Places
|
||||
|
||||
**Estimated Effort**: 12-15 hours
|
||||
|
||||
### Phase 2C: Social Interactions ✅ COMPLETE
|
||||
|
||||
**Goal**: Like system and user engagement
|
||||
|
||||
**Completed Tasks:**
|
||||
|
||||
- [x] Implement like/unlike API using existing findLike table
|
||||
- [x] Create reusable LikeButton component with animations
|
||||
- [x] Add like counts and user's liked status to find queries
|
||||
- [x] Add optimistic UI updates for instant feedback
|
||||
- [x] **COMPLETED**: Full UI integration into FindCard and FindPreview components
|
||||
- [x] **COMPLETED**: Updated all data interfaces to support like information
|
||||
- [x] **COMPLETED**: Enhanced media carousel with VideoPlayer component integration
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
- Created `/api/finds/[findId]/like` endpoints for POST (like) and DELETE (unlike)
|
||||
- Built `LikeButton.svelte` with optimistic UI updates and heart animations
|
||||
- Enhanced find queries to include like counts and user's liked status via SQL aggregation
|
||||
- Integrated LikeButton into both FindCard (list view) and FindPreview (modal view)
|
||||
- Updated VideoPlayer usage throughout the application for consistent video playback
|
||||
- Maintained type safety across all interfaces and data flows
|
||||
|
||||
**Future Task:**
|
||||
|
||||
- [ ] Build "My Liked Finds" collection page (moved to Phase 2G)
|
||||
|
||||
**Actual Effort**: ~15 hours (including UI integration)
|
||||
|
||||
### Phase 2D: Friends & Privacy System ✅ COMPLETE
|
||||
|
||||
**Goal**: Social connections and privacy controls
|
||||
|
||||
**Completed Tasks:**
|
||||
|
||||
- [x] Build friend request system (send/accept/decline)
|
||||
- [x] Create Friends management page using SHADCN components
|
||||
- [x] Implement friend search with user suggestions
|
||||
- [x] Update find privacy logic to respect friendships
|
||||
- [x] Add friend-specific find visibility filters
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
- Created comprehensive friends API with `/api/friends` and `/api/friends/[friendshipId]` endpoints
|
||||
- Built `/api/users` endpoint for user search with friendship status integration
|
||||
- Developed complete Friends management page (`/routes/friends/`) with tabs for:
|
||||
- Friends list with remove functionality
|
||||
- Friend requests (received/sent) with accept/decline actions
|
||||
- User search with friend request sending capabilities
|
||||
- Enhanced finds API to support friend-based privacy filtering with `includeFriends` parameter
|
||||
- Created `FindsFilter.svelte` component with filter options:
|
||||
- All Finds (public, friends, and user's finds)
|
||||
- Public Only (publicly visible finds)
|
||||
- Friends Only (finds from friends)
|
||||
- My Finds (user's own finds)
|
||||
- Updated main page with integrated filter dropdown and real-time filtering
|
||||
- Enhanced ProfilePanel with Friends navigation link
|
||||
- Maintained type safety and error handling throughout all implementations
|
||||
|
||||
**Technical Architecture:**
|
||||
|
||||
- Leveraged existing `friendship` table schema without modifications
|
||||
- Used SHADCN components (Cards, Badges, Avatars, Buttons, Dropdowns) for consistent UI
|
||||
- Implemented proper authentication and authorization on all endpoints
|
||||
- Added comprehensive error handling with descriptive user feedback
|
||||
- Followed Svelte 5 patterns with `$state`, `$derived`, and `$props` runes
|
||||
|
||||
**Actual Effort**: ~22 hours
|
||||
|
||||
### Phase 2E: Advanced Filtering & Discovery (Priority: Medium)
|
||||
|
||||
**Goal**: Better find discovery and organization
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- [ ] Create FilterPanel component with category/distance/date filters
|
||||
- [ ] Implement text search through find titles/descriptions
|
||||
- [ ] Add sort options (recent, popular, nearest)
|
||||
- [ ] Build infinite scroll for find feeds
|
||||
- [ ] Add "Similar finds nearby" recommendations
|
||||
|
||||
**Estimated Effort**: 15-18 hours
|
||||
|
||||
### Phase 2F: Enhanced Sharing & Individual Pages (Priority: Medium)
|
||||
|
||||
**Goal**: Better sharing and find discoverability
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- [ ] Create individual find detail pages (`/finds/[id]`)
|
||||
- [ ] Add social media sharing with OpenGraph meta tags
|
||||
- [ ] Implement "Get Directions" integration with map apps
|
||||
- [ ] Build shareable find links with previews
|
||||
- [ ] Add "Copy link" functionality
|
||||
|
||||
**Estimated Effort**: 12-15 hours
|
||||
|
||||
---
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Component Architecture:
|
||||
|
||||
- Use composition pattern with reusable SHADCN components
|
||||
- Implement proper TypeScript interfaces for all props
|
||||
- Follow Svelte 5 runes pattern ($props, $derived, $effect)
|
||||
- Create clean separation between UI and business logic
|
||||
|
||||
### Code Quality:
|
||||
|
||||
- Maintain existing formatting (tabs, single quotes, 100 char width)
|
||||
- Use descriptive variable names and function signatures
|
||||
- Implement proper error boundaries and loading states
|
||||
- Add accessibility attributes (ARIA labels, keyboard navigation)
|
||||
|
||||
### Performance:
|
||||
|
||||
- Lazy load media content and heavy components
|
||||
- Implement proper caching strategies for API calls
|
||||
- Use virtual scrolling for long lists when needed
|
||||
- Optimize images/videos for web delivery
|
||||
|
||||
**Total Phase 2 Estimated Effort**: 82-105 hours
|
||||
**Total Phase 2 Completed Effort**: ~49 hours (Phases 2A: 12h + 2C: 15h + 2D: 22h)
|
||||
**Expected Timeline**: 8-10 weeks (part-time development)
|
||||
|
||||
## Next Steps:
|
||||
|
||||
1. ✅ **Completed**: Phase 2A (Modern Media Support) for immediate impact
|
||||
2. **Next Priority**: Phase 2B (Google Maps POI) for better UX
|
||||
3. ✅ **Completed**: Phase 2C (Social Interactions) for user engagement
|
||||
4. ✅ **Completed**: Phase 2D (Friends & Privacy System) for social connections
|
||||
5. **Continue with**: Phase 2E (Advanced Filtering) or 2F (Enhanced Sharing) based on user feedback
|
||||
|
||||
## Production Ready Features Summary:
|
||||
|
||||
**Core Functionality:**
|
||||
|
||||
- Create/view finds with photos, videos, descriptions, and categories
|
||||
- Location-based filtering and discovery with interactive map
|
||||
- Media carousel with navigation (WebP images and MP4 videos)
|
||||
- Real-time map markers with click-to-preview functionality
|
||||
|
||||
**Social Features:**
|
||||
|
||||
- Like/unlike finds with real-time count updates and animations
|
||||
- Friend request system (send, accept, decline, remove)
|
||||
- Friends management page with user search capabilities
|
||||
- Privacy-aware find feeds with customizable visibility filters
|
||||
|
||||
**Technical Excellence:**
|
||||
|
||||
- Modern media formats (WebP, MP4) with fallback support
|
||||
- Type-safe API endpoints with comprehensive error handling
|
||||
- Mobile-optimized responsive design
|
||||
- Performance-optimized with proper caching and lazy loading
|
||||
1007
logs/logboek.md
12
package.json
@@ -16,7 +16,8 @@
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:generate-erd": "drizzle-erd --in ./src/lib/server/db/schema.ts --out erd.svg"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
@@ -25,14 +26,15 @@
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@types/node": "^22",
|
||||
"bits-ui": "^2.11.4",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-erd": "0.0.1-alpha.11",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-storybook": "^9.1.8",
|
||||
@@ -59,9 +61,13 @@
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@sveltejs/adapter-vercel": "^5.10.2",
|
||||
"arctic": "^3.7.0",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"lucide-svelte": "^0.553.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"postgres": "^3.4.5",
|
||||
"sharp": "^0.34.4",
|
||||
"svelte-maplibre": "^1.2.1"
|
||||
"svelte-maplibre": "^1.2.1",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
20
scripts/generate-vapid-keys.js
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate VAPID keys for Web Push notifications
|
||||
* Run this script once to generate your VAPID keys and add them to your .env file
|
||||
*/
|
||||
|
||||
import webpush from 'web-push';
|
||||
|
||||
console.log('Generating VAPID keys for Web Push notifications...\n');
|
||||
|
||||
const vapidKeys = webpush.generateVAPIDKeys();
|
||||
|
||||
console.log('VAPID Keys Generated Successfully!');
|
||||
console.log('Add these to your .env file:\n');
|
||||
console.log(`VAPID_PUBLIC_KEY="${vapidKeys.publicKey}"`);
|
||||
console.log(`VAPID_PRIVATE_KEY="${vapidKeys.privateKey}"`);
|
||||
console.log('VAPID_SUBJECT="mailto:your-email@example.com"');
|
||||
console.log('\nReplace "your-email@example.com" with your actual email address.');
|
||||
console.log('\nIMPORTANT: Keep your private key secret and never commit it to version control!');
|
||||
@@ -88,6 +88,7 @@
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -13,9 +13,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
!origin ||
|
||||
origin.includes('localhost') ||
|
||||
origin.includes('127.0.0.1') ||
|
||||
origin.includes('serengo.ziasvannes.tech')
|
||||
origin.includes('serengo.zias.be')
|
||||
) {
|
||||
// Allow in development and serengo.ziasvannes.tech
|
||||
// Allow in development and serengo.zias.be
|
||||
}
|
||||
// In production, you would add: else if (origin !== 'yourdomain.com') { return new Response('Forbidden', { status: 403 }); }
|
||||
}
|
||||
@@ -50,9 +50,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
"worker-src 'self' blob:; " +
|
||||
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
|
||||
"font-src 'self' fonts.gstatic.com; " +
|
||||
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com; " +
|
||||
"media-src 'self' *.r2.cloudflarestorage.com; " +
|
||||
"connect-src 'self' *.openstreetmap.org; " +
|
||||
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.basemaps.cartocdn.com *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||
"media-src 'self' *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||
"connect-src 'self' *.openstreetmap.org *.basemaps.cartocdn.com https://fcm.googleapis.com https://android.googleapis.com; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self';"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ProfilePanel } from '$lib';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
@@ -12,7 +13,7 @@
|
||||
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<h1 class="app-title"><a href="/">Serengo</a></h1>
|
||||
<h1 class="app-title"><a href={resolveRoute('/')}>Serengo</a></h1>
|
||||
<div class="profile-container">
|
||||
<ProfilePanel
|
||||
username={user.username}
|
||||
@@ -27,8 +28,7 @@
|
||||
.app-header {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@@ -37,7 +37,6 @@
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Heart } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
findId: string;
|
||||
isLiked?: boolean;
|
||||
likeCount?: number;
|
||||
size?: 'sm' | 'default' | 'lg';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
findId,
|
||||
isLiked = false,
|
||||
likeCount = 0,
|
||||
size = 'default',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
// Track the source of truth - server state
|
||||
let serverIsLiked = $state(isLiked);
|
||||
let serverLikeCount = $state(likeCount);
|
||||
|
||||
// Track optimistic state during loading
|
||||
let isLoading = $state(false);
|
||||
let optimisticIsLiked = $state(isLiked);
|
||||
let optimisticLikeCount = $state(likeCount);
|
||||
|
||||
// Derived state for display
|
||||
let displayIsLiked = $derived(isLoading ? optimisticIsLiked : serverIsLiked);
|
||||
let displayLikeCount = $derived(isLoading ? optimisticLikeCount : serverLikeCount);
|
||||
|
||||
async function toggleLike() {
|
||||
if (isLoading) return;
|
||||
|
||||
// Set optimistic state
|
||||
optimisticIsLiked = !serverIsLiked;
|
||||
optimisticLikeCount = serverLikeCount + (optimisticIsLiked ? 1 : -1);
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const method = optimisticIsLiked ? 'POST' : 'DELETE';
|
||||
const response = await fetch(`/api/finds/${findId}/like`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update server state with response
|
||||
serverIsLiked = result.isLiked;
|
||||
serverLikeCount = result.likeCount;
|
||||
} catch (error: unknown) {
|
||||
console.error('Error updating like:', error);
|
||||
toast.error('Failed to update like. Please try again.');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update server state when props change (from parent component)
|
||||
$effect(() => {
|
||||
serverIsLiked = isLiked;
|
||||
serverLikeCount = likeCount;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
{size}
|
||||
class="group gap-1.5 {className}"
|
||||
onclick={toggleLike}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Heart
|
||||
class="h-4 w-4 transition-all duration-200 {displayIsLiked
|
||||
? 'scale-110 fill-red-500 text-red-500'
|
||||
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {isLoading
|
||||
? 'animate-pulse'
|
||||
: ''}"
|
||||
/>
|
||||
{#if displayLikeCount > 0}
|
||||
<span
|
||||
class="text-sm font-medium transition-colors {displayIsLiked
|
||||
? 'text-red-500'
|
||||
: 'text-gray-500 group-hover:text-red-400'}"
|
||||
>
|
||||
{displayLikeCount}
|
||||
</span>
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -1,237 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
locationActions,
|
||||
locationStatus,
|
||||
locationError,
|
||||
isLocationLoading
|
||||
} from '$lib/stores/location';
|
||||
import { Skeleton } from './skeleton';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
variant?: 'primary' | 'secondary' | 'icon';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
showLabel = true
|
||||
}: Props = $props();
|
||||
|
||||
async function handleLocationClick() {
|
||||
const result = await locationActions.getCurrentLocation({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 15000,
|
||||
maximumAge: 300000
|
||||
});
|
||||
|
||||
if (!result && $locationError) {
|
||||
toast.error($locationError.message);
|
||||
}
|
||||
}
|
||||
|
||||
const buttonText = $derived(() => {
|
||||
if ($isLocationLoading) return 'Finding location...';
|
||||
if ($locationStatus === 'success') return 'Update location';
|
||||
return 'Find my location';
|
||||
});
|
||||
|
||||
const iconClass = $derived(() => {
|
||||
if ($isLocationLoading) return 'loading';
|
||||
if ($locationStatus === 'success') return 'success';
|
||||
if ($locationStatus === 'error') return 'error';
|
||||
return 'default';
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="location-button {variant} {size} {className}"
|
||||
onclick={handleLocationClick}
|
||||
disabled={$isLocationLoading}
|
||||
title={buttonText()}
|
||||
>
|
||||
<span class="icon {iconClass()}">
|
||||
{#if $isLocationLoading}
|
||||
<div class="loading-skeleton">
|
||||
<Skeleton class="h-5 w-5 rounded-full" />
|
||||
</div>
|
||||
{:else if $locationStatus === 'success'}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if $locationStatus === 'error'}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if showLabel && variant !== 'icon'}
|
||||
<span class="label">{buttonText()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.location-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.location-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.primary {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: #1d4ed8;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.icon:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.small {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.small.icon {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.medium {
|
||||
padding: 8px 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.medium.icon {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.large {
|
||||
padding: 12px 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.large.icon {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Icon styles */
|
||||
.icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.small .icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.large .icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.icon.success svg {
|
||||
color: #10b981;
|
||||
fill: #10b981;
|
||||
}
|
||||
|
||||
.icon.error svg {
|
||||
color: #ef4444;
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.location-button .label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.location-button {
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,336 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { MapLibre, Marker } from 'svelte-maplibre';
|
||||
import type { StyleSpecification } from 'svelte-maplibre';
|
||||
import {
|
||||
coordinates,
|
||||
getMapCenter,
|
||||
getMapZoom,
|
||||
shouldZoomToLocation,
|
||||
locationActions
|
||||
} from '$lib/stores/location';
|
||||
import LocationButton from './LocationButton.svelte';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
|
||||
interface Find {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string;
|
||||
category?: string;
|
||||
isPublic: number;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
media?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
style?: StyleSpecification;
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
class?: string;
|
||||
showLocationButton?: boolean;
|
||||
autoCenter?: boolean;
|
||||
finds?: Find[];
|
||||
onFindClick?: (find: Find) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
style = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'osm-raster': {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-tiles',
|
||||
type: 'raster',
|
||||
source: 'osm-raster'
|
||||
}
|
||||
]
|
||||
},
|
||||
center,
|
||||
zoom,
|
||||
class: className = '',
|
||||
showLocationButton = true,
|
||||
autoCenter = true,
|
||||
finds = [],
|
||||
onFindClick
|
||||
}: Props = $props();
|
||||
|
||||
let mapLoaded = $state(false);
|
||||
let styleLoaded = $state(false);
|
||||
let isIdle = $state(false);
|
||||
|
||||
// Handle comprehensive map loading events
|
||||
function handleStyleLoad() {
|
||||
styleLoaded = true;
|
||||
}
|
||||
|
||||
function handleIdle() {
|
||||
isIdle = true;
|
||||
}
|
||||
|
||||
// Map is considered fully ready when it's loaded, style is loaded, and it's idle
|
||||
const mapReady = $derived(mapLoaded && styleLoaded && isIdle);
|
||||
|
||||
// Reactive center and zoom based on location or props
|
||||
const mapCenter = $derived(
|
||||
$coordinates && (autoCenter || $shouldZoomToLocation)
|
||||
? ([$coordinates.longitude, $coordinates.latitude] as [number, number])
|
||||
: center || $getMapCenter
|
||||
);
|
||||
|
||||
const mapZoom = $derived(() => {
|
||||
if ($shouldZoomToLocation && $coordinates) {
|
||||
// Force zoom to calculated level when location button is clicked
|
||||
return $getMapZoom;
|
||||
}
|
||||
if ($coordinates && autoCenter) {
|
||||
return $getMapZoom;
|
||||
}
|
||||
return zoom || 13;
|
||||
});
|
||||
|
||||
// Effect to clear zoom trigger after it's been used
|
||||
$effect(() => {
|
||||
if ($shouldZoomToLocation) {
|
||||
// Use a timeout to ensure the map has updated before clearing the trigger
|
||||
setTimeout(() => {
|
||||
locationActions.clearZoomTrigger();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="map-container {className}">
|
||||
{#if !mapReady}
|
||||
<div class="map-skeleton">
|
||||
<Skeleton class="h-full w-full rounded-xl" />
|
||||
<div class="skeleton-overlay">
|
||||
<Skeleton class="mb-2 h-4 w-16" />
|
||||
<Skeleton class="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="map-wrapper" class:hidden={!mapReady}>
|
||||
<MapLibre
|
||||
{style}
|
||||
center={mapCenter}
|
||||
zoom={mapZoom()}
|
||||
bind:loaded={mapLoaded}
|
||||
onstyleload={handleStyleLoad}
|
||||
onidle={handleIdle}
|
||||
>
|
||||
{#if $coordinates}
|
||||
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
|
||||
<div class="location-marker">
|
||||
<div class="marker-pulse"></div>
|
||||
<div class="marker-outer">
|
||||
<div class="marker-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
{/if}
|
||||
|
||||
{#each finds as find (find.id)}
|
||||
<Marker lngLat={[parseFloat(find.longitude), parseFloat(find.latitude)]}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="find-marker"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onFindClick?.(find)}
|
||||
title={find.title}
|
||||
>
|
||||
<div class="find-marker-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2L13.09 8.26L19 9.27L13.09 10.28L12 16.54L10.91 10.28L5 9.27L10.91 8.26L12 2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{#if find.media && find.media.length > 0}
|
||||
<div class="find-marker-preview">
|
||||
<img src={find.media[0].thumbnailUrl} alt={find.title} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Marker>
|
||||
{/each}
|
||||
</MapLibre>
|
||||
|
||||
{#if showLocationButton}
|
||||
<div class="location-controls">
|
||||
<LocationButton variant="icon" size="medium" showLabel={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-skeleton {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeleton-overlay {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-wrapper.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-container :global(.maplibregl-map) {
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-controls {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* Location marker styles */
|
||||
:global(.location-marker) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.marker-outer) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(37, 99, 235, 0.2);
|
||||
border: 2px solid #2563eb;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:global(.marker-inner) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
:global(.marker-pulse) {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(37, 99, 235, 0.6);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Find marker styles */
|
||||
:global(.find-marker) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.find-marker:hover) {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
:global(.find-marker-icon) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #ff6b35;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
:global(.find-marker-preview) {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
:global(.find-marker-preview img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.map-container {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.location-controls {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -163,6 +163,7 @@
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.modal.dropdown {
|
||||
@@ -171,11 +172,12 @@
|
||||
right: 0;
|
||||
max-width: 320px;
|
||||
width: 320px;
|
||||
z-index: 1000;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.modal::backdrop {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
|
||||
1
src/lib/components/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LoginForm } from './login-form.svelte';
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
import { Button } from '$lib/components/button/index.js';
|
||||
import * as Card from '$lib/components/card/index.js';
|
||||
import { Label } from '$lib/components/label/index.js';
|
||||
@@ -8,7 +9,7 @@
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { ActionData } from '../../routes/login/$types.js';
|
||||
import type { ActionData } from '../../../routes/login/$types.js';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
@@ -62,7 +63,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" class="mt-4 w-full" onclick={() => goto('/login/google')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mt-4 w-full"
|
||||
onclick={() => goto(resolveRoute('/login/google'))}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
141
src/lib/components/finds/Comment.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import type { CommentState } from '$lib/stores/api-sync';
|
||||
|
||||
interface CommentProps {
|
||||
comment: CommentState;
|
||||
showDeleteButton?: boolean;
|
||||
onDelete?: (commentId: string) => void;
|
||||
}
|
||||
|
||||
let { comment, showDeleteButton = false, onDelete }: CommentProps = $props();
|
||||
|
||||
function handleDelete() {
|
||||
if (onDelete) {
|
||||
onDelete(comment.id);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string): string {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - dateObj.getTime();
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (minutes < 1) {
|
||||
return 'just now';
|
||||
} else if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
} else if (hours < 24) {
|
||||
return `${hours}h`;
|
||||
} else if (days < 7) {
|
||||
return `${days}d`;
|
||||
} else {
|
||||
return dateObj.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="comment">
|
||||
<div class="comment-avatar">
|
||||
<ProfilePicture
|
||||
username={comment.user.username}
|
||||
profilePictureUrl={comment.user.profilePictureUrl}
|
||||
class="avatar-small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="comment-content">
|
||||
<div class="comment-header">
|
||||
<span class="comment-username">@{comment.user.username}</span>
|
||||
<span class="comment-time">{formatDate(comment.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
<div class="comment-text">
|
||||
{comment.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showDeleteButton}
|
||||
<div class="comment-actions">
|
||||
<Button variant="ghost" size="sm" class="delete-button" onclick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comment {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.comment:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.avatar-small) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.comment-username {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
color: hsl(var(--foreground));
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
:global(.delete-button) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding: 0.25rem;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.delete-button:hover) {
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
}
|
||||
</style>
|
||||
118
src/lib/components/finds/CommentForm.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Send } from '@lucide/svelte';
|
||||
|
||||
interface CommentFormProps {
|
||||
onSubmit: (content: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { onSubmit, placeholder = 'Add a comment...', disabled = false }: CommentFormProps = $props();
|
||||
|
||||
let content = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const trimmedContent = content.trim();
|
||||
if (!trimmedContent || isSubmitting || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedContent.length > 500) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
onSubmit(trimmedContent);
|
||||
content = '';
|
||||
} catch (error) {
|
||||
console.error('Error submitting comment:', error);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit(event);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = $derived(
|
||||
content.trim().length > 0 && content.length <= 500 && !isSubmitting && !disabled
|
||||
);
|
||||
</script>
|
||||
|
||||
<form class="comment-form" onsubmit={handleSubmit}>
|
||||
<div class="input-container">
|
||||
<Input
|
||||
bind:value={content}
|
||||
{placeholder}
|
||||
disabled={disabled || isSubmitting}
|
||||
class="comment-input"
|
||||
onkeydown={handleKeyDown}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="ghost" size="sm" disabled={!canSubmit} class="submit-button">
|
||||
<Send size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if content.length > 450}
|
||||
<div class="character-count" class:warning={content.length > 500}>
|
||||
{content.length}/500
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.comment-form {
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--background));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.comment-input) {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
:global(.submit-button) {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.submit-button:not(:disabled)) {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
:global(.submit-button:not(:disabled):hover) {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.character-count {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: right;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.character-count.warning {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
</style>
|
||||
23
src/lib/components/finds/Comments.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import CommentsList from './CommentsList.svelte';
|
||||
|
||||
interface CommentsProps {
|
||||
findId: string;
|
||||
currentUserId?: string;
|
||||
collapsed?: boolean;
|
||||
maxComments?: number;
|
||||
showCommentForm?: boolean;
|
||||
isScrollable?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
findId,
|
||||
currentUserId,
|
||||
collapsed = true,
|
||||
maxComments,
|
||||
showCommentForm = true,
|
||||
isScrollable = false
|
||||
}: CommentsProps = $props();
|
||||
</script>
|
||||
|
||||
<CommentsList {findId} {currentUserId} {collapsed} {maxComments} {showCommentForm} {isScrollable} />
|
||||
253
src/lib/components/finds/CommentsList.svelte
Normal file
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import Comment from './Comment.svelte';
|
||||
import CommentForm from './CommentForm.svelte';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
import { apiSync } from '$lib/stores/api-sync';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface CommentsListProps {
|
||||
findId: string;
|
||||
currentUserId?: string;
|
||||
collapsed?: boolean;
|
||||
maxComments?: number;
|
||||
showCommentForm?: boolean;
|
||||
isScrollable?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
findId,
|
||||
currentUserId,
|
||||
collapsed = true,
|
||||
maxComments,
|
||||
showCommentForm = true,
|
||||
isScrollable = false
|
||||
}: CommentsListProps = $props();
|
||||
|
||||
let isExpanded = $state(!collapsed);
|
||||
let hasLoadedComments = $state(false);
|
||||
|
||||
const commentsState = apiSync.subscribeFindComments(findId);
|
||||
|
||||
onMount(() => {
|
||||
if (isExpanded && !hasLoadedComments) {
|
||||
loadComments();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadComments() {
|
||||
if (hasLoadedComments) return;
|
||||
|
||||
hasLoadedComments = true;
|
||||
await apiSync.loadComments(findId);
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
if (isExpanded && !hasLoadedComments) {
|
||||
loadComments();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddComment(content: string) {
|
||||
await apiSync.addComment(findId, content);
|
||||
}
|
||||
|
||||
async function handleDeleteComment(commentId: string) {
|
||||
await apiSync.deleteComment(commentId, findId);
|
||||
}
|
||||
|
||||
function canDeleteComment(comment: { user: { id: string } }): boolean {
|
||||
return Boolean(
|
||||
currentUserId && (comment.user.id === currentUserId || comment.user.id === 'current-user')
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet loadingSkeleton()}
|
||||
<div class="loading-skeleton">
|
||||
{#each Array(3) as _, index (index)}
|
||||
<div class="comment-skeleton">
|
||||
<Skeleton class="avatar-skeleton" />
|
||||
<div class="content-skeleton">
|
||||
<Skeleton class="header-skeleton" />
|
||||
<Skeleton class="text-skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="comments-list">
|
||||
{#if collapsed}
|
||||
<button class="toggle-button" onclick={toggleExpanded}>
|
||||
{#if isExpanded}
|
||||
Hide comments ({$commentsState.commentCount})
|
||||
{:else if $commentsState.commentCount > 0}
|
||||
View comments ({$commentsState.commentCount})
|
||||
{:else}
|
||||
Add comment
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="comments-container">
|
||||
{#if showCommentForm}
|
||||
<div class="comment-form-container">
|
||||
<CommentForm onSubmit={handleAddComment} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $commentsState.isLoading && !hasLoadedComments}
|
||||
{@render loadingSkeleton()}
|
||||
{:else if $commentsState.error}
|
||||
<div class="error-message">
|
||||
Failed to load comments.
|
||||
<button class="retry-button" onclick={loadComments}> Try again </button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="comments" class:scrollable={isScrollable}>
|
||||
{#each maxComments ? $commentsState.comments.slice(0, maxComments) : $commentsState.comments as comment (comment.id)}
|
||||
<Comment
|
||||
{comment}
|
||||
showDeleteButton={canDeleteComment(comment)}
|
||||
onDelete={handleDeleteComment}
|
||||
/>
|
||||
{:else}
|
||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||
{/each}
|
||||
{#if maxComments && $commentsState.comments.length > maxComments}
|
||||
<div class="see-more">
|
||||
<div class="see-more-text">
|
||||
+{$commentsState.comments.length - maxComments} more comments
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comments-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.comments-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.comment-form-container {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: hsl(var(--background));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.comments {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.comments.scrollable {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
min-height: 0;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.see-more {
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.see-more-text {
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.no-comments {
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
padding: 1.5rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
color: hsl(var(--destructive));
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--primary));
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
color: hsl(var(--primary) / 0.8);
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.comment-skeleton {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
:global(.avatar-skeleton) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.header-skeleton) {
|
||||
width: 120px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
:global(.text-skeleton) {
|
||||
width: 80%;
|
||||
height: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,31 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||
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 './POISearch.svelte';
|
||||
import type { PlaceResult } from '$lib/utils/places';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
locationId: string;
|
||||
onClose: () => void;
|
||||
onFindCreated: (event: CustomEvent) => void;
|
||||
}
|
||||
|
||||
let { isOpen, onClose, onFindCreated }: Props = $props();
|
||||
let { isOpen, locationId, onClose, onFindCreated }: Props = $props();
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let latitude = $state('');
|
||||
let longitude = $state('');
|
||||
let locationName = $state('');
|
||||
let category = $state('cafe');
|
||||
let isPublic = $state(true);
|
||||
let selectedFiles = $state<FileList | null>(null);
|
||||
let isSubmitting = $state(false);
|
||||
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
||||
let useManualLocation = $state(false);
|
||||
|
||||
const categories = [
|
||||
{ value: 'cafe', label: 'Café' },
|
||||
@@ -37,7 +30,6 @@
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
let showModal = $state(true);
|
||||
let isMobile = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
@@ -53,19 +45,6 @@
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!showModal) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (showModal && $coordinates) {
|
||||
latitude = $coordinates.latitude.toString();
|
||||
longitude = $coordinates.longitude.toString();
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
selectedFiles = target.files;
|
||||
@@ -93,10 +72,7 @@
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const lat = parseFloat(latitude);
|
||||
const lng = parseFloat(longitude);
|
||||
|
||||
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
|
||||
if (!title.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,11 +89,9 @@
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
locationId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
locationName: locationName.trim() || null,
|
||||
category,
|
||||
isPublic,
|
||||
media: uploadedMedia
|
||||
@@ -129,8 +103,8 @@
|
||||
}
|
||||
|
||||
resetForm();
|
||||
showModal = false;
|
||||
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating find:', error);
|
||||
alert('Failed to create find. Please try again.');
|
||||
@@ -139,31 +113,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlaceSelected(place: PlaceResult) {
|
||||
locationName = place.name;
|
||||
latitude = place.latitude.toString();
|
||||
longitude = place.longitude.toString();
|
||||
}
|
||||
|
||||
function toggleLocationMode() {
|
||||
useManualLocation = !useManualLocation;
|
||||
if (!useManualLocation && $coordinates) {
|
||||
latitude = $coordinates.latitude.toString();
|
||||
longitude = $coordinates.longitude.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
title = '';
|
||||
description = '';
|
||||
locationName = '';
|
||||
latitude = '';
|
||||
longitude = '';
|
||||
category = 'cafe';
|
||||
isPublic = true;
|
||||
selectedFiles = null;
|
||||
uploadedMedia = [];
|
||||
useManualLocation = false;
|
||||
|
||||
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
@@ -173,16 +129,27 @@
|
||||
|
||||
function closeModal() {
|
||||
resetForm();
|
||||
showModal = false;
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Create Find</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Create Find</h2>
|
||||
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
@@ -191,7 +158,7 @@
|
||||
}}
|
||||
class="form"
|
||||
>
|
||||
<div class="form-content">
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<Label for="title">What did you find?</Label>
|
||||
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
||||
@@ -207,33 +174,6 @@
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="location-section">
|
||||
<div class="location-header">
|
||||
<Label>Location</Label>
|
||||
<button type="button" onclick={toggleLocationMode} class="toggle-button">
|
||||
{useManualLocation ? 'Use Search' : 'Manual Entry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if useManualLocation}
|
||||
<div class="field">
|
||||
<Label for="location-name">Location name</Label>
|
||||
<Input
|
||||
name="location-name"
|
||||
placeholder="Café Central, Brussels"
|
||||
bind:value={locationName}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<POISearch
|
||||
onPlaceSelected={handlePlaceSelected}
|
||||
placeholder="Search for cafés, restaurants, landmarks..."
|
||||
label=""
|
||||
showNearbyButton={true}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<div class="field">
|
||||
<Label for="category">Category</Label>
|
||||
@@ -304,37 +244,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if useManualLocation || (!latitude && !longitude)}
|
||||
<div class="field-group">
|
||||
<div class="field">
|
||||
<Label for="latitude">Latitude</Label>
|
||||
<Input name="latitude" type="text" required bind:value={latitude} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label for="longitude">Longitude</Label>
|
||||
<Input name="longitude" type="text" required bind:value={longitude} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if latitude && longitude}
|
||||
<div class="coordinates-display">
|
||||
<Label>Selected coordinates</Label>
|
||||
<div class="coordinates-info">
|
||||
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
|
||||
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (useManualLocation = true)}
|
||||
class="edit-coords-button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<div class="modal-footer">
|
||||
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -343,24 +255,105 @@
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.create-find-sheet) {
|
||||
padding: 0 !important;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:global(.create-find-sheet) {
|
||||
height: 90vh;
|
||||
border-radius: 12px 12px 0 0;
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.form {
|
||||
@@ -369,13 +362,14 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -390,14 +384,8 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.field-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 80px;
|
||||
min-height: 100px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
@@ -407,6 +395,7 @@
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
@@ -448,13 +437,14 @@
|
||||
}
|
||||
|
||||
.privacy-toggle:hover {
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.privacy-toggle input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-upload {
|
||||
@@ -485,7 +475,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
@@ -493,6 +483,7 @@
|
||||
|
||||
.file-content span {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-selected {
|
||||
@@ -500,7 +491,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
@@ -516,100 +507,48 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.actions {
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actions :global(button) {
|
||||
.modal-footer :global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.location-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.location-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.coordinates-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.edit-coords-button {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-coords-button:hover {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-content {
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.actions :global(button) {
|
||||
flex: none;
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
876
src/lib/components/finds/EditFindModal.svelte
Normal file
@@ -0,0 +1,876 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Label } from '$lib/components/label';
|
||||
import { Button } from '$lib/components/button';
|
||||
import POISearch from '../map/POISearch.svelte';
|
||||
import type { PlaceResult } from '$lib/utils/places';
|
||||
import { apiSync } from '$lib/stores/api-sync';
|
||||
|
||||
interface FindData {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string | null;
|
||||
category?: string | null;
|
||||
isPublic: number;
|
||||
media?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
orderIndex: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
find: FindData;
|
||||
onClose: () => void;
|
||||
onFindUpdated: (event: CustomEvent) => void;
|
||||
onFindDeleted?: (event: CustomEvent) => void;
|
||||
}
|
||||
|
||||
let { isOpen, find, onClose, onFindUpdated, onFindDeleted }: Props = $props();
|
||||
|
||||
let title = $state(find.title);
|
||||
let description = $state(find.description || '');
|
||||
let latitude = $state(find.latitude);
|
||||
let longitude = $state(find.longitude);
|
||||
let locationName = $state(find.locationName || '');
|
||||
let category = $state(find.category || 'cafe');
|
||||
let isPublic = $state(find.isPublic === 1);
|
||||
let selectedFiles = $state<FileList | null>(null);
|
||||
let isSubmitting = $state(false);
|
||||
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
||||
let useManualLocation = $state(false);
|
||||
let existingMedia = $state<
|
||||
Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
orderIndex: number | null;
|
||||
}>
|
||||
>(find.media || []);
|
||||
let mediaToDelete = $state<string[]>([]);
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
const categories = [
|
||||
{ value: 'cafe', label: 'Café' },
|
||||
{ value: 'restaurant', label: 'Restaurant' },
|
||||
{ value: 'park', label: 'Park' },
|
||||
{ value: 'landmark', label: 'Landmark' },
|
||||
{ value: 'shop', label: 'Shop' },
|
||||
{ value: 'museum', label: 'Museum' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
let isMobile = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const checkIsMobile = () => {
|
||||
isMobile = window.innerWidth < 768;
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener('resize', checkIsMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
// Reset state when find changes
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
title = find.title;
|
||||
description = find.description || '';
|
||||
latitude = find.latitude;
|
||||
longitude = find.longitude;
|
||||
locationName = find.locationName || '';
|
||||
category = find.category || 'cafe';
|
||||
isPublic = find.isPublic === 1;
|
||||
existingMedia = find.media || [];
|
||||
mediaToDelete = [];
|
||||
uploadedMedia = [];
|
||||
selectedFiles = null;
|
||||
useManualLocation = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
selectedFiles = target.files;
|
||||
}
|
||||
|
||||
async function uploadMedia(): Promise<void> {
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
const formData = new FormData();
|
||||
Array.from(selectedFiles).forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await fetch('/api/finds/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload media');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
uploadedMedia = result.media;
|
||||
}
|
||||
|
||||
function removeExistingMedia(mediaId: string) {
|
||||
mediaToDelete = [...mediaToDelete, mediaId];
|
||||
existingMedia = existingMedia.filter((m) => m.id !== mediaId);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const lat = parseFloat(latitude);
|
||||
const lng = parseFloat(longitude);
|
||||
|
||||
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
// Upload new media files if any
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
await uploadMedia();
|
||||
}
|
||||
|
||||
// Combine existing media with new uploads
|
||||
const allMedia = [
|
||||
...existingMedia.map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || m.url
|
||||
})),
|
||||
...uploadedMedia
|
||||
];
|
||||
|
||||
await apiSync.updateFind(find.id, {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
locationName: locationName.trim() || null,
|
||||
category,
|
||||
isPublic,
|
||||
media: allMedia,
|
||||
mediaToDelete
|
||||
});
|
||||
|
||||
onFindUpdated(new CustomEvent('findUpdated', { detail: { reload: true } }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error updating find:', error);
|
||||
alert('Failed to update find. Please try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!showDeleteConfirm) {
|
||||
showDeleteConfirm = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
await apiSync.deleteFind(find.id);
|
||||
|
||||
if (onFindDeleted) {
|
||||
onFindDeleted(new CustomEvent('findDeleted', { detail: { findId: find.id } }));
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error deleting find:', error);
|
||||
alert('Failed to delete find. Please try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
showDeleteConfirm = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlaceSelected(place: PlaceResult) {
|
||||
locationName = place.name;
|
||||
latitude = place.latitude.toString();
|
||||
longitude = place.longitude.toString();
|
||||
}
|
||||
|
||||
function toggleLocationMode() {
|
||||
useManualLocation = !useManualLocation;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showDeleteConfirm = false;
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Edit Find</h2>
|
||||
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="form"
|
||||
>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<Label for="title">What did you find?</Label>
|
||||
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label for="description">Tell us about it</Label>
|
||||
<textarea
|
||||
name="description"
|
||||
placeholder="The best cappuccino in town..."
|
||||
maxlength="500"
|
||||
bind:value={description}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="location-section">
|
||||
<div class="location-header">
|
||||
<Label>Location</Label>
|
||||
<button type="button" onclick={toggleLocationMode} class="toggle-button">
|
||||
{useManualLocation ? 'Use Search' : 'Manual Entry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if useManualLocation}
|
||||
<div class="field">
|
||||
<Label for="location-name">Location name</Label>
|
||||
<Input
|
||||
name="location-name"
|
||||
placeholder="Café Central, Brussels"
|
||||
bind:value={locationName}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<POISearch
|
||||
onPlaceSelected={handlePlaceSelected}
|
||||
placeholder="Search for cafés, restaurants, landmarks..."
|
||||
label=""
|
||||
showNearbyButton={true}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<div class="field">
|
||||
<Label for="category">Category</Label>
|
||||
<select name="category" bind:value={category} class="select">
|
||||
{#each categories as cat (cat.value)}
|
||||
<option value={cat.value}>{cat.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label>Privacy</Label>
|
||||
<label class="privacy-toggle">
|
||||
<input type="checkbox" bind:checked={isPublic} />
|
||||
<span>{isPublic ? 'Public' : 'Private'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if existingMedia.length > 0}
|
||||
<div class="field">
|
||||
<Label>Current Media</Label>
|
||||
<div class="existing-media-grid">
|
||||
{#each existingMedia as mediaItem (mediaItem.id)}
|
||||
<div class="media-item">
|
||||
{#if mediaItem.type === 'photo'}
|
||||
<img
|
||||
src={mediaItem.thumbnailUrl || mediaItem.url}
|
||||
alt="Media"
|
||||
class="media-thumbnail"
|
||||
/>
|
||||
{:else}
|
||||
<video src={mediaItem.url} class="media-thumbnail" muted>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="remove-media-button"
|
||||
onclick={() => removeExistingMedia(mediaItem.id)}
|
||||
aria-label="Remove media"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<Label for="media-files">Add new photo or video</Label>
|
||||
<div class="file-upload">
|
||||
<input
|
||||
id="media-files"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
||||
onchange={handleFileChange}
|
||||
class="file-input"
|
||||
/>
|
||||
<div class="file-content">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 4V10H20"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>Click to upload</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedFiles && selectedFiles.length > 0}
|
||||
<div class="file-selected">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 4V10H20"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{selectedFiles[0].name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if useManualLocation || (!latitude && !longitude)}
|
||||
<div class="field-group">
|
||||
<div class="field">
|
||||
<Label for="latitude">Latitude</Label>
|
||||
<Input name="latitude" type="text" required bind:value={latitude} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label for="longitude">Longitude</Label>
|
||||
<Input name="longitude" type="text" required bind:value={longitude} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if latitude && longitude}
|
||||
<div class="coordinates-display">
|
||||
<Label>Selected coordinates</Label>
|
||||
<div class="coordinates-info">
|
||||
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
|
||||
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (useManualLocation = true)}
|
||||
class="edit-coords-button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
disabled={isSubmitting}
|
||||
class="delete-button"
|
||||
>
|
||||
{showDeleteConfirm ? 'Confirm Delete?' : 'Delete Find'}
|
||||
</Button>
|
||||
<div class="action-buttons">
|
||||
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !title.trim()}>
|
||||
{isSubmitting ? 'Updating...' : 'Update Find'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.form {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 100px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||
}
|
||||
|
||||
.privacy-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.privacy-toggle:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.privacy-toggle input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.existing-media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.media-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.remove-media-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.remove-media-button:hover {
|
||||
background: rgba(255, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.file-upload {
|
||||
position: relative;
|
||||
border: 2px dashed hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-upload:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.file-content span {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.file-selected svg {
|
||||
color: hsl(var(--primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-selected span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.action-buttons :global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:global(.delete-button) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.location-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.location-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.coordinates-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.coordinate {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.edit-coords-button {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-coords-button:hover {
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.delete-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Badge } from '$lib/components/badge';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
|
||||
import { MoreHorizontal, MessageCircle, Share } from '@lucide/svelte';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator
|
||||
} from '$lib/components/dropdown-menu';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
import VideoPlayer from '../media/VideoPlayer.svelte';
|
||||
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||
import CommentsList from './CommentsList.svelte';
|
||||
import Rating from './Rating.svelte';
|
||||
import { Ellipsis, MessageCircle, Share, Edit, Trash2 } from '@lucide/svelte';
|
||||
import { apiSync } from '$lib/stores/api-sync';
|
||||
|
||||
interface FindCardProps {
|
||||
id: string;
|
||||
@@ -12,18 +22,32 @@
|
||||
description?: string;
|
||||
category?: string;
|
||||
locationName?: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
isPublic?: number;
|
||||
userId?: string;
|
||||
user: {
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
media?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
orderIndex?: number | null;
|
||||
}>;
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
commentCount?: number;
|
||||
rating?: number | null;
|
||||
ratingCount?: number;
|
||||
userRating?: number | null;
|
||||
currentUserId?: string;
|
||||
onExplore?: (id: string) => void;
|
||||
onDeleted?: () => void;
|
||||
onUpdated?: () => void;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -32,19 +56,86 @@
|
||||
description,
|
||||
category,
|
||||
locationName,
|
||||
latitude: _latitude,
|
||||
longitude: _longitude,
|
||||
isPublic: _isPublic,
|
||||
userId,
|
||||
user,
|
||||
media,
|
||||
likeCount = 0,
|
||||
isLiked = false,
|
||||
onExplore
|
||||
commentCount = 0,
|
||||
rating,
|
||||
ratingCount = 0,
|
||||
userRating,
|
||||
currentUserId,
|
||||
onExplore,
|
||||
onDeleted,
|
||||
onUpdated: _onUpdated,
|
||||
onEdit
|
||||
}: FindCardProps = $props();
|
||||
|
||||
let showComments = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
const isOwner = $derived(currentUserId && userId && currentUserId === userId);
|
||||
|
||||
function handleExplore() {
|
||||
onExplore?.(id);
|
||||
}
|
||||
|
||||
function getUserInitials(username: string): string {
|
||||
return username.slice(0, 2).toUpperCase();
|
||||
function toggleComments() {
|
||||
showComments = !showComments;
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
const url = `${window.location.origin}/finds/${id}`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
.share({
|
||||
title: title,
|
||||
text: description || `Check out this find: ${title}`,
|
||||
url: url
|
||||
})
|
||||
.catch((error) => {
|
||||
// User cancelled or error occurred
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error sharing:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback: Copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
alert('Find URL copied to clipboard!');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
onEdit?.();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Are you sure you want to delete this find? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting = true;
|
||||
try {
|
||||
await apiSync.deleteFind(id);
|
||||
onDeleted?.();
|
||||
} catch (error) {
|
||||
console.error('Error deleting find:', error);
|
||||
alert('Failed to delete find. Please try again.');
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,14 +143,11 @@
|
||||
<!-- Post Header -->
|
||||
<div class="post-header">
|
||||
<div class="user-info">
|
||||
<Avatar class="avatar">
|
||||
{#if user.profilePictureUrl}
|
||||
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
|
||||
{/if}
|
||||
<AvatarFallback class="avatar-fallback">
|
||||
{getUserInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={user.username}
|
||||
profilePictureUrl={user.profilePictureUrl}
|
||||
class="avatar"
|
||||
/>
|
||||
<div class="user-details">
|
||||
<div class="username">@{user.username}</div>
|
||||
{#if locationName}
|
||||
@@ -79,9 +167,24 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="more-button">
|
||||
<MoreHorizontal size={16} />
|
||||
</Button>
|
||||
{#if isOwner}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="more-button-trigger">
|
||||
<Ellipsis size={16} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onclick={handleEdit}>
|
||||
<Edit size={16} />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onclick={handleDelete} disabled={isDeleting} class="text-destructive">
|
||||
<Trash2 size={16} />
|
||||
<span>{isDeleting ? 'Deleting...' : 'Delete'}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
@@ -101,8 +204,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div class="post-media">
|
||||
{#if media && media.length > 0}
|
||||
{#if media && media.length > 0}
|
||||
<div class="post-media">
|
||||
{#if media[0].type === 'photo'}
|
||||
<img
|
||||
src={media[0].thumbnailUrl || media[0].url}
|
||||
@@ -120,31 +223,18 @@
|
||||
class="media-video"
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="no-media">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Post Actions -->
|
||||
<div class="post-actions">
|
||||
<div class="action-buttons">
|
||||
<LikeButton findId={id} {isLiked} {likeCount} size="sm" />
|
||||
<Button variant="ghost" size="sm" class="action-button">
|
||||
<Button variant="ghost" size="sm" class="action-button" onclick={toggleComments}>
|
||||
<MessageCircle size={16} />
|
||||
<span>comment</span>
|
||||
<span>{commentCount || 'comment'}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="action-button">
|
||||
<Button variant="ghost" size="sm" class="action-button" onclick={handleShare}>
|
||||
<Share size={16} />
|
||||
<span>share</span>
|
||||
</Button>
|
||||
@@ -153,20 +243,38 @@
|
||||
explore
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Rating Section -->
|
||||
{#if currentUserId}
|
||||
<div class="rating-section">
|
||||
<Rating
|
||||
findId={id}
|
||||
initialRating={rating ? rating / 100 : 0}
|
||||
initialCount={ratingCount}
|
||||
{userRating}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Comments Section -->
|
||||
{#if showComments}
|
||||
<div class="comments-section">
|
||||
<CommentsList
|
||||
findId={id}
|
||||
{currentUserId}
|
||||
collapsed={true}
|
||||
maxComments={5}
|
||||
showCommentForm={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.find-card {
|
||||
background: white;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.find-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Post Header */
|
||||
@@ -219,6 +327,33 @@
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
:global(.more-button-trigger) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.more-button-trigger:hover) {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
:global(.text-destructive) {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
:global(.text-destructive:hover) {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* Post Content */
|
||||
.post-content {
|
||||
padding: 0 1rem 0.75rem 1rem;
|
||||
@@ -258,33 +393,23 @@
|
||||
/* Media */
|
||||
.post-media {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/10;
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--muted));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
max-height: 600px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:global(.media-video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-media {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0.5;
|
||||
height: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
/* Post Actions */
|
||||
@@ -300,12 +425,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.action-button) {
|
||||
gap: 0.375rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.action-button:hover) {
|
||||
@@ -317,6 +446,20 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Rating Section */
|
||||
.rating-section {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
}
|
||||
|
||||
/* Comments Section */
|
||||
.comments-section {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.post-actions {
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
import VideoPlayer from '../media/VideoPlayer.svelte';
|
||||
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||
import CommentsList from './CommentsList.svelte';
|
||||
|
||||
interface Find {
|
||||
id: string;
|
||||
@@ -30,11 +30,11 @@
|
||||
interface Props {
|
||||
find: Find | null;
|
||||
onClose: () => void;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
||||
let { find, onClose }: Props = $props();
|
||||
let { find, onClose, currentUserId }: Props = $props();
|
||||
|
||||
let showModal = $state(true);
|
||||
let currentMediaIndex = $state(0);
|
||||
let isMobile = $state(false);
|
||||
|
||||
@@ -52,13 +52,6 @@
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
// Close modal when showModal changes to false
|
||||
$effect(() => {
|
||||
if (!showModal) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
function nextMedia() {
|
||||
if (!find?.media) return;
|
||||
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
|
||||
@@ -95,33 +88,44 @@
|
||||
const url = `${window.location.origin}/finds/${find.id}`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: find.title,
|
||||
text: find.description || `Check out this find: ${find.title}`,
|
||||
url: url
|
||||
});
|
||||
navigator
|
||||
.share({
|
||||
title: find.title,
|
||||
text: find.description || `Check out this find: ${find.title}`,
|
||||
url: url
|
||||
})
|
||||
.catch((error) => {
|
||||
// User cancelled or error occurred
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error sharing:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
navigator.clipboard.writeText(url);
|
||||
alert('Find URL copied to clipboard!');
|
||||
// Fallback: Copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
alert('Find URL copied to clipboard!');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if find}
|
||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
|
||||
<SheetHeader class="sheet-header">
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="user-section">
|
||||
<Avatar class="user-avatar">
|
||||
{#if find.user.profilePictureUrl}
|
||||
<AvatarImage src={find.user.profilePictureUrl} alt={find.user.username} />
|
||||
{/if}
|
||||
<AvatarFallback class="avatar-fallback">
|
||||
{find.user.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={find.user.username}
|
||||
profilePictureUrl={find.user.profilePictureUrl}
|
||||
class="user-avatar"
|
||||
/>
|
||||
<div class="user-info">
|
||||
<SheetTitle class="find-title">{find.title}</SheetTitle>
|
||||
<h2 class="find-title">{find.title}</h2>
|
||||
<div class="find-meta">
|
||||
<span class="username">@{find.user.username}</span>
|
||||
<span class="separator">•</span>
|
||||
@@ -133,9 +137,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-body">
|
||||
<div class="modal-body">
|
||||
{#if find.media && find.media.length > 0}
|
||||
<div class="media-container">
|
||||
<div class="media-viewer">
|
||||
@@ -254,38 +269,90 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comments-section">
|
||||
<CommentsList
|
||||
findId={find.id}
|
||||
{currentUserId}
|
||||
collapsed={false}
|
||||
isScrollable={true}
|
||||
showCommentForm={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Base styles for sheet content */
|
||||
:global(.sheet-content) {
|
||||
padding: 0 !important;
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: fit-content;
|
||||
max-width: 600px;
|
||||
min-width: 400px;
|
||||
max-height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Desktop styles (side sheet) */
|
||||
@media (min-width: 768px) {
|
||||
:global(.sheet-content) {
|
||||
width: 80vw !important;
|
||||
max-width: 600px !important;
|
||||
height: 100vh !important;
|
||||
border-radius: 0 !important;
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile styles (bottom sheet) */
|
||||
@media (max-width: 767px) {
|
||||
:global(.sheet-content) {
|
||||
height: 80vh !important;
|
||||
border-radius: 16px 16px 0 0 !important;
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
max-height: calc(90vh - 20px);
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.sheet-header) {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -293,7 +360,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.user-avatar) {
|
||||
@@ -314,13 +382,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.find-title) {
|
||||
font-family: 'Washington', serif !important;
|
||||
font-size: 1.25rem !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 0 !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
line-height: 1.3 !important;
|
||||
.find-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.find-meta {
|
||||
@@ -351,70 +419,55 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.sheet-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.media-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-viewer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
background: hsl(var(--muted));
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Desktop media viewer - maximize available space */
|
||||
@media (min-width: 768px) {
|
||||
.sheet-body {
|
||||
height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.media-container {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile media viewer - maximize available space */
|
||||
@media (max-width: 767px) {
|
||||
.sheet-body {
|
||||
height: calc(80vh - 140px);
|
||||
}
|
||||
|
||||
.media-container {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
flex: 1;
|
||||
min-height: 140px;
|
||||
max-height: 250px;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:global(.media-video) {
|
||||
@@ -479,9 +532,9 @@
|
||||
|
||||
.content-section {
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.description {
|
||||
@@ -497,7 +550,6 @@
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -545,9 +597,18 @@
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 640px) {
|
||||
:global(.sheet-header) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@@ -560,21 +621,12 @@
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
:global(.find-title) {
|
||||
font-size: 1.125rem !important;
|
||||
.find-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,10 @@
|
||||
description?: string;
|
||||
category?: string;
|
||||
locationName?: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
isPublic?: number;
|
||||
userId?: string;
|
||||
user: {
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
@@ -14,15 +18,19 @@
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
media?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
orderIndex?: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FindsListProps {
|
||||
finds: Find[];
|
||||
onFindExplore?: (id: string) => void;
|
||||
currentUserId?: string;
|
||||
onEdit?: (find: Find) => void;
|
||||
title?: string;
|
||||
showEmpty?: boolean;
|
||||
emptyMessage?: string;
|
||||
@@ -32,6 +40,8 @@
|
||||
let {
|
||||
finds,
|
||||
onFindExplore,
|
||||
currentUserId,
|
||||
onEdit,
|
||||
title = 'Finds',
|
||||
showEmpty = true,
|
||||
emptyMessage = 'No finds to display',
|
||||
@@ -59,11 +69,17 @@
|
||||
description={find.description}
|
||||
category={find.category}
|
||||
locationName={find.locationName}
|
||||
latitude={find.latitude}
|
||||
longitude={find.longitude}
|
||||
isPublic={find.isPublic}
|
||||
userId={find.userId}
|
||||
user={find.user}
|
||||
media={find.media}
|
||||
likeCount={find.likeCount}
|
||||
isLiked={find.isLiked}
|
||||
{currentUserId}
|
||||
onExplore={handleFindExplore}
|
||||
onEdit={() => onEdit?.(find)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
119
src/lib/components/finds/LikeButton.svelte
Normal file
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Heart } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
interface Props {
|
||||
findId: string;
|
||||
isLiked?: boolean;
|
||||
likeCount?: number;
|
||||
size?: 'sm' | 'default' | 'lg';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
findId,
|
||||
isLiked = false,
|
||||
likeCount = 0,
|
||||
size = 'default',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
// Local state stores for this like button - start with props but will be overridden by global state
|
||||
const likeState = writable({
|
||||
isLiked: isLiked,
|
||||
likeCount: likeCount,
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
let apiSync: typeof import('$lib/stores/api-sync').apiSync | null = null;
|
||||
|
||||
// Initialize API sync and subscribe to global state
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
try {
|
||||
// Dynamically import the API sync
|
||||
const module = await import('$lib/stores/api-sync');
|
||||
apiSync = module.apiSync;
|
||||
|
||||
// Check if global state already exists for this find
|
||||
const existingState = apiSync.getEntityState('find', findId);
|
||||
|
||||
if (existingState) {
|
||||
// Use existing global state - it's more current than props
|
||||
console.log(`Using existing global state for find ${findId}`, existingState);
|
||||
} else {
|
||||
// Initialize with minimal data only if no global state exists
|
||||
console.log(`Initializing new minimal state for find ${findId}`);
|
||||
apiSync.setEntityState('find', findId, {
|
||||
id: findId,
|
||||
isLikedByUser: isLiked,
|
||||
likeCount: likeCount,
|
||||
// Minimal data needed for like functionality
|
||||
title: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
isPublic: true,
|
||||
createdAt: new Date(),
|
||||
userId: '',
|
||||
username: '',
|
||||
isFromFriend: false
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to global state for this find
|
||||
const globalLikeState = apiSync.subscribeFindLikes(findId);
|
||||
globalLikeState.subscribe(
|
||||
(state: { isLiked: boolean; likeCount: number; isLoading: boolean }) => {
|
||||
likeState.set({
|
||||
isLiked: state.isLiked,
|
||||
likeCount: state.likeCount,
|
||||
isLoading: state.isLoading
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize API sync:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function toggleLike() {
|
||||
if (!apiSync || !browser) return;
|
||||
|
||||
if ($likeState.isLoading) return;
|
||||
|
||||
try {
|
||||
await apiSync.toggleLike(findId);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle like:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
{size}
|
||||
class="group gap-1.5 {className}"
|
||||
onclick={toggleLike}
|
||||
disabled={$likeState.isLoading}
|
||||
>
|
||||
<Heart
|
||||
class="h-4 w-4 transition-all duration-200 {$likeState.isLiked
|
||||
? 'scale-110 fill-red-500 text-red-500'
|
||||
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {$likeState.isLoading
|
||||
? 'animate-pulse'
|
||||
: ''}"
|
||||
/>
|
||||
{#if $likeState.likeCount > 0}
|
||||
<span
|
||||
class="text-sm font-medium transition-colors {$likeState.isLiked
|
||||
? 'text-red-500'
|
||||
: 'text-gray-500 group-hover:text-red-400'}"
|
||||
>
|
||||
{$likeState.likeCount}
|
||||
</span>
|
||||
{/if}
|
||||
</Button>
|
||||
156
src/lib/components/finds/Rating.svelte
Normal file
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import { Star } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
findId: string;
|
||||
initialRating?: number;
|
||||
initialCount?: number;
|
||||
userRating?: number | null;
|
||||
onRatingChange?: (rating: number) => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
findId,
|
||||
initialRating = 0,
|
||||
initialCount = 0,
|
||||
userRating = null,
|
||||
onRatingChange,
|
||||
readonly = false
|
||||
}: Props = $props();
|
||||
|
||||
let rating = $state(initialRating);
|
||||
let ratingCount = $state(initialCount);
|
||||
let currentUserRating = $state(userRating);
|
||||
let hoverRating = $state(0);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
async function handleRate(stars: number) {
|
||||
if (readonly || isSubmitting) return;
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/finds/${findId}/rate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ rating: stars })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to rate find');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
rating = data.rating / 100;
|
||||
ratingCount = data.ratingCount;
|
||||
currentUserRating = stars;
|
||||
|
||||
if (onRatingChange) {
|
||||
onRatingChange(stars);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rating find:', error);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayRating() {
|
||||
if (readonly) {
|
||||
return rating;
|
||||
}
|
||||
return hoverRating || rating;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rating-container">
|
||||
<div class="stars">
|
||||
{#each [1, 2, 3, 4, 5] as star}
|
||||
<button
|
||||
class="star-button"
|
||||
class:filled={star <= getDisplayRating()}
|
||||
class:user-rated={!readonly && currentUserRating && star <= currentUserRating}
|
||||
class:readonly
|
||||
disabled={readonly || isSubmitting}
|
||||
onclick={() => handleRate(star)}
|
||||
onmouseenter={() => !readonly && (hoverRating = star)}
|
||||
onmouseleave={() => !readonly && (hoverRating = 0)}
|
||||
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
class="star-icon"
|
||||
fill={star <= getDisplayRating() ? 'currentColor' : 'none'}
|
||||
size={readonly ? 16 : 24}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if ratingCount > 0}
|
||||
<span class="rating-info">
|
||||
{(rating || 0).toFixed(1)} ({ratingCount}
|
||||
{ratingCount === 1 ? 'rating' : 'ratings'})
|
||||
</span>
|
||||
{:else if !readonly}
|
||||
<span class="rating-info">Be the first to rate</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rating-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stars {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.star-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.star-button:not(.readonly):hover {
|
||||
transform: scale(1.1);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.star-button.filled {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.star-button.user-rated {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.star-button.readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.star-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.rating-info {
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.star-icon) {
|
||||
stroke-width: 2;
|
||||
}
|
||||
</style>
|
||||
12
src/lib/components/finds/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as Comment } from './Comment.svelte';
|
||||
export { default as CommentForm } from './CommentForm.svelte';
|
||||
export { default as CommentsList } from './CommentsList.svelte';
|
||||
export { default as Comments } from './Comments.svelte';
|
||||
export { default as CreateFindModal } from './CreateFindModal.svelte';
|
||||
export { default as EditFindModal } from './EditFindModal.svelte';
|
||||
export { default as FindCard } from './FindCard.svelte';
|
||||
export { default as FindPreview } from './FindPreview.svelte';
|
||||
export { default as FindsFilter } from './FindsFilter.svelte';
|
||||
export { default as FindsList } from './FindsList.svelte';
|
||||
export { default as LikeButton } from './LikeButton.svelte';
|
||||
export { default as Rating } from './Rating.svelte';
|
||||
457
src/lib/components/locations/CreateLocationModal.svelte
Normal file
@@ -0,0 +1,457 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Label } from '$lib/components/label';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { coordinates } from '$lib/stores/location';
|
||||
import POISearch from '../map/POISearch.svelte';
|
||||
import type { PlaceResult } from '$lib/utils/places';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onLocationCreated: (event: CustomEvent<{ locationId: string; reload?: boolean }>) => void;
|
||||
}
|
||||
|
||||
let { isOpen, onClose, onLocationCreated }: Props = $props();
|
||||
|
||||
let latitude = $state('');
|
||||
let longitude = $state('');
|
||||
let locationName = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let useManualLocation = $state(false);
|
||||
|
||||
let isMobile = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const checkIsMobile = () => {
|
||||
isMobile = window.innerWidth < 768;
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener('resize', checkIsMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen && $coordinates) {
|
||||
latitude = $coordinates.latitude.toString();
|
||||
longitude = $coordinates.longitude.toString();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const lat = parseFloat(latitude);
|
||||
const lng = parseFloat(longitude);
|
||||
|
||||
if (isNaN(lat) || isNaN(lng)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/locations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
locationName: locationName.trim() || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create location');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
resetForm();
|
||||
onLocationCreated(
|
||||
new CustomEvent('locationCreated', {
|
||||
detail: { locationId: result.location.id, reload: true }
|
||||
})
|
||||
);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating location:', error);
|
||||
alert('Failed to create location. Please try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlaceSelected(place: PlaceResult) {
|
||||
locationName = place.name;
|
||||
latitude = place.latitude.toString();
|
||||
longitude = place.longitude.toString();
|
||||
}
|
||||
|
||||
function toggleLocationMode() {
|
||||
useManualLocation = !useManualLocation;
|
||||
if (!useManualLocation && $coordinates) {
|
||||
latitude = $coordinates.latitude.toString();
|
||||
longitude = $coordinates.longitude.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
latitude = '';
|
||||
longitude = '';
|
||||
locationName = '';
|
||||
useManualLocation = false;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Create Location</h2>
|
||||
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="form"
|
||||
>
|
||||
<div class="modal-body">
|
||||
<p class="description">
|
||||
Choose a location where you and others can create finds (posts). This will be a point on
|
||||
the map where discoveries can be shared.
|
||||
</p>
|
||||
|
||||
<div class="location-section">
|
||||
<div class="location-header">
|
||||
<Label>Location</Label>
|
||||
<button type="button" onclick={toggleLocationMode} class="toggle-button">
|
||||
{useManualLocation ? 'Use Search' : 'Manual Entry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !useManualLocation}
|
||||
<POISearch
|
||||
onPlaceSelected={handlePlaceSelected}
|
||||
placeholder="Search for a place..."
|
||||
label=""
|
||||
showNearbyButton={true}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label for="location-name">Location Name (Optional)</Label>
|
||||
<Input
|
||||
name="location-name"
|
||||
type="text"
|
||||
placeholder="Café Central, Brussels"
|
||||
bind:value={locationName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if useManualLocation || (!latitude && !longitude)}
|
||||
<div class="field-group">
|
||||
<div class="field">
|
||||
<Label for="latitude">Latitude</Label>
|
||||
<Input name="latitude" type="text" required bind:value={latitude} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label for="longitude">Longitude</Label>
|
||||
<Input name="longitude" type="text" required bind:value={longitude} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if latitude && longitude}
|
||||
<div class="coordinates-display">
|
||||
<Label>Selected coordinates</Label>
|
||||
<div class="coordinates-info">
|
||||
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
|
||||
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (useManualLocation = true)}
|
||||
class="edit-coords-button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !latitude || !longitude}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Location'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
max-height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
max-height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.form {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-footer :global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.location-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.location-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.coordinates-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.coordinate {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.edit-coords-button {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-coords-button:hover {
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
257
src/lib/components/locations/LocationCard.svelte
Normal file
@@ -0,0 +1,257 @@
|
||||
<script lang="ts">
|
||||
import { formatDistance } from '$lib/utils/distance';
|
||||
import { Star } from 'lucide-svelte';
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string | null;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
findCount: number;
|
||||
averageRating?: number | null;
|
||||
ratingCount?: 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.locationName}
|
||||
{location.locationName}
|
||||
{:else 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>
|
||||
|
||||
{#if location.averageRating && (location.ratingCount || 0) > 0}
|
||||
<div class="meta-item rating">
|
||||
<Star size={16} fill="currentColor" class="star-icon" />
|
||||
<span>{(location.averageRating / 100).toFixed(1)} ({location.ratingCount})</span>
|
||||
</div>
|
||||
{/if}
|
||||
</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;
|
||||
}
|
||||
|
||||
.meta-item.rating {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
:global(.star-icon) {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.explore-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explore-button:hover {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.explore-button svg {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.explore-button:hover svg {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.location-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.explore-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.distance-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
391
src/lib/components/locations/LocationFindsModal.svelte
Normal file
@@ -0,0 +1,391 @@
|
||||
<script lang="ts">
|
||||
import FindsList from '../finds/FindsList.svelte';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { goto } from '$app/navigation';
|
||||
import EditFindModal from '../finds/EditFindModal.svelte';
|
||||
|
||||
interface Find {
|
||||
id: string;
|
||||
locationId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
locationName?: string;
|
||||
isPublic: number;
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
likeCount?: number;
|
||||
isLikedByUser?: boolean;
|
||||
isFromFriend?: boolean;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
media?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
orderIndex?: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
findCount: number;
|
||||
finds?: Find[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
location: Location | null;
|
||||
currentUserId?: string;
|
||||
onClose: () => void;
|
||||
onCreateFind?: () => void;
|
||||
}
|
||||
|
||||
let { isOpen, location, currentUserId, onClose, onCreateFind }: Props = $props();
|
||||
|
||||
let isMobile = $state(false);
|
||||
let showEditModal = $state(false);
|
||||
let findToEdit = $state<Find | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const checkIsMobile = () => {
|
||||
isMobile = window.innerWidth < 768;
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener('resize', checkIsMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
function handleCreateFind() {
|
||||
onCreateFind?.();
|
||||
}
|
||||
|
||||
function handleFindExplore(findId: string) {
|
||||
goto(`/finds/${findId}`);
|
||||
}
|
||||
|
||||
function handleFindEdit(findData: any) {
|
||||
const find = location?.finds?.find((f) => f.id === findData.id);
|
||||
if (find) {
|
||||
findToEdit = find;
|
||||
showEditModal = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditModalClose() {
|
||||
showEditModal = false;
|
||||
findToEdit = null;
|
||||
}
|
||||
|
||||
function handleFindUpdated() {
|
||||
showEditModal = false;
|
||||
findToEdit = null;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function handleFindDeleted() {
|
||||
showEditModal = false;
|
||||
findToEdit = null;
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen && location}
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="header-info">
|
||||
<h2 class="modal-title">Location Finds</h2>
|
||||
<p class="location-coords">
|
||||
{parseFloat(location.latitude).toFixed(4)}, {parseFloat(location.longitude).toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
{#if location.finds && location.finds.length > 0}
|
||||
<FindsList
|
||||
finds={location.finds.map((find) => ({
|
||||
id: find.id,
|
||||
locationId: find.locationId,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
category: find.category,
|
||||
locationName: find.locationName,
|
||||
latitude: find.latitude,
|
||||
longitude: find.longitude,
|
||||
isPublic: find.isPublic,
|
||||
userId: find.userId,
|
||||
username: find.username,
|
||||
profilePictureUrl: find.profilePictureUrl,
|
||||
user: {
|
||||
username: find.username,
|
||||
profilePictureUrl: find.profilePictureUrl
|
||||
},
|
||||
likeCount: find.likeCount,
|
||||
isLiked: find.isLikedByUser,
|
||||
media: find.media
|
||||
}))}
|
||||
hideTitle={true}
|
||||
{currentUserId}
|
||||
onFindExplore={handleFindExplore}
|
||||
onEdit={handleFindEdit}
|
||||
/>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="empty-title">No finds yet</h3>
|
||||
<p class="empty-message">Be the first to share a discovery at this location!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if currentUserId}
|
||||
<div class="modal-footer">
|
||||
<Button onclick={handleCreateFind} class="w-full">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
Create Find Here
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showEditModal && findToEdit}
|
||||
<EditFindModal
|
||||
isOpen={showEditModal}
|
||||
find={{
|
||||
id: findToEdit.id,
|
||||
title: findToEdit.title,
|
||||
description: findToEdit.description || null,
|
||||
latitude: findToEdit.latitude || location?.latitude || '0',
|
||||
longitude: findToEdit.longitude || location?.longitude || '0',
|
||||
locationName: findToEdit.locationName || null,
|
||||
category: findToEdit.category || null,
|
||||
isPublic: findToEdit.isPublic ?? 1,
|
||||
media: (findToEdit.media || []).map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || null,
|
||||
orderIndex: m.orderIndex ?? null
|
||||
}))
|
||||
}}
|
||||
onClose={handleEditModalClose}
|
||||
onFindUpdated={handleFindUpdated}
|
||||
onFindDeleted={handleFindDeleted}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.location-coords {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 1.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.w-full) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.mr-2) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
189
src/lib/components/locations/LocationsList.svelte
Normal file
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import LocationCard from './LocationCard.svelte';
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string | null;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
findCount: number;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
locations: Location[];
|
||||
onLocationExplore?: (id: string) => void;
|
||||
title?: string;
|
||||
showEmpty?: boolean;
|
||||
emptyMessage?: string;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
locations,
|
||||
onLocationExplore,
|
||||
title = 'Locations',
|
||||
showEmpty = true,
|
||||
emptyMessage = 'No locations nearby',
|
||||
hideTitle = false
|
||||
}: Props = $props();
|
||||
|
||||
function handleLocationExplore(id: string) {
|
||||
onLocationExplore?.(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="locations-feed">
|
||||
{#if !hideTitle}
|
||||
<div class="feed-header">
|
||||
<h2 class="feed-title">{title}</h2>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if locations.length > 0}
|
||||
<div class="feed-container">
|
||||
{#each locations as location (location.id)}
|
||||
<LocationCard {location} onExplore={handleLocationExplore} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if showEmpty}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="empty-title">No locations discovered yet</h3>
|
||||
<p class="empty-message">{emptyMessage}</p>
|
||||
<div class="empty-action">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>Create a location to start sharing finds</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.locations-feed {
|
||||
width: 100%;
|
||||
padding: 0 24px 24px 24px;
|
||||
}
|
||||
|
||||
.feed-header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.feed-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
background: hsl(var(--card));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 1.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0 0 1.5rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--primary));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.feed-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.feed-container {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
:global(.location-card) {
|
||||
animation: fadeInUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1077
src/lib/components/locations/SelectLocationModal.svelte
Normal file
5
src/lib/components/locations/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as LocationCard } from './LocationCard.svelte';
|
||||
export { default as LocationsList } from './LocationsList.svelte';
|
||||
export { default as CreateLocationModal } from './CreateLocationModal.svelte';
|
||||
export { default as SelectLocationModal } from './SelectLocationModal.svelte';
|
||||
export { default as LocationFindsModal } from './LocationFindsModal.svelte';
|
||||
81
src/lib/components/map/LocationManager.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { locationActions, isWatching, coordinates } from '$lib/stores/location';
|
||||
|
||||
interface Props {
|
||||
autoStart?: boolean;
|
||||
enableHighAccuracy?: boolean;
|
||||
timeout?: number;
|
||||
maximumAge?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
autoStart = true,
|
||||
enableHighAccuracy = true,
|
||||
timeout = 15000,
|
||||
maximumAge = 60000
|
||||
}: Props = $props();
|
||||
|
||||
// Location watching options
|
||||
const watchOptions = {
|
||||
enableHighAccuracy,
|
||||
timeout,
|
||||
maximumAge
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (!browser || !autoStart) return;
|
||||
|
||||
// Check if geolocation is supported
|
||||
if (!navigator.geolocation) {
|
||||
console.warn('Geolocation is not supported by this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we already have coordinates and aren't watching
|
||||
if ($coordinates && !$isWatching) {
|
||||
// Start watching immediately if we have previous coordinates
|
||||
startLocationWatching();
|
||||
return;
|
||||
}
|
||||
|
||||
// If no coordinates, try to get current location first
|
||||
if (!$coordinates) {
|
||||
getCurrentLocationThenWatch();
|
||||
}
|
||||
});
|
||||
|
||||
async function getCurrentLocationThenWatch() {
|
||||
try {
|
||||
const result = await locationActions.getCurrentLocation(watchOptions);
|
||||
if (result) {
|
||||
// Successfully got location, now start watching
|
||||
startLocationWatching();
|
||||
}
|
||||
} catch {
|
||||
// If we can't get location due to permissions, don't auto-start watching
|
||||
console.log('Could not get initial location, location watching not started automatically');
|
||||
}
|
||||
}
|
||||
|
||||
function startLocationWatching() {
|
||||
if (!$isWatching) {
|
||||
locationActions.startWatching(watchOptions);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function to stop watching when component is destroyed
|
||||
function cleanup() {
|
||||
if ($isWatching) {
|
||||
locationActions.stopWatching();
|
||||
}
|
||||
}
|
||||
|
||||
// Stop watching when the component is destroyed
|
||||
onMount(() => {
|
||||
return cleanup;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- This component doesn't render anything, it just manages location watching -->
|
||||
606
src/lib/components/map/Map.svelte
Normal file
@@ -0,0 +1,606 @@
|
||||
<script lang="ts">
|
||||
import { MapLibre, Marker } from 'svelte-maplibre';
|
||||
import type { StyleSpecification } from 'svelte-maplibre';
|
||||
import { untrack } from 'svelte';
|
||||
import {
|
||||
coordinates,
|
||||
getMapCenter,
|
||||
getMapZoom,
|
||||
shouldZoomToLocation,
|
||||
locationActions,
|
||||
isWatching
|
||||
} from '$lib/stores/location';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
finds: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
isPublic: number;
|
||||
media?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
class?: string;
|
||||
autoCenter?: boolean;
|
||||
locations?: Location[];
|
||||
onLocationClick?: (location: Location) => void;
|
||||
sidebarVisible?: boolean;
|
||||
}
|
||||
|
||||
// Map styles - Positron for light mode, Dark Matter for dark mode
|
||||
const LIGHT_STYLE = '/map-styles/positron.json';
|
||||
const DARK_STYLE = '/map-styles/dark-matter.json';
|
||||
|
||||
// Detect dark mode preference
|
||||
let isDarkMode = $state(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
// Compute map style based on dark mode preference
|
||||
const mapStyle = $derived(isDarkMode ? DARK_STYLE : LIGHT_STYLE);
|
||||
|
||||
let {
|
||||
center,
|
||||
zoom,
|
||||
class: className = '',
|
||||
autoCenter = true,
|
||||
locations = [],
|
||||
onLocationClick,
|
||||
sidebarVisible = false
|
||||
}: Props = $props();
|
||||
|
||||
let mapLoaded = $state(false);
|
||||
let styleLoaded = $state(false);
|
||||
let isIdle = $state(false);
|
||||
let mapInstance: any = $state(null);
|
||||
let userHasMovedMap = $state(false);
|
||||
let initialCenter: [number, number] = center || [0, 51.505];
|
||||
let initialZoom: number = zoom || 13;
|
||||
|
||||
// Use a plain variable (not reactive) to track programmatic moves
|
||||
let isProgrammaticMove = false;
|
||||
|
||||
// Listen for system theme changes
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleThemeChange = (e: MediaQueryListEvent) => {
|
||||
isDarkMode = e.matches;
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleThemeChange);
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleThemeChange);
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate padding for map centering based on sidebar visibility
|
||||
const getMapPadding = $derived.by(() => {
|
||||
if (!sidebarVisible) {
|
||||
return { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
}
|
||||
|
||||
// Check if we're on mobile (sidebar at bottom) or desktop (sidebar on left)
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768;
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, sidebar is at bottom
|
||||
// Sidebar takes up about 60vh, so add padding at bottom to shift center up
|
||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800;
|
||||
const sidebarHeight = viewportHeight * 0.6;
|
||||
return { top: 0, bottom: sidebarHeight / 2, left: 0, right: 0 };
|
||||
} else {
|
||||
// On desktop, sidebar is on left
|
||||
// Calculate sidebar width: 40% of viewport, max 1000px, min 500px
|
||||
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
|
||||
const sidebarWidth = Math.min(1000, Math.max(500, viewportWidth * 0.4));
|
||||
|
||||
// Add left padding of half sidebar width to shift center to the right
|
||||
// This centers the location in the visible (non-sidebar) area
|
||||
return { top: 0, bottom: 0, left: sidebarWidth / 2, right: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
// Handle comprehensive map loading events
|
||||
function handleStyleLoad() {
|
||||
styleLoaded = true;
|
||||
}
|
||||
|
||||
function handleIdle() {
|
||||
isIdle = true;
|
||||
}
|
||||
|
||||
// Map is considered fully ready when it's loaded, style is loaded, and it's idle
|
||||
const mapReady = $derived(mapLoaded && styleLoaded && isIdle);
|
||||
|
||||
// Check if map is centered on user location (approximately)
|
||||
const isCenteredOnUser = $derived.by(() => {
|
||||
if (!$coordinates || !mapInstance) return false;
|
||||
|
||||
const center = mapInstance.getCenter();
|
||||
const userLng = $coordinates.longitude;
|
||||
const userLat = $coordinates.latitude;
|
||||
|
||||
// Check if within ~100m (roughly 0.001 degrees)
|
||||
const threshold = 0.001;
|
||||
return Math.abs(center.lng - userLng) < threshold && Math.abs(center.lat - userLat) < threshold;
|
||||
});
|
||||
|
||||
// Effect to handle recenter trigger
|
||||
$effect(() => {
|
||||
if ($shouldZoomToLocation && mapInstance && $coordinates) {
|
||||
// Use untrack to avoid tracking getMapZoom changes inside this effect
|
||||
untrack(() => {
|
||||
// Mark this as a programmatic move
|
||||
isProgrammaticMove = true;
|
||||
userHasMovedMap = false;
|
||||
|
||||
// Fly to the user's location with padding based on sidebar
|
||||
mapInstance.flyTo({
|
||||
center: [$coordinates.longitude, $coordinates.latitude],
|
||||
zoom: $getMapZoom,
|
||||
padding: getMapPadding,
|
||||
duration: 1000
|
||||
});
|
||||
|
||||
// Clear the trigger and reset flag after animation
|
||||
setTimeout(() => {
|
||||
locationActions.clearZoomTrigger();
|
||||
isProgrammaticMove = false;
|
||||
}, 1100);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to center on user location when map first loads (if autoCenter is true)
|
||||
let hasInitialCentered = $state(false);
|
||||
$effect(() => {
|
||||
if (autoCenter && mapReady && $coordinates && !hasInitialCentered) {
|
||||
untrack(() => {
|
||||
isProgrammaticMove = true;
|
||||
hasInitialCentered = true;
|
||||
mapInstance.flyTo({
|
||||
center: [$coordinates.longitude, $coordinates.latitude],
|
||||
zoom: $getMapZoom,
|
||||
padding: getMapPadding,
|
||||
duration: 1000
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
isProgrammaticMove = false;
|
||||
}, 1100);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to attach move listener to map instance (only depends on mapInstance)
|
||||
$effect(() => {
|
||||
if (!mapInstance) return;
|
||||
|
||||
const handleMoveEnd = () => {
|
||||
// Only mark as user move if it's not programmatic
|
||||
if (!isProgrammaticMove) {
|
||||
userHasMovedMap = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Use 'moveend' to capture when user finishes moving the map
|
||||
mapInstance.on('moveend', handleMoveEnd);
|
||||
|
||||
return () => {
|
||||
mapInstance.off('moveend', handleMoveEnd);
|
||||
};
|
||||
});
|
||||
|
||||
// Effect to adjust map center when sidebar visibility changes
|
||||
$effect(() => {
|
||||
if (mapInstance && mapReady && $coordinates) {
|
||||
// React to sidebar visibility changes
|
||||
const padding = getMapPadding;
|
||||
|
||||
untrack(() => {
|
||||
isProgrammaticMove = true;
|
||||
|
||||
// Smoothly adjust the map to account for sidebar
|
||||
mapInstance.easeTo({
|
||||
center: [$coordinates.longitude, $coordinates.latitude],
|
||||
padding: padding,
|
||||
duration: 300
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
isProgrammaticMove = false;
|
||||
}, 350);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function recenterMap() {
|
||||
if (!$coordinates) return;
|
||||
|
||||
// Trigger zoom to location
|
||||
locationActions.getCurrentLocation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="map-container {className}">
|
||||
{#if !mapReady}
|
||||
<div class="map-skeleton">
|
||||
<Skeleton class="h-full w-full rounded-xl" />
|
||||
<div class="skeleton-overlay">
|
||||
<Skeleton class="mb-2 h-4 w-16" />
|
||||
<Skeleton class="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="map-wrapper" class:hidden={!mapReady}>
|
||||
<MapLibre
|
||||
style={mapStyle}
|
||||
center={initialCenter}
|
||||
zoom={initialZoom}
|
||||
bind:map={mapInstance}
|
||||
bind:loaded={mapLoaded}
|
||||
onstyleload={handleStyleLoad}
|
||||
onidle={handleIdle}
|
||||
>
|
||||
{#if $coordinates}
|
||||
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
|
||||
<div class="location-marker" class:watching={$isWatching}>
|
||||
<div class="marker-pulse" class:watching={$isWatching}></div>
|
||||
<div class="marker-outer" class:watching={$isWatching}>
|
||||
<div class="marker-inner" class:watching={$isWatching}></div>
|
||||
</div>
|
||||
{#if $isWatching}
|
||||
<div class="watching-ring"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</Marker>
|
||||
{/if}
|
||||
|
||||
{#each locations as location (location.id)}
|
||||
<Marker lngLat={[parseFloat(location.longitude), parseFloat(location.latitude)]}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="location-pin-marker"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onLocationClick?.(location)}
|
||||
title={`${location.finds.length} find${location.finds.length !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<div class="location-pin-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<circle cx="12" cy="9" r="2.5" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="location-find-count">
|
||||
{location.finds.length}
|
||||
</div>
|
||||
{#if location.finds.length > 0 && location.finds[0].media && location.finds[0].media.length > 0}
|
||||
<div class="location-marker-preview">
|
||||
<img src={location.finds[0].media[0].thumbnailUrl} alt="Preview" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Marker>
|
||||
{/each}
|
||||
</MapLibre>
|
||||
|
||||
<!-- Recenter button - only show when user has moved map and has coordinates -->
|
||||
{#if userHasMovedMap && !isCenteredOnUser && $coordinates}
|
||||
<button
|
||||
class="recenter-button"
|
||||
onclick={recenterMap}
|
||||
type="button"
|
||||
aria-label="Recenter on my location"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-skeleton {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeleton-overlay {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-wrapper.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-container :global(.maplibregl-map) {
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Location marker styles */
|
||||
:global(.location-marker) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.marker-outer) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(37, 99, 235, 0.2);
|
||||
border: 2px solid #2563eb;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.marker-outer.watching) {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
:global(.marker-inner) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.marker-inner.watching) {
|
||||
background: #f59e0b;
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
:global(.marker-pulse) {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(37, 99, 235, 0.6);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
:global(.marker-pulse.watching) {
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
animation: pulse-watching 1.5s infinite;
|
||||
}
|
||||
|
||||
:global(.watching-ring) {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2px solid rgba(245, 158, 11, 0.4);
|
||||
border-radius: 50%;
|
||||
animation: expand-ring 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-watching {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expand-ring {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Location pin marker styles */
|
||||
:global(.location-pin-marker) {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transform: translate(-50%, -100%);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.location-pin-marker:hover) {
|
||||
transform: translate(-50%, -100%) scale(1.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
:global(.location-pin-icon) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ff6b35;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
:global(.location-find-count) {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: white;
|
||||
color: #ff6b35;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
:global(.location-marker-preview) {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
:global(.location-marker-preview img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recenter-button {
|
||||
position: absolute;
|
||||
top: 100px;
|
||||
right: 20px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: white;
|
||||
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.recenter-button:hover {
|
||||
background: #f0f9ff;
|
||||
border-color: #2563eb;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.recenter-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.map-container {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.location-controls {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.recenter-button {
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -36,17 +36,16 @@
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
action: 'autocomplete',
|
||||
query: query.trim()
|
||||
});
|
||||
const searchParams = new URL('/api/places', window.location.origin).searchParams;
|
||||
searchParams.set('action', 'autocomplete');
|
||||
searchParams.set('query', query.trim());
|
||||
|
||||
if ($coordinates) {
|
||||
params.set('lat', $coordinates.latitude.toString());
|
||||
params.set('lng', $coordinates.longitude.toString());
|
||||
searchParams.set('lat', $coordinates.latitude.toString());
|
||||
searchParams.set('lng', $coordinates.longitude.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/places?${params}`);
|
||||
const response = await fetch(`/api/places?${searchParams}`);
|
||||
if (response.ok) {
|
||||
suggestions = await response.json();
|
||||
showSuggestions = true;
|
||||
@@ -179,7 +178,7 @@
|
||||
<div class="suggestion-content">
|
||||
<span class="suggestion-name">{suggestion.description}</span>
|
||||
<div class="suggestion-types">
|
||||
{#each suggestion.types.slice(0, 2) as type}
|
||||
{#each suggestion.types.slice(0, 2) as type, index (index)}
|
||||
<span class="suggestion-type">{type.replace(/_/g, ' ')}</span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -274,7 +273,7 @@
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: hsl(var(--background));
|
||||
background: hsl(var(--background) / 0.95);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
@@ -282,7 +281,7 @@
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
margin-top: 0.25rem;
|
||||
backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.suggestions-header {
|
||||
@@ -291,7 +290,7 @@
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--muted) / 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
@@ -302,7 +301,7 @@
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
background: hsl(var(--background));
|
||||
background: hsl(var(--background) / 0.95);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
@@ -315,7 +314,7 @@
|
||||
}
|
||||
|
||||
.suggestion-item:hover:not(:disabled) {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
background: hsl(var(--muted) / 0.8);
|
||||
}
|
||||
|
||||
.suggestion-item:disabled {
|
||||
3
src/lib/components/map/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as LocationManager } from './LocationManager.svelte';
|
||||
export { default as Map } from './Map.svelte';
|
||||
export { default as POISearch } from './POISearch.svelte';
|
||||
1
src/lib/components/media/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as VideoPlayer } from './VideoPlayer.svelte';
|
||||
194
src/lib/components/notifications/NotificationManager.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import NotificationPrompt from './NotificationPrompt.svelte';
|
||||
|
||||
/**
|
||||
* NotificationManager - Handles push notification subscription
|
||||
* Shows a prompt for users to enable notifications (requires user gesture for iOS)
|
||||
*/
|
||||
|
||||
let permissionStatus = $state<NotificationPermission>('default');
|
||||
let showPrompt = $state<boolean>(false);
|
||||
let isSupported = $state<boolean>(false);
|
||||
|
||||
const PROMPT_DISMISSED_KEY = 'notification-prompt-dismissed';
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
|
||||
// Check if notifications and service workers are supported
|
||||
isSupported = 'Notification' in window && 'serviceWorker' in navigator;
|
||||
|
||||
if (!isSupported) {
|
||||
console.log('Notifications or service workers not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize without requesting permission
|
||||
initializeNotifications();
|
||||
});
|
||||
|
||||
async function initializeNotifications() {
|
||||
try {
|
||||
console.log('[NotificationManager] Starting initialization...');
|
||||
|
||||
// Get current permission status
|
||||
permissionStatus = Notification.permission;
|
||||
console.log('[NotificationManager] Permission status:', permissionStatus);
|
||||
|
||||
// If already granted, subscribe automatically
|
||||
if (permissionStatus === 'granted') {
|
||||
console.log('[NotificationManager] Permission already granted');
|
||||
await subscribeToNotifications();
|
||||
}
|
||||
// If permission is default and not dismissed, show prompt
|
||||
else if (permissionStatus === 'default') {
|
||||
const dismissed = localStorage.getItem(PROMPT_DISMISSED_KEY);
|
||||
if (!dismissed) {
|
||||
showPrompt = true;
|
||||
}
|
||||
}
|
||||
// If denied, do nothing
|
||||
else {
|
||||
console.log('[NotificationManager] Permission denied by user');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationManager] Error initializing notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnableNotifications() {
|
||||
try {
|
||||
console.log('[NotificationManager] User clicked enable notifications');
|
||||
showPrompt = false;
|
||||
|
||||
// Request permission (this is triggered by user gesture, so iOS will allow it)
|
||||
permissionStatus = await Notification.requestPermission();
|
||||
console.log('[NotificationManager] Permission response:', permissionStatus);
|
||||
|
||||
if (permissionStatus === 'granted') {
|
||||
await subscribeToNotifications();
|
||||
} else {
|
||||
console.log('[NotificationManager] Permission not granted');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationManager] Error enabling notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismissPrompt() {
|
||||
console.log('[NotificationManager] User dismissed notification prompt');
|
||||
showPrompt = false;
|
||||
localStorage.setItem(PROMPT_DISMISSED_KEY, 'true');
|
||||
}
|
||||
|
||||
async function subscribeToNotifications() {
|
||||
try {
|
||||
console.log('[NotificationManager] subscribeToNotifications called');
|
||||
|
||||
// Get or register service worker
|
||||
let registration = await navigator.serviceWorker.getRegistration();
|
||||
|
||||
if (!registration) {
|
||||
console.log('[NotificationManager] No SW found, registering...');
|
||||
registration = await navigator.serviceWorker.register('/service-worker.js', {
|
||||
type: 'module'
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for service worker to be ready
|
||||
await navigator.serviceWorker.ready;
|
||||
console.log('[NotificationManager] Service worker ready');
|
||||
|
||||
// Get VAPID public key from server
|
||||
console.log('[NotificationManager] Fetching VAPID key...');
|
||||
const response = await fetch('/api/notifications/subscribe');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get VAPID public key');
|
||||
}
|
||||
|
||||
const { publicKey } = await response.json();
|
||||
console.log('[NotificationManager] Got VAPID key:', publicKey);
|
||||
|
||||
// Check if already subscribed
|
||||
console.log('[NotificationManager] Checking existing subscription...');
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
console.log('[NotificationManager] Existing subscription:', subscription);
|
||||
|
||||
// If not subscribed, create new subscription
|
||||
if (!subscription) {
|
||||
console.log('[NotificationManager] Creating new subscription...');
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||||
});
|
||||
console.log('[NotificationManager] Subscription created:', subscription);
|
||||
}
|
||||
|
||||
// Send subscription to server
|
||||
console.log('[NotificationManager] Sending subscription to server...');
|
||||
const saveResponse = await fetch('/api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscription: {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
|
||||
auth: arrayBufferToBase64(subscription.getKey('auth'))
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
console.log('[NotificationManager] Save response status:', saveResponse.status);
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
const errorText = await saveResponse.text();
|
||||
console.error('[NotificationManager] Save failed:', errorText);
|
||||
throw new Error('Failed to save subscription to server');
|
||||
}
|
||||
|
||||
console.log('[NotificationManager] Successfully subscribed to push notifications!');
|
||||
} catch (error) {
|
||||
console.error('[NotificationManager] Error subscribing to push notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert VAPID public key from base64 to Uint8Array
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray as Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to base64 string
|
||||
*/
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer | null): string {
|
||||
if (!buffer) return '';
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showPrompt}
|
||||
<NotificationPrompt onEnable={handleEnableNotifications} onDismiss={handleDismissPrompt} />
|
||||
{/if}
|
||||
173
src/lib/components/notifications/NotificationPrompt.svelte
Normal file
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { X, Bell } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
onEnable: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
let { onEnable, onDismiss }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="notification-prompt">
|
||||
<div class="notification-prompt-content">
|
||||
<div class="notification-prompt-icon">
|
||||
<Bell size={20} />
|
||||
</div>
|
||||
<div class="notification-prompt-text">
|
||||
<h3>Enable Notifications</h3>
|
||||
<p>Stay updated when friends like or comment on your finds</p>
|
||||
</div>
|
||||
<div class="notification-prompt-actions">
|
||||
<button class="enable-button" onclick={onEnable}>Enable</button>
|
||||
<button class="dismiss-button" onclick={onDismiss} aria-label="Dismiss notification prompt">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.notification-prompt {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-prompt-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.notification-prompt-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.notification-prompt-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-prompt-text h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.notification-prompt-text p {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-prompt-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.enable-button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.enable-button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.enable-button:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.dismiss-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dismiss-button:hover {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.notification-prompt {
|
||||
top: 70px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.notification-prompt-content {
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notification-prompt-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.notification-prompt-text h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.notification-prompt-text p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.enable-button {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
613
src/lib/components/notifications/NotificationSettings.svelte
Normal file
@@ -0,0 +1,613 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface NotificationPreferences {
|
||||
friendRequests: boolean;
|
||||
friendAccepted: boolean;
|
||||
findLiked: boolean;
|
||||
findCommented: boolean;
|
||||
pushEnabled: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let preferences = $state<NotificationPreferences>({
|
||||
friendRequests: true,
|
||||
friendAccepted: true,
|
||||
findLiked: true,
|
||||
findCommented: true,
|
||||
pushEnabled: true
|
||||
});
|
||||
|
||||
let isLoading = $state<boolean>(true);
|
||||
let isSaving = $state<boolean>(false);
|
||||
let isSubscribing = $state<boolean>(false);
|
||||
let browserPermission = $state<NotificationPermission>('default');
|
||||
let isMobile = $state(false);
|
||||
|
||||
// Detect screen size
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const checkIsMobile = () => {
|
||||
isMobile = window.innerWidth < 768;
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener('resize', checkIsMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
loadPreferences();
|
||||
checkBrowserPermission();
|
||||
});
|
||||
|
||||
function checkBrowserPermission() {
|
||||
if (!browser || !('Notification' in window)) {
|
||||
browserPermission = 'denied';
|
||||
return;
|
||||
}
|
||||
browserPermission = Notification.permission;
|
||||
}
|
||||
|
||||
async function requestBrowserPermission() {
|
||||
if (!browser || !('Notification' in window)) {
|
||||
toast.error('Notifications are not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSubscribing = true;
|
||||
const permission = await Notification.requestPermission();
|
||||
browserPermission = permission;
|
||||
|
||||
if (permission === 'granted') {
|
||||
// Subscribe to push notifications
|
||||
await subscribeToPush();
|
||||
toast.success('Notifications enabled successfully');
|
||||
} else if (permission === 'denied') {
|
||||
toast.error('Notification permission denied');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting notification permission:', error);
|
||||
toast.error('Failed to enable notifications');
|
||||
} finally {
|
||||
isSubscribing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToPush() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const vapidPublicKey = await fetch('/api/notifications/subscribe').then((r) => r.text());
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
|
||||
});
|
||||
|
||||
await fetch('/api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(subscription)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
async function loadPreferences() {
|
||||
try {
|
||||
isLoading = true;
|
||||
const response = await fetch('/api/notifications/preferences');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load notification preferences');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
preferences = data;
|
||||
} catch (error) {
|
||||
console.error('Error loading notification preferences:', error);
|
||||
toast.error('Failed to load notification preferences');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePreferences() {
|
||||
try {
|
||||
isSaving = true;
|
||||
const response = await fetch('/api/notifications/preferences', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(preferences)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save notification preferences');
|
||||
}
|
||||
|
||||
toast.success('Notification preferences updated');
|
||||
} catch (error) {
|
||||
console.error('Error saving notification preferences:', error);
|
||||
toast.error('Failed to save notification preferences');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(key: keyof NotificationPreferences) {
|
||||
preferences[key] = !preferences[key];
|
||||
savePreferences();
|
||||
}
|
||||
|
||||
const canTogglePreferences = $derived(browserPermission === 'granted' && preferences.pushEnabled);
|
||||
</script>
|
||||
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="header-content">
|
||||
<h2 class="modal-title">Notification Settings</h2>
|
||||
<p class="modal-subtitle">Manage your notification preferences</p>
|
||||
</div>
|
||||
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading preferences...</div>
|
||||
{:else}
|
||||
<!-- Browser Permission Banner -->
|
||||
{#if browserPermission !== 'granted'}
|
||||
<div class="permission-banner {browserPermission === 'denied' ? 'denied' : 'default'}">
|
||||
<div class="permission-info">
|
||||
{#if browserPermission === 'denied'}
|
||||
<strong>Browser notifications blocked</strong>
|
||||
<p>
|
||||
Please enable notifications in your browser settings to receive push notifications
|
||||
</p>
|
||||
{:else}
|
||||
<strong>Browser permission required</strong>
|
||||
<p>Enable browser notifications to receive push notifications</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if browserPermission === 'default'}
|
||||
<button
|
||||
class="enable-button"
|
||||
onclick={requestBrowserPermission}
|
||||
disabled={isSubscribing}
|
||||
>
|
||||
{isSubscribing ? 'Enabling...' : 'Enable'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="settings-list">
|
||||
<!-- Push Notifications Toggle -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Push Notifications</h3>
|
||||
<p>Enable or disable all push notifications</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.pushEnabled}
|
||||
onchange={() => handleToggle('pushEnabled')}
|
||||
disabled={isSaving || browserPermission !== 'granted'}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Friend Requests -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Friend Requests</h3>
|
||||
<p>Get notified when someone sends you a friend request</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.friendRequests}
|
||||
onchange={() => handleToggle('friendRequests')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Friend Accepted -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Friend Request Accepted</h3>
|
||||
<p>Get notified when someone accepts your friend request</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.friendAccepted}
|
||||
onchange={() => handleToggle('friendAccepted')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Find Liked -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Find Likes</h3>
|
||||
<p>Get notified when someone likes your find</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.findLiked}
|
||||
onchange={() => handleToggle('findLiked')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Find Commented -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Find Comments</h3>
|
||||
<p>Get notified when someone comments on your find</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.findCommented}
|
||||
onchange={() => handleToggle('findCommented')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: auto;
|
||||
max-width: 500px;
|
||||
min-width: 380px;
|
||||
max-height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 0 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
background: transparent;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.permission-banner {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
padding: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.permission-banner.denied {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
border-color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.permission-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.permission-info strong {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.permission-info p {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.enable-button {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.enable-button:hover:not(:disabled) {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
.enable-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
background: hsl(var(--card));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem;
|
||||
gap: 1rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.setting-item.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-info h3 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.setting-info p {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: hsl(var(--border));
|
||||
margin: 0 1.25rem;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 24px;
|
||||
transition: all 0.2s;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
left: 3px;
|
||||
bottom: 2px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.25s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider:before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.toggle input:disabled + .toggle-slider {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Mobile specificadjust ments */
|
||||
@media (max-width: 767px) {
|
||||
.modal-container {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
src/lib/components/notifications/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as NotificationManager } from './NotificationManager.svelte';
|
||||
export { default as NotificationPrompt } from './NotificationPrompt.svelte';
|
||||
export { default as NotificationSettings } from './NotificationSettings.svelte';
|
||||
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from './dropdown-menu';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
||||
import { Skeleton } from './skeleton';
|
||||
} from '../dropdown-menu';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
|
||||
import NotificationSettings from '../notifications/NotificationSettings.svelte';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
@@ -21,9 +23,7 @@
|
||||
let { username, id, profilePictureUrl, loading = false }: Props = $props();
|
||||
|
||||
let showProfilePictureSheet = $state(false);
|
||||
|
||||
// Get the first letter of username for avatar
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
let showNotificationSettings = $state(false);
|
||||
|
||||
function openProfilePictureSheet() {
|
||||
showProfilePictureSheet = true;
|
||||
@@ -32,22 +32,19 @@
|
||||
function closeProfilePictureSheet() {
|
||||
showProfilePictureSheet = false;
|
||||
}
|
||||
|
||||
function openNotificationSettings() {
|
||||
showNotificationSettings = true;
|
||||
}
|
||||
|
||||
function closeNotificationSettings() {
|
||||
showNotificationSettings = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="profile-trigger">
|
||||
{#if loading}
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
{:else}
|
||||
<Avatar class="profile-avatar">
|
||||
{#if profilePictureUrl}
|
||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||
{/if}
|
||||
<AvatarFallback class="profile-avatar-fallback">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/if}
|
||||
<ProfilePicture {username} {profilePictureUrl} {loading} class="profile-avatar" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" class="profile-dropdown-content">
|
||||
@@ -85,7 +82,11 @@
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem class="friends-item">
|
||||
<a href="/friends" class="friends-link">Friends</a>
|
||||
<a href={resolveRoute('/friends')} class="friends-link">Friends</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem class="notification-settings-item" onclick={openNotificationSettings}>
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
@@ -120,6 +121,10 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showNotificationSettings}
|
||||
<NotificationSettings onClose={closeNotificationSettings} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.profile-trigger) {
|
||||
background: none;
|
||||
@@ -144,15 +149,6 @@
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
:global(.profile-avatar-fallback) {
|
||||
background: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.profile-dropdown-content) {
|
||||
min-width: 200px;
|
||||
max-width: 240px;
|
||||
@@ -211,6 +207,16 @@
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
:global(.notification-settings-item) {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:global(.notification-settings-item:hover) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.friends-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
59
src/lib/components/profile/ProfilePicture.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../avatar';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
class?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
username,
|
||||
profilePictureUrl,
|
||||
size = 'md',
|
||||
class: className,
|
||||
loading = false
|
||||
}: Props = $props();
|
||||
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 w-8',
|
||||
md: 'h-10 w-10',
|
||||
lg: 'h-16 w-16',
|
||||
xl: 'h-24 w-24'
|
||||
};
|
||||
|
||||
const fallbackSizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
lg: 'text-xl',
|
||||
xl: 'text-3xl'
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class={cn('animate-pulse rounded-full bg-gray-200', sizeClasses[size], className)}></div>
|
||||
{:else}
|
||||
<Avatar class={cn(sizeClasses[size], className)}>
|
||||
{#if profilePictureUrl}
|
||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||
{/if}
|
||||
<AvatarFallback class={cn('profile-picture-fallback', fallbackSizeClasses[size])}>
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.profile-picture-fallback) {
|
||||
background: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
|
||||
import { Button } from './button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../sheet';
|
||||
import { Button } from '../button';
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
@@ -19,8 +19,6 @@
|
||||
let isDeleting = $state(false);
|
||||
let showModal = $state(true);
|
||||
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
|
||||
// Close modal when showModal changes to false
|
||||
$effect(() => {
|
||||
if (!showModal && onClose) {
|
||||
@@ -118,14 +116,7 @@
|
||||
|
||||
<div class="profile-picture-content">
|
||||
<div class="current-avatar">
|
||||
<Avatar class="large-avatar">
|
||||
{#if profilePictureUrl}
|
||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||
{/if}
|
||||
<AvatarFallback class="large-avatar-fallback">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture {username} {profilePictureUrl} size="xl" class="large-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="upload-section">
|
||||
@@ -190,15 +181,6 @@
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
:global(.large-avatar-fallback) {
|
||||
background: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 48px;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
3
src/lib/components/profile/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ProfilePanel } from './ProfilePanel.svelte';
|
||||
export { default as ProfilePicture } from './ProfilePicture.svelte';
|
||||
export { default as ProfilePictureSheet } from './ProfilePictureSheet.svelte';
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from 'tailwind-variants';
|
||||
export const sheetVariants = tv({
|
||||
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-[9999] flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
variants: {
|
||||
side: {
|
||||
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
bind:ref
|
||||
data-slot="sheet-overlay"
|
||||
class={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-[9998] bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
export { default as Input } from './components/Input.svelte';
|
||||
export { default as Button } from './components/Button.svelte';
|
||||
export { default as ErrorMessage } from './components/ErrorMessage.svelte';
|
||||
export { default as ProfilePanel } from './components/ProfilePanel.svelte';
|
||||
export { default as ProfilePictureSheet } from './components/ProfilePictureSheet.svelte';
|
||||
export { default as ProfilePanel } from './components/profile/ProfilePanel.svelte';
|
||||
export { default as ProfilePicture } from './components/profile/ProfilePicture.svelte';
|
||||
export { default as ProfilePictureSheet } from './components/profile/ProfilePictureSheet.svelte';
|
||||
export { default as Header } from './components/Header.svelte';
|
||||
export { default as Modal } from './components/Modal.svelte';
|
||||
export { default as Map } from './components/Map.svelte';
|
||||
export { default as LocationButton } from './components/LocationButton.svelte';
|
||||
export { default as FindCard } from './components/FindCard.svelte';
|
||||
export { default as FindsList } from './components/FindsList.svelte';
|
||||
export { default as Map } from './components/map/Map.svelte';
|
||||
export { default as LocationManager } from './components/map/LocationManager.svelte';
|
||||
export { default as NotificationManager } from './components/notifications/NotificationManager.svelte';
|
||||
export { default as NotificationPrompt } from './components/notifications/NotificationPrompt.svelte';
|
||||
export { default as NotificationSettings } from './components/notifications/NotificationSettings.svelte';
|
||||
export { default as FindCard } from './components/finds/FindCard.svelte';
|
||||
export { default as FindsList } from './components/finds/FindsList.svelte';
|
||||
|
||||
// Skeleton Loading Components
|
||||
export { Skeleton, SkeletonVariants } from './components/skeleton';
|
||||
@@ -24,6 +28,7 @@ export {
|
||||
locationError,
|
||||
isLocationLoading,
|
||||
hasLocationAccess,
|
||||
isWatching,
|
||||
getMapCenter,
|
||||
getMapZoom
|
||||
} from './stores/location';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
@@ -63,16 +63,11 @@ export async function validateSessionToken(token: string) {
|
||||
.where(eq(table.session.id, session.id));
|
||||
}
|
||||
|
||||
// Generate signed URL for profile picture if it exists
|
||||
// Generate local proxy URL for profile picture if it exists
|
||||
let profilePictureUrl = user.profilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
// It's a path, generate signed URL
|
||||
try {
|
||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for profile picture:', error);
|
||||
profilePictureUrl = null;
|
||||
}
|
||||
// It's a path, generate local proxy URL
|
||||
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, integer, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, integer, text, timestamp, boolean, jsonb, real } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const user = pgTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -21,18 +21,34 @@ export type Session = typeof session.$inferSelect;
|
||||
|
||||
export type User = typeof user.$inferSelect;
|
||||
|
||||
// Finds feature tables
|
||||
// Location table - represents geographical points where finds can be made
|
||||
export const location = pgTable('location', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
latitude: text('latitude').notNull(), // Using text for precision
|
||||
longitude: text('longitude').notNull(), // Using text for precision
|
||||
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
|
||||
averageRating: integer('average_rating'), // Average rating (1-5 scale, stored as integer * 100 for precision)
|
||||
ratingCount: integer('rating_count').default(0), // Total number of finds with ratings at this location
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Find table - represents posts/content made at a location
|
||||
export const find = pgTable('find', {
|
||||
id: text('id').primaryKey(),
|
||||
locationId: text('location_id')
|
||||
.notNull()
|
||||
.references(() => location.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
latitude: text('latitude').notNull(), // Using text for precision
|
||||
longitude: text('longitude').notNull(), // Using text for precision
|
||||
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
|
||||
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
|
||||
rating: integer('rating'), // Average rating for this find (1-5 stars, stored as integer * 100)
|
||||
ratingCount: integer('rating_count').default(0), // Number of ratings for this find
|
||||
isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false)
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
@@ -63,6 +79,19 @@ export const findLike = pgTable('find_like', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const findRating = pgTable('find_rating', {
|
||||
id: text('id').primaryKey(),
|
||||
findId: text('find_id')
|
||||
.notNull()
|
||||
.references(() => find.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
rating: integer('rating').notNull(), // 1-5 stars (stored as 100-500 for precision)
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const friendship = pgTable('friendship', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
@@ -75,13 +104,79 @@ export const friendship = pgTable('friendship', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Type exports for the new tables
|
||||
export const findComment = pgTable('find_comment', {
|
||||
id: text('id').primaryKey(),
|
||||
findId: text('find_id')
|
||||
.notNull()
|
||||
.references(() => find.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
content: text('content').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Notification system tables
|
||||
export const notification = pgTable('notification', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
type: text('type').notNull(), // 'friend_request', 'friend_accepted', 'find_liked', 'find_commented'
|
||||
title: text('title').notNull(),
|
||||
message: text('message').notNull(),
|
||||
data: jsonb('data'), // Additional context data (findId, friendId, etc.)
|
||||
isRead: boolean('is_read').default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const notificationSubscription = pgTable('notification_subscription', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
p256dhKey: text('p256dh_key').notNull(),
|
||||
authKey: text('auth_key').notNull(),
|
||||
userAgent: text('user_agent'),
|
||||
isActive: boolean('is_active').default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const notificationPreferences = pgTable('notification_preferences', {
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
friendRequests: boolean('friend_requests').default(true),
|
||||
friendAccepted: boolean('friend_accepted').default(true),
|
||||
findLiked: boolean('find_liked').default(true),
|
||||
findCommented: boolean('find_commented').default(true),
|
||||
pushEnabled: boolean('push_enabled').default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Type exports for the tables
|
||||
export type Location = typeof location.$inferSelect;
|
||||
export type Find = typeof find.$inferSelect;
|
||||
export type FindMedia = typeof findMedia.$inferSelect;
|
||||
export type FindLike = typeof findLike.$inferSelect;
|
||||
export type FindRating = typeof findRating.$inferSelect;
|
||||
export type FindComment = typeof findComment.$inferSelect;
|
||||
export type Friendship = typeof friendship.$inferSelect;
|
||||
export type Notification = typeof notification.$inferSelect;
|
||||
export type NotificationSubscription = typeof notificationSubscription.$inferSelect;
|
||||
export type NotificationPreferences = typeof notificationPreferences.$inferSelect;
|
||||
|
||||
export type LocationInsert = typeof location.$inferInsert;
|
||||
export type FindInsert = typeof find.$inferInsert;
|
||||
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
||||
export type FindLikeInsert = typeof findLike.$inferInsert;
|
||||
export type FindRatingInsert = typeof findRating.$inferInsert;
|
||||
export type FindCommentInsert = typeof findComment.$inferInsert;
|
||||
export type FriendshipInsert = typeof friendship.$inferInsert;
|
||||
export type NotificationInsert = typeof notification.$inferInsert;
|
||||
export type NotificationSubscriptionInsert = typeof notificationSubscription.$inferInsert;
|
||||
export type NotificationPreferencesInsert = typeof notificationPreferences.$inferInsert;
|
||||
|
||||
160
src/lib/server/notifications.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { db } from './db';
|
||||
import { notification, notificationPreferences } from './db/schema';
|
||||
import type { NotificationInsert } from './db/schema';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export type NotificationType =
|
||||
| 'friend_request'
|
||||
| 'friend_accepted'
|
||||
| 'find_liked'
|
||||
| 'find_commented';
|
||||
|
||||
export interface CreateNotificationData {
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GetNotificationsOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
unreadOnly?: boolean;
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
/**
|
||||
* Create a new notification record in the database
|
||||
*/
|
||||
async createNotification(data: CreateNotificationData): Promise<void> {
|
||||
const notificationData: NotificationInsert = {
|
||||
id: nanoid(),
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
data: data.data || null,
|
||||
isRead: false
|
||||
};
|
||||
|
||||
await db.insert(notification).values(notificationData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has notifications enabled for a specific type
|
||||
*/
|
||||
async shouldNotify(userId: string, type: NotificationType): Promise<boolean> {
|
||||
const prefs = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
// If no preferences exist, default to true
|
||||
if (prefs.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pref = prefs[0];
|
||||
|
||||
// Check if push is enabled and specific notification type is enabled
|
||||
if (!pref.pushEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'friend_request':
|
||||
return pref.friendRequests ?? true;
|
||||
case 'friend_accepted':
|
||||
return pref.friendAccepted ?? true;
|
||||
case 'find_liked':
|
||||
return pref.findLiked ?? true;
|
||||
case 'find_commented':
|
||||
return pref.findCommented ?? true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user notifications with pagination and filtering
|
||||
*/
|
||||
async getUserNotifications(userId: string, options: GetNotificationsOptions = {}) {
|
||||
const { limit = 20, offset = 0, unreadOnly = false } = options;
|
||||
|
||||
let query = db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(eq(notification.userId, userId))
|
||||
.orderBy(desc(notification.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
if (unreadOnly) {
|
||||
query = db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(and(eq(notification.userId, userId), eq(notification.isRead, false)))
|
||||
.orderBy(desc(notification.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
return await query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notifications as read
|
||||
*/
|
||||
async markAsRead(notificationIds: string[]): Promise<void> {
|
||||
for (const id of notificationIds) {
|
||||
await db.update(notification).set({ isRead: true }).where(eq(notification.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single notification as read
|
||||
*/
|
||||
async markOneAsRead(notificationId: string): Promise<void> {
|
||||
await db.update(notification).set({ isRead: true }).where(eq(notification.id, notificationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a user
|
||||
*/
|
||||
async markAllAsRead(userId: string): Promise<void> {
|
||||
await db.update(notification).set({ isRead: true }).where(eq(notification.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count for a user
|
||||
*/
|
||||
async getUnreadCount(userId: string): Promise<number> {
|
||||
const notifications = await db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(and(eq(notification.userId, userId), eq(notification.isRead, false)));
|
||||
|
||||
return notifications.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a notification
|
||||
*/
|
||||
async deleteNotification(notificationId: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.delete(notification)
|
||||
.where(and(eq(notification.id, notificationId), eq(notification.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all notifications for a user
|
||||
*/
|
||||
async deleteAllNotifications(userId: string): Promise<void> {
|
||||
await db.delete(notification).where(eq(notification.userId, userId));
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
@@ -4,5 +4,5 @@ import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } from '$env/static/private';
|
||||
export const google = new Google(
|
||||
GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET,
|
||||
'https://serengo.ziasvannes.tech/login/google/callback'
|
||||
'https://serengo.zias.be/login/google/callback'
|
||||
);
|
||||
|
||||
185
src/lib/server/push.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import webpush from 'web-push';
|
||||
import { db } from './db';
|
||||
import { notificationSubscription } from './db/schema';
|
||||
import type { NotificationSubscriptionInsert } from './db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT } from '$env/static/private';
|
||||
|
||||
// Initialize web-push with VAPID keys
|
||||
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY || !VAPID_SUBJECT) {
|
||||
throw new Error(
|
||||
'VAPID keys are not configured. Please set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT in your environment variables.'
|
||||
);
|
||||
}
|
||||
|
||||
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
||||
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushNotificationPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
tag?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export class PushService {
|
||||
/**
|
||||
* Save a push subscription for a user
|
||||
*/
|
||||
async saveSubscription(
|
||||
userId: string,
|
||||
subscription: PushSubscriptionData,
|
||||
userAgent?: string
|
||||
): Promise<void> {
|
||||
const subscriptionData: NotificationSubscriptionInsert = {
|
||||
id: nanoid(),
|
||||
userId,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dhKey: subscription.keys.p256dh,
|
||||
authKey: subscription.keys.auth,
|
||||
userAgent: userAgent || null,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
// Check if subscription already exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(notificationSubscription)
|
||||
.where(eq(notificationSubscription.endpoint, subscription.endpoint))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing subscription
|
||||
await db
|
||||
.update(notificationSubscription)
|
||||
.set({
|
||||
p256dhKey: subscription.keys.p256dh,
|
||||
authKey: subscription.keys.auth,
|
||||
userAgent: userAgent || null,
|
||||
isActive: true,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(notificationSubscription.endpoint, subscription.endpoint));
|
||||
} else {
|
||||
// Insert new subscription
|
||||
await db.insert(notificationSubscription).values(subscriptionData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a push subscription
|
||||
*/
|
||||
async removeSubscription(userId: string, endpoint: string): Promise<void> {
|
||||
await db
|
||||
.delete(notificationSubscription)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationSubscription.userId, userId),
|
||||
eq(notificationSubscription.endpoint, endpoint)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active subscriptions for a user
|
||||
*/
|
||||
async getUserSubscriptions(userId: string) {
|
||||
return await db
|
||||
.select()
|
||||
.from(notificationSubscription)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationSubscription.userId, userId),
|
||||
eq(notificationSubscription.isActive, true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification to a user's subscriptions
|
||||
*/
|
||||
async sendPushNotification(userId: string, payload: PushNotificationPayload): Promise<void> {
|
||||
const subscriptions = await this.getUserSubscriptions(userId);
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
console.log(`No active subscriptions found for user ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationPayload = JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.message,
|
||||
icon: payload.icon || '/logo.svg',
|
||||
badge: payload.badge || '/logo.svg',
|
||||
tag: payload.tag,
|
||||
data: {
|
||||
url: payload.url || '/',
|
||||
...payload.data
|
||||
}
|
||||
});
|
||||
|
||||
const sendPromises = subscriptions.map(async (sub) => {
|
||||
const pushSubscription: webpush.PushSubscription = {
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.p256dhKey,
|
||||
auth: sub.authKey
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(pushSubscription, notificationPayload);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send push notification to ${sub.endpoint}:`, error);
|
||||
|
||||
// If the subscription is invalid (410 Gone or 404), deactivate it
|
||||
if (error instanceof Error && 'statusCode' in error) {
|
||||
const statusCode = (error as { statusCode: number }).statusCode;
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
await db
|
||||
.update(notificationSubscription)
|
||||
.set({ isActive: false })
|
||||
.where(eq(notificationSubscription.id, sub.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(sendPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification to multiple users
|
||||
*/
|
||||
async sendPushNotificationToUsers(
|
||||
userIds: string[],
|
||||
payload: PushNotificationPayload
|
||||
): Promise<void> {
|
||||
const sendPromises = userIds.map((userId) => this.sendPushNotification(userId, payload));
|
||||
await Promise.all(sendPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a subscription
|
||||
*/
|
||||
async deactivateSubscription(endpoint: string): Promise<void> {
|
||||
await db
|
||||
.update(notificationSubscription)
|
||||
.set({ isActive: false })
|
||||
.where(eq(notificationSubscription.endpoint, endpoint));
|
||||
}
|
||||
}
|
||||
|
||||
export const pushService = new PushService();
|
||||
export { VAPID_PUBLIC_KEY };
|
||||
@@ -72,3 +72,7 @@ export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<st
|
||||
|
||||
return await getSignedUrl(r2Client, command, { expiresIn });
|
||||
}
|
||||
|
||||
export function getLocalR2Url(path: string): string {
|
||||
return `/api/media/${path}`;
|
||||
}
|
||||
|
||||
851
src/lib/stores/api-sync.ts
Normal file
@@ -0,0 +1,851 @@
|
||||
import { writable, derived, type Readable, type Writable } from 'svelte/store';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// Core types for the API sync system
|
||||
export interface EntityState<T = unknown> {
|
||||
data: T;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
export interface QueuedOperation {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
action?: string;
|
||||
data?: unknown;
|
||||
retry: number;
|
||||
maxRetries: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface APIResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Specific entity state types
|
||||
export interface FindLikeState {
|
||||
isLiked: boolean;
|
||||
likeCount: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FindState {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string;
|
||||
category?: string;
|
||||
rating?: number | null;
|
||||
ratingCount?: number;
|
||||
isPublic: boolean;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string;
|
||||
media?: Array<{
|
||||
id: string;
|
||||
findId: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
orderIndex: number | null;
|
||||
}>;
|
||||
isLikedByUser: boolean;
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
isFromFriend: boolean;
|
||||
}
|
||||
|
||||
export interface CommentState {
|
||||
id: string;
|
||||
findId: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FindCommentsState {
|
||||
comments: CommentState[];
|
||||
commentCount: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Generate unique operation IDs
|
||||
function generateOperationId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Create operation key for deduplication
|
||||
function createOperationKey(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
operation: string,
|
||||
action?: string
|
||||
): string {
|
||||
return `${entityType}:${entityId}:${operation}${action ? `:${action}` : ''}`;
|
||||
}
|
||||
|
||||
class APISync {
|
||||
// Entity stores - each entity type has its own store
|
||||
private entityStores = new Map<string, Writable<Map<string, EntityState>>>();
|
||||
|
||||
// Operation queue for API calls
|
||||
private operationQueue = new Map<string, QueuedOperation>();
|
||||
private processingQueue = false;
|
||||
|
||||
// Cleanup tracking for memory management
|
||||
private subscriptions = new Map<string, Set<() => void>>();
|
||||
|
||||
constructor() {
|
||||
// Initialize core entity stores
|
||||
this.initializeEntityStore('find');
|
||||
this.initializeEntityStore('user');
|
||||
this.initializeEntityStore('friendship');
|
||||
this.initializeEntityStore('comment');
|
||||
|
||||
// Start processing queue
|
||||
this.startQueueProcessor();
|
||||
}
|
||||
|
||||
private initializeEntityStore(entityType: string): void {
|
||||
if (!this.entityStores.has(entityType)) {
|
||||
this.entityStores.set(entityType, writable(new Map<string, EntityState>()));
|
||||
}
|
||||
}
|
||||
|
||||
private getEntityStore(entityType: string): Writable<Map<string, EntityState>> {
|
||||
this.initializeEntityStore(entityType);
|
||||
return this.entityStores.get(entityType)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a specific entity's state
|
||||
*/
|
||||
subscribe<T>(entityType: string, entityId: string): Readable<EntityState<T>> {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
return derived(store, ($entities) => {
|
||||
const entity = $entities.get(entityId);
|
||||
if (!entity) {
|
||||
// Return default state if entity doesn't exist
|
||||
return {
|
||||
data: null as T,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
};
|
||||
}
|
||||
return entity as EntityState<T>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe specifically to find like state
|
||||
*/
|
||||
subscribeFindLikes(findId: string): Readable<FindLikeState> {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
return derived(store, ($entities) => {
|
||||
const entity = $entities.get(findId);
|
||||
if (!entity || !entity.data) {
|
||||
return {
|
||||
isLiked: false,
|
||||
likeCount: 0,
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const findData = entity.data as FindState;
|
||||
return {
|
||||
isLiked: findData.isLikedByUser,
|
||||
likeCount: findData.likeCount,
|
||||
isLoading: entity.isLoading,
|
||||
error: entity.error
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to find comments state
|
||||
*/
|
||||
subscribeFindComments(findId: string): Readable<FindCommentsState> {
|
||||
const store = this.getEntityStore('comment');
|
||||
|
||||
return derived(store, ($entities) => {
|
||||
const entity = $entities.get(findId);
|
||||
if (!entity || !entity.data) {
|
||||
return {
|
||||
comments: [],
|
||||
commentCount: 0,
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const commentsData = entity.data as CommentState[];
|
||||
return {
|
||||
comments: commentsData,
|
||||
commentCount: commentsData.length,
|
||||
isLoading: entity.isLoading,
|
||||
error: entity.error
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity state exists
|
||||
*/
|
||||
hasEntityState(entityType: string, entityId: string): boolean {
|
||||
const store = this.getEntityStore(entityType);
|
||||
let exists = false;
|
||||
|
||||
const unsubscribe = store.subscribe(($entities) => {
|
||||
exists = $entities.has(entityId);
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
return exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current entity state
|
||||
*/
|
||||
getEntityState<T>(entityType: string, entityId: string): T | null {
|
||||
const store = this.getEntityStore(entityType);
|
||||
let currentState: T | null = null;
|
||||
|
||||
const unsubscribe = store.subscribe(($entities) => {
|
||||
const entity = $entities.get(entityId);
|
||||
if (entity?.data) {
|
||||
currentState = entity.data as T;
|
||||
}
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
return currentState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize entity state with server data (only if no existing state)
|
||||
*/
|
||||
setEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
newEntities.set(entityId, {
|
||||
data,
|
||||
isLoading,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize entity state only if it doesn't exist yet
|
||||
*/
|
||||
initializeEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
|
||||
if (!this.hasEntityState(entityType, entityId)) {
|
||||
this.setEntityState(entityType, entityId, data, isLoading);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entity loading state
|
||||
*/
|
||||
private setEntityLoading(entityType: string, entityId: string, isLoading: boolean): void {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(entityId);
|
||||
if (existing) {
|
||||
newEntities.set(entityId, {
|
||||
...existing,
|
||||
isLoading
|
||||
});
|
||||
}
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entity error state
|
||||
*/
|
||||
private setEntityError(entityType: string, entityId: string, error: string): void {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(entityId);
|
||||
if (existing) {
|
||||
newEntities.set(entityId, {
|
||||
...existing,
|
||||
isLoading: false,
|
||||
error
|
||||
});
|
||||
}
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an operation for processing
|
||||
*/
|
||||
async queueOperation(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
operation: 'create' | 'update' | 'delete',
|
||||
action?: string,
|
||||
data?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const operationKey = createOperationKey(entityType, entityId, operation, action);
|
||||
|
||||
// Check if same operation is already queued
|
||||
if (this.operationQueue.has(operationKey)) {
|
||||
console.log(`Operation ${operationKey} already queued, skipping duplicate`);
|
||||
return;
|
||||
}
|
||||
|
||||
const queuedOperation: QueuedOperation = {
|
||||
id: generateOperationId(),
|
||||
entityType,
|
||||
entityId,
|
||||
operation,
|
||||
action,
|
||||
data,
|
||||
retry: 0,
|
||||
maxRetries: 3,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
this.operationQueue.set(operationKey, queuedOperation);
|
||||
|
||||
// Set entity to loading state
|
||||
this.setEntityLoading(entityType, entityId, true);
|
||||
|
||||
// Process queue if not already processing
|
||||
if (!this.processingQueue) {
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the operation queue
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.processingQueue || this.operationQueue.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processingQueue = true;
|
||||
|
||||
const operations = Array.from(this.operationQueue.entries());
|
||||
|
||||
for (const [operationKey, operation] of operations) {
|
||||
try {
|
||||
await this.executeOperation(operation);
|
||||
this.operationQueue.delete(operationKey);
|
||||
} catch (error) {
|
||||
console.error(`Operation ${operationKey} failed:`, error);
|
||||
|
||||
if (operation.retry < operation.maxRetries) {
|
||||
operation.retry++;
|
||||
console.log(
|
||||
`Retrying operation ${operationKey} (attempt ${operation.retry}/${operation.maxRetries})`
|
||||
);
|
||||
} else {
|
||||
console.error(`Operation ${operationKey} failed after ${operation.maxRetries} retries`);
|
||||
this.operationQueue.delete(operationKey);
|
||||
this.setEntityError(
|
||||
operation.entityType,
|
||||
operation.entityId,
|
||||
'Operation failed after multiple retries'
|
||||
);
|
||||
toast.error('Failed to sync changes. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.processingQueue = false;
|
||||
|
||||
// If more operations were added while processing, process again
|
||||
if (this.operationQueue.size > 0) {
|
||||
setTimeout(() => this.processQueue(), 1000); // Wait 1s before retry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a specific operation
|
||||
*/
|
||||
private async executeOperation(operation: QueuedOperation): Promise<void> {
|
||||
const { entityType, entityId, operation: op, action, data } = operation;
|
||||
|
||||
let response: Response;
|
||||
|
||||
if (entityType === 'find' && action === 'like') {
|
||||
// Handle like operations
|
||||
const method = (data as { isLiked?: boolean })?.isLiked ? 'POST' : 'DELETE';
|
||||
response = await fetch(`/api/finds/${entityId}/like`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} else if (entityType === 'find' && op === 'update') {
|
||||
// Handle find update
|
||||
response = await fetch(`/api/finds/${entityId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else if (entityType === 'find' && op === 'delete') {
|
||||
// Handle find deletion
|
||||
response = await fetch(`/api/finds/${entityId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} else if (entityType === 'comment' && op === 'create') {
|
||||
// Handle comment creation
|
||||
response = await fetch(`/api/finds/${entityId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else if (entityType === 'comment' && op === 'delete') {
|
||||
// Handle comment deletion
|
||||
response = await fetch(`/api/finds/comments/${entityId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported operation: ${entityType}:${op}:${action}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update entity state with successful result
|
||||
if (entityType === 'find' && action === 'like') {
|
||||
this.updateFindLikeState(entityId, result.isLiked, result.likeCount);
|
||||
} else if (entityType === 'find' && op === 'update') {
|
||||
// Reload the find data to get the updated state
|
||||
// For now, just clear loading state - the parent component handles refresh
|
||||
// TODO: Ideally, we'd merge the update data into the existing state
|
||||
this.setEntityLoading(entityType, entityId, false);
|
||||
} else if (entityType === 'find' && op === 'delete') {
|
||||
// Find already removed optimistically, just clear loading state
|
||||
this.setEntityLoading(entityType, entityId, false);
|
||||
} else if (entityType === 'comment' && op === 'create') {
|
||||
this.addCommentToState(result.data.findId, result.data);
|
||||
} else if (entityType === 'comment' && op === 'delete') {
|
||||
this.removeCommentFromState(entityId, data as { findId: string });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update find like state after successful API call
|
||||
*/
|
||||
private updateFindLikeState(findId: string, isLiked: boolean, likeCount: number): void {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
if (existing && existing.data) {
|
||||
const findData = existing.data as FindState;
|
||||
newEntities.set(findId, {
|
||||
...existing,
|
||||
data: {
|
||||
...findData,
|
||||
isLikedByUser: isLiked,
|
||||
likeCount: likeCount
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the queue processor
|
||||
*/
|
||||
private startQueueProcessor(): void {
|
||||
// Process queue every 100ms
|
||||
setInterval(() => {
|
||||
if (this.operationQueue.size > 0 && !this.processingQueue) {
|
||||
this.processQueue();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle like for a find
|
||||
*/
|
||||
async toggleLike(findId: string): Promise<void> {
|
||||
// Get current state for optimistic update
|
||||
const store = this.getEntityStore('find');
|
||||
let currentState: FindState | null = null;
|
||||
|
||||
const unsubscribe = store.subscribe(($entities) => {
|
||||
const entity = $entities.get(findId);
|
||||
if (entity?.data) {
|
||||
currentState = entity.data as FindState;
|
||||
}
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
if (!currentState) {
|
||||
console.warn(`Cannot toggle like for find ${findId}: find state not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const findState = currentState as FindState;
|
||||
const newIsLiked = !findState.isLikedByUser;
|
||||
const newLikeCount = findState.likeCount + (newIsLiked ? 1 : -1);
|
||||
|
||||
// Update state optimistically
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
if (existing && existing.data) {
|
||||
const findData = existing.data as FindState;
|
||||
newEntities.set(findId, {
|
||||
...existing,
|
||||
data: {
|
||||
...findData,
|
||||
isLikedByUser: newIsLiked,
|
||||
likeCount: newLikeCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('find', findId, 'update', 'like', { isLiked: newIsLiked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add comment to find comments state
|
||||
*/
|
||||
private addCommentToState(findId: string, comment: CommentState): void {
|
||||
const store = this.getEntityStore('comment');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
const comments = existing?.data ? (existing.data as CommentState[]) : [];
|
||||
|
||||
// If this is a real comment from server, remove any temporary comment with the same content
|
||||
let updatedComments = comments;
|
||||
if (!comment.id.startsWith('temp-')) {
|
||||
updatedComments = comments.filter(
|
||||
(c) => !(c.id.startsWith('temp-') && c.content === comment.content)
|
||||
);
|
||||
}
|
||||
|
||||
updatedComments = [comment, ...updatedComments];
|
||||
|
||||
newEntities.set(findId, {
|
||||
data: updatedComments,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Update find comment count only for temp comments (optimistic updates)
|
||||
if (comment.id.startsWith('temp-')) {
|
||||
this.updateFindCommentCount(findId, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove comment from find comments state
|
||||
*/
|
||||
private removeCommentFromState(commentId: string, data: { findId: string }): void {
|
||||
const store = this.getEntityStore('comment');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(data.findId);
|
||||
|
||||
if (existing?.data) {
|
||||
const comments = existing.data as CommentState[];
|
||||
const updatedComments = comments.filter((c) => c.id !== commentId);
|
||||
|
||||
newEntities.set(data.findId, {
|
||||
...existing,
|
||||
data: updatedComments,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Update find comment count
|
||||
this.updateFindCommentCount(data.findId, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update find comment count
|
||||
*/
|
||||
private updateFindCommentCount(findId: string, delta: number): void {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
if (existing && existing.data) {
|
||||
const findData = existing.data as FindState;
|
||||
newEntities.set(findId, {
|
||||
...existing,
|
||||
data: {
|
||||
...findData,
|
||||
commentCount: Math.max(0, findData.commentCount + delta)
|
||||
},
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load comments for a find
|
||||
*/
|
||||
async loadComments(findId: string): Promise<void> {
|
||||
if (this.hasEntityState('comment', findId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setEntityLoading('comment', findId, true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/finds/${findId}/comments`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.setEntityState('comment', findId, result.data, false);
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to load comments');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading comments:', error);
|
||||
this.setEntityError('comment', findId, 'Failed to load comments');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comment to a find
|
||||
*/
|
||||
async addComment(findId: string, content: string): Promise<void> {
|
||||
// Optimistic update: add temporary comment
|
||||
const tempComment: CommentState = {
|
||||
id: `temp-${Date.now()}`,
|
||||
findId,
|
||||
content,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
user: {
|
||||
id: 'current-user',
|
||||
username: 'You',
|
||||
profilePictureUrl: undefined
|
||||
}
|
||||
};
|
||||
|
||||
this.addCommentToState(findId, tempComment);
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('comment', findId, 'create', undefined, { content });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment
|
||||
*/
|
||||
async deleteComment(commentId: string, findId: string): Promise<void> {
|
||||
// Optimistic update: remove comment
|
||||
this.removeCommentFromState(commentId, { findId });
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('comment', commentId, 'delete', undefined, { findId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize find data from server (only if no existing state)
|
||||
*/
|
||||
initializeFindData(finds: FindState[]): void {
|
||||
for (const find of finds) {
|
||||
this.initializeEntityState('find', find.id, find);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize comments data for a find
|
||||
*/
|
||||
initializeCommentsData(findId: string, comments: CommentState[]): void {
|
||||
this.initializeEntityState('comment', findId, comments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup unused subscriptions (call this when components unmount)
|
||||
*/
|
||||
cleanup(entityType: string, entityId: string): void {
|
||||
const key = `${entityType}:${entityId}`;
|
||||
const subscriptions = this.subscriptions.get(key);
|
||||
if (subscriptions) {
|
||||
subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.subscriptions.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove find from state after successful deletion
|
||||
*/
|
||||
private removeFindFromState(findId: string): void {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
newEntities.delete(findId);
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Also clean up associated comments
|
||||
const commentStore = this.getEntityStore('comment');
|
||||
commentStore.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
newEntities.delete(findId);
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a find
|
||||
*/
|
||||
async updateFind(
|
||||
findId: string,
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
locationName?: string | null;
|
||||
category?: string;
|
||||
isPublic?: boolean;
|
||||
media?: Array<{ type: string; url: string; thumbnailUrl?: string }>;
|
||||
mediaToDelete?: string[];
|
||||
}
|
||||
): Promise<void> {
|
||||
// Optimistically update the find state
|
||||
const currentState = this.getEntityState<FindState>('find', findId);
|
||||
if (currentState) {
|
||||
const updatedFind: FindState = {
|
||||
...currentState,
|
||||
...(data.title !== undefined && { title: data.title }),
|
||||
...(data.description !== undefined && { description: data.description || undefined }),
|
||||
...(data.latitude !== undefined && { latitude: data.latitude.toString() }),
|
||||
...(data.longitude !== undefined && { longitude: data.longitude.toString() }),
|
||||
...(data.locationName !== undefined && { locationName: data.locationName || undefined }),
|
||||
...(data.category !== undefined && { category: data.category }),
|
||||
...(data.isPublic !== undefined && { isPublic: data.isPublic }),
|
||||
...(data.media !== undefined && {
|
||||
media: data.media.map((m, index) => ({
|
||||
id: (m as any).id || `temp-${index}`,
|
||||
findId: findId,
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || null,
|
||||
orderIndex: index
|
||||
}))
|
||||
})
|
||||
};
|
||||
this.setEntityState('find', findId, updatedFind, false);
|
||||
}
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('find', findId, 'update', undefined, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a find
|
||||
*/
|
||||
async deleteFind(findId: string): Promise<void> {
|
||||
// Optimistically remove find from state
|
||||
this.removeFindFromState(findId);
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('find', findId, 'delete', undefined, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all finds as an array
|
||||
*/
|
||||
subscribeAllFinds(): Readable<FindState[]> {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
return derived(store, ($entities) => {
|
||||
const finds: FindState[] = [];
|
||||
$entities.forEach((entity) => {
|
||||
if (entity.data) {
|
||||
finds.push(entity.data as FindState);
|
||||
}
|
||||
});
|
||||
// Sort by creation date, newest first
|
||||
return finds.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const apiSync = new APISync();
|
||||
@@ -43,6 +43,7 @@ export const shouldZoomToLocation = derived(
|
||||
locationStore,
|
||||
($location) => $location.shouldZoomToLocation
|
||||
);
|
||||
export const isWatching = derived(locationStore, ($location) => $location.isWatching);
|
||||
|
||||
// Location actions
|
||||
export const locationActions = {
|
||||
|
||||
41
src/lib/utils/distance.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Calculate distance between two points using Haversine formula
|
||||
* @param lat1 Latitude of first point
|
||||
* @param lon1 Longitude of first point
|
||||
* @param lat2 Latitude of second point
|
||||
* @param lon2 Longitude of second point
|
||||
* @returns Distance in kilometers
|
||||
*/
|
||||
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371; // Radius of the Earth in kilometers
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const distance = R * c;
|
||||
|
||||
return distance;
|
||||
}
|
||||
|
||||
function toRad(degrees: number): number {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance for display
|
||||
* @param distance Distance in kilometers
|
||||
* @returns Formatted string
|
||||
*/
|
||||
export function formatDistance(distance: number): string {
|
||||
if (distance < 1) {
|
||||
return `${Math.round(distance * 1000)}m`;
|
||||
} else if (distance < 10) {
|
||||
return `${distance.toFixed(1)}km`;
|
||||
} else {
|
||||
return `${Math.round(distance)}km`;
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@
|
||||
import { Header } from '$lib';
|
||||
import { page } from '$app/state';
|
||||
import { Toaster } from '$lib/components/sonner/index.js';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
import LocationManager from '$lib/components/map/LocationManager.svelte';
|
||||
import NotificationManager from '$lib/components/notifications/NotificationManager.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
|
||||
let showHeader = $derived(!isLoginRoute && data?.user);
|
||||
let isLoading = $derived(!isLoginRoute && !data?.user && data !== null);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -32,42 +32,14 @@
|
||||
|
||||
<Toaster />
|
||||
|
||||
<!-- Auto-start location and notfication watching for authenticated users -->
|
||||
{#if data?.user && !isLoginRoute}
|
||||
<LocationManager autoStart={true} />
|
||||
<NotificationManager />
|
||||
{/if}
|
||||
|
||||
{#if showHeader && data.user}
|
||||
<Header user={data.user} />
|
||||
{:else if isLoading}
|
||||
<header class="header-skeleton">
|
||||
<div class="header-content">
|
||||
<Skeleton class="h-8 w-32" />
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
<style>
|
||||
.header-skeleton {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
padding: 0 20px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-skeleton {
|
||||
padding: 0 16px;
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
||||
if (!locals.user) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
// Build API URL with query parameters
|
||||
const apiUrl = new URL('/api/finds', url.origin);
|
||||
const apiUrl = new URL('/api/locations', url.origin);
|
||||
|
||||
// Forward location filtering parameters
|
||||
const lat = url.searchParams.get('lat');
|
||||
@@ -17,8 +12,13 @@ export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
||||
if (lat) apiUrl.searchParams.set('lat', lat);
|
||||
if (lng) apiUrl.searchParams.set('lng', lng);
|
||||
apiUrl.searchParams.set('radius', radius);
|
||||
apiUrl.searchParams.set('includePrivate', 'true'); // Include user's private finds
|
||||
apiUrl.searchParams.set('includeFriends', 'true'); // Include friends' finds
|
||||
|
||||
// Only include private and friends' finds if user is logged in
|
||||
if (locals.user) {
|
||||
apiUrl.searchParams.set('includePrivate', 'true'); // Include user's private finds
|
||||
apiUrl.searchParams.set('includeFriends', 'true'); // Include friends' finds
|
||||
}
|
||||
|
||||
apiUrl.searchParams.set('order', 'desc'); // Newest first
|
||||
|
||||
try {
|
||||
@@ -32,15 +32,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
||||
throw new Error(`API request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const finds = await response.json();
|
||||
const locations = await response.json();
|
||||
|
||||
return {
|
||||
finds
|
||||
locations
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error loading finds:', err);
|
||||
console.error('Error loading locations:', err);
|
||||
return {
|
||||
finds: []
|
||||
locations: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,189 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { Map } from '$lib';
|
||||
import FindsList from '$lib/components/FindsList.svelte';
|
||||
import CreateFindModal from '$lib/components/CreateFindModal.svelte';
|
||||
import FindPreview from '$lib/components/FindPreview.svelte';
|
||||
import FindsFilter from '$lib/components/FindsFilter.svelte';
|
||||
import {
|
||||
LocationsList,
|
||||
SelectLocationModal,
|
||||
LocationFindsModal
|
||||
} from '$lib/components/locations';
|
||||
import type { PageData } from './$types';
|
||||
import { coordinates } from '$lib/stores/location';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { calculateDistance } from '$lib/utils/distance';
|
||||
|
||||
// Server response type
|
||||
interface ServerFind {
|
||||
interface Find {
|
||||
id: string;
|
||||
locationId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string;
|
||||
category?: string;
|
||||
locationName?: string;
|
||||
rating?: number | null;
|
||||
ratingCount?: number;
|
||||
isPublic: number;
|
||||
createdAt: string; // Will be converted to Date type, but is a string from api
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
likeCount?: number;
|
||||
isLikedByUser?: boolean;
|
||||
isFromFriend?: boolean;
|
||||
media: Array<{
|
||||
createdAt: string;
|
||||
media?: Array<{
|
||||
id: string;
|
||||
findId: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
orderIndex: number | null;
|
||||
thumbnailUrl: string;
|
||||
orderIndex?: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Map component type
|
||||
interface MapFind {
|
||||
interface Location {
|
||||
id: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
findCount: number;
|
||||
finds?: Find[];
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
interface MapLocation {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string;
|
||||
category?: string;
|
||||
isPublic: number;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
isFromFriend?: boolean;
|
||||
media?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Interface for FindPreview component
|
||||
interface FindPreviewData {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string;
|
||||
category?: string;
|
||||
createdAt: string;
|
||||
user: {
|
||||
finds: Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
media?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
isPublic: number;
|
||||
media?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
}>;
|
||||
}>;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
let { data }: { data: PageData & { finds?: ServerFind[] } } = $props();
|
||||
let { data }: { data: PageData & { locations?: Location[] } } = $props();
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let selectedFind: FindPreviewData | null = $state(null);
|
||||
let currentFilter = $state('all');
|
||||
let showCreateFindModal = $state(false);
|
||||
let showLocationFindsModal = $state(false);
|
||||
let selectedLocation: Location | null = $state(null);
|
||||
let isSidebarVisible = $state(true);
|
||||
|
||||
// All finds - convert server format to component format
|
||||
let allFinds = $derived(
|
||||
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({
|
||||
...serverFind,
|
||||
createdAt: new Date(serverFind.createdAt), // Convert string to Date
|
||||
user: {
|
||||
id: serverFind.userId,
|
||||
username: serverFind.username,
|
||||
profilePictureUrl: serverFind.profilePictureUrl
|
||||
},
|
||||
likeCount: serverFind.likeCount,
|
||||
isLiked: serverFind.isLikedByUser,
|
||||
isFromFriend: serverFind.isFromFriend,
|
||||
media: serverFind.media?.map(
|
||||
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || m.url
|
||||
})
|
||||
)
|
||||
})) as MapFind[]
|
||||
);
|
||||
// Process locations with distance
|
||||
let locations = $derived.by(() => {
|
||||
if (!data.locations || !$coordinates) return data.locations || [];
|
||||
|
||||
// Filtered finds based on current filter
|
||||
let finds = $derived.by(() => {
|
||||
if (!data.user) return allFinds;
|
||||
|
||||
switch (currentFilter) {
|
||||
case 'public':
|
||||
return allFinds.filter((find) => find.isPublic === 1);
|
||||
case 'friends':
|
||||
return allFinds.filter((find) => find.isFromFriend === true);
|
||||
case 'mine':
|
||||
return allFinds.filter((find) => find.userId === data.user!.id);
|
||||
case 'all':
|
||||
default:
|
||||
return allFinds;
|
||||
}
|
||||
return data.locations
|
||||
.map((loc: Location) => ({
|
||||
...loc,
|
||||
distance: calculateDistance(
|
||||
$coordinates.latitude,
|
||||
$coordinates.longitude,
|
||||
parseFloat(loc.latitude),
|
||||
parseFloat(loc.longitude)
|
||||
)
|
||||
}))
|
||||
.sort(
|
||||
(a: Location & { distance?: number }, b: Location & { distance?: number }) =>
|
||||
(a.distance || 0) - (b.distance || 0)
|
||||
);
|
||||
});
|
||||
|
||||
function handleFilterChange(filter: string) {
|
||||
currentFilter = filter;
|
||||
}
|
||||
// Convert locations to map markers - keep the full location object
|
||||
let mapLocations: MapLocation[] = $derived(
|
||||
locations.map(
|
||||
(loc: Location): MapLocation => ({
|
||||
id: loc.id,
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
createdAt: new Date(loc.createdAt),
|
||||
userId: loc.userId,
|
||||
user: {
|
||||
id: loc.userId,
|
||||
username: loc.username
|
||||
},
|
||||
finds: (loc.finds || []).map((find) => ({
|
||||
id: find.id,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
isPublic: find.isPublic,
|
||||
media: find.media || []
|
||||
})),
|
||||
distance: loc.distance
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
function handleFindCreated(event: CustomEvent) {
|
||||
// For now, just close modal and refresh page as in original implementation
|
||||
showCreateModal = false;
|
||||
if (event.detail?.reload) {
|
||||
window.location.reload();
|
||||
function handleLocationExplore(id: string) {
|
||||
const location = locations.find((l: Location) => l.id === id);
|
||||
if (location) {
|
||||
selectedLocation = location;
|
||||
showLocationFindsModal = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFindClick(find: MapFind) {
|
||||
// Convert MapFind to FindPreviewData format
|
||||
selectedFind = {
|
||||
id: find.id,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
latitude: find.latitude,
|
||||
longitude: find.longitude,
|
||||
locationName: find.locationName,
|
||||
category: find.category,
|
||||
createdAt: find.createdAt.toISOString(),
|
||||
user: find.user,
|
||||
likeCount: find.likeCount,
|
||||
isLiked: find.isLiked,
|
||||
media: find.media?.map((m) => ({
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || m.url
|
||||
}))
|
||||
};
|
||||
function handleMapLocationClick(location: MapLocation) {
|
||||
handleLocationExplore(location.id);
|
||||
}
|
||||
|
||||
function handleFindExplore(id: string) {
|
||||
// Find the specific find and show preview
|
||||
const find = finds.find((f) => f.id === id);
|
||||
if (find) {
|
||||
handleFindClick(find);
|
||||
}
|
||||
function openCreateFindModal() {
|
||||
showCreateFindModal = true;
|
||||
}
|
||||
|
||||
function closeFindPreview() {
|
||||
selectedFind = null;
|
||||
function closeCreateFindModal() {
|
||||
showCreateFindModal = false;
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
showCreateModal = true;
|
||||
function closeLocationFindsModal() {
|
||||
showLocationFindsModal = false;
|
||||
selectedLocation = null;
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
showCreateModal = false;
|
||||
function handleFindCreated() {
|
||||
closeCreateFindModal();
|
||||
// Reload page to show new find
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function handleCreateFindFromLocation() {
|
||||
// Close location modal and open create find modal
|
||||
showLocationFindsModal = false;
|
||||
showCreateFindModal = true;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
isSidebarVisible = !isSidebarVisible;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -208,200 +188,281 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="home-container">
|
||||
<main class="main-content">
|
||||
<div class="map-section">
|
||||
<Map
|
||||
showLocationButton={true}
|
||||
autoCenter={true}
|
||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||
{finds}
|
||||
onFindClick={handleFindClick}
|
||||
/>
|
||||
</div>
|
||||
<!-- Fullscreen map -->
|
||||
<div class="map-section">
|
||||
<Map
|
||||
autoCenter={true}
|
||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||
locations={mapLocations}
|
||||
onLocationClick={handleMapLocationClick}
|
||||
sidebarVisible={isSidebarVisible}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="finds-section">
|
||||
<div class="finds-sticky-header">
|
||||
<div class="finds-header-content">
|
||||
<div class="finds-title-section">
|
||||
<h2 class="finds-title">Finds</h2>
|
||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||
</div>
|
||||
<Button onclick={openCreateModal} class="create-find-button">
|
||||
<!-- Sidebar container -->
|
||||
<div class="sidebar-container">
|
||||
<!-- Left sidebar with locations list -->
|
||||
<div class="finds-sidebar" class:hidden={!isSidebarVisible}>
|
||||
<div class="finds-header">
|
||||
{#if data.user}
|
||||
<h3 class="header-title">Locations</h3>
|
||||
<Button onclick={openCreateFindModal} class="create-find-button">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
Create Find
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="login-prompt">
|
||||
<h3>Welcome to Serengo</h3>
|
||||
<p>Login to create finds and view your friends' discoveries</p>
|
||||
<a href="/login" class="login-button">Login</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="finds-list-container">
|
||||
<LocationsList {locations} onLocationExplore={handleLocationExplore} hideTitle={true} />
|
||||
</div>
|
||||
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Floating action button for mobile -->
|
||||
<button class="fab" onclick={openCreateModal} aria-label="Create new find">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Toggle button -->
|
||||
<button
|
||||
class="sidebar-toggle"
|
||||
class:collapsed={!isSidebarVisible}
|
||||
onclick={toggleSidebar}
|
||||
aria-label="Toggle locations list"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
{#if isSidebarVisible}
|
||||
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{:else}
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
{#if showCreateModal}
|
||||
<CreateFindModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={closeCreateModal}
|
||||
{#if showCreateFindModal}
|
||||
<SelectLocationModal
|
||||
isOpen={showCreateFindModal}
|
||||
onClose={closeCreateFindModal}
|
||||
onFindCreated={handleFindCreated}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if selectedFind}
|
||||
<FindPreview find={selectedFind} onClose={closeFindPreview} />
|
||||
{#if showLocationFindsModal && selectedLocation}
|
||||
<LocationFindsModal
|
||||
isOpen={showLocationFindsModal}
|
||||
location={selectedLocation}
|
||||
currentUserId={data.user?.id}
|
||||
onClose={closeLocationFindsModal}
|
||||
onCreateFind={handleCreateFindFromLocation}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.home-container {
|
||||
background-color: #f8f8f8;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 500px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.finds-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.finds-sticky-header {
|
||||
position: sticky;
|
||||
.map-section {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: white;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
padding: 24px 24px 16px 24px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finds-header-content {
|
||||
.map-section :global(.map-container) {
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.map-section :global(.maplibregl-map) {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-left: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.finds-sidebar {
|
||||
width: 40%;
|
||||
max-width: 1000px;
|
||||
min-width: 500px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.finds-sidebar.hidden {
|
||||
display: none;
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.finds-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.finds-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.finds-title {
|
||||
.header-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.login-prompt {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-prompt h3 {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.login-prompt p {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
.finds-list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:global(.create-find-button) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
:global(.mr-2) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.finds-sticky-header {
|
||||
padding: 16px 16px 12px 16px;
|
||||
}
|
||||
|
||||
.finds-header-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.finds-title-section {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.finds-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.create-find-button) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fab {
|
||||
.sidebar-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-toggle.collapsed {
|
||||
margin: 12px auto;
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.finds-sidebar {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
min-width: 0;
|
||||
border-radius: 20px 20px 0 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.finds-sidebar.hidden {
|
||||
display: none;
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.finds-header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 300px;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.main-content {
|
||||
padding: 12px;
|
||||
.finds-sidebar {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.finds-sticky-header {
|
||||
padding: 12px 12px 8px 12px;
|
||||
.finds-header {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
||||
import { location, find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
||||
import { eq, and, sql, desc, or } from 'drizzle-orm';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
function generateFindId(): string {
|
||||
function generateId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||
return encodeBase64url(bytes);
|
||||
}
|
||||
|
||||
// GET endpoint now returns finds for a specific location
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const lat = url.searchParams.get('lat');
|
||||
const lng = url.searchParams.get('lng');
|
||||
const radius = url.searchParams.get('radius') || '50';
|
||||
const locationId = url.searchParams.get('locationId');
|
||||
const includePrivate = url.searchParams.get('includePrivate') === 'true';
|
||||
const order = url.searchParams.get('order') || 'desc';
|
||||
|
||||
const includeFriends = url.searchParams.get('includeFriends') === 'true';
|
||||
|
||||
if (!locationId) {
|
||||
throw error(400, 'locationId is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user's friends if needed
|
||||
// Get user's friends if needed and user is logged in
|
||||
let friendIds: string[] = [];
|
||||
if (includeFriends || includePrivate) {
|
||||
if (locals.user && (includeFriends || includePrivate)) {
|
||||
const friendships = await db
|
||||
.select({
|
||||
userId: friendship.userId,
|
||||
@@ -37,7 +35,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
.where(
|
||||
and(
|
||||
eq(friendship.status, 'accepted'),
|
||||
or(eq(friendship.userId, locals.user!.id), eq(friendship.friendId, locals.user!.id))
|
||||
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -47,12 +45,12 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
// Build privacy conditions
|
||||
const conditions = [sql`${find.isPublic} = 1`]; // Always include public finds
|
||||
|
||||
if (includePrivate) {
|
||||
if (locals.user && includePrivate) {
|
||||
// Include user's own finds (both public and private)
|
||||
conditions.push(sql`${find.userId} = ${locals.user!.id}`);
|
||||
conditions.push(sql`${find.userId} = ${locals.user.id}`);
|
||||
}
|
||||
|
||||
if (includeFriends && friendIds.length > 0) {
|
||||
if (locals.user && includeFriends && friendIds.length > 0) {
|
||||
// Include friends' finds (both public and private)
|
||||
conditions.push(
|
||||
sql`${find.userId} IN (${sql.join(
|
||||
@@ -62,68 +60,51 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const baseCondition = sql`(${sql.join(conditions, sql` OR `)})`;
|
||||
const privacyCondition = sql`(${sql.join(conditions, sql` OR `)})`;
|
||||
const whereConditions = and(eq(find.locationId, locationId), privacyCondition);
|
||||
|
||||
let whereConditions = baseCondition;
|
||||
|
||||
// Add location filtering if coordinates provided
|
||||
if (lat && lng) {
|
||||
const radiusKm = parseFloat(radius);
|
||||
const latOffset = radiusKm / 111;
|
||||
const lngOffset = radiusKm / (111 * Math.cos((parseFloat(lat) * Math.PI) / 180));
|
||||
|
||||
const locationConditions = and(
|
||||
baseCondition,
|
||||
sql`${find.latitude} BETWEEN ${parseFloat(lat) - latOffset} AND ${
|
||||
parseFloat(lat) + latOffset
|
||||
}`,
|
||||
sql`${find.longitude} BETWEEN ${parseFloat(lng) - lngOffset} AND ${
|
||||
parseFloat(lng) + lngOffset
|
||||
}`
|
||||
);
|
||||
|
||||
if (locationConditions) {
|
||||
whereConditions = locationConditions;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all finds with filtering, like counts, and user's liked status
|
||||
// Get all finds at this location with filtering, like counts, and user's liked status
|
||||
const finds = await db
|
||||
.select({
|
||||
id: find.id,
|
||||
locationId: find.locationId,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
latitude: find.latitude,
|
||||
longitude: find.longitude,
|
||||
locationName: find.locationName,
|
||||
locationName: location.locationName,
|
||||
category: find.category,
|
||||
rating: find.rating,
|
||||
ratingCount: find.ratingCount,
|
||||
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: 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`,
|
||||
isFromFriend: sql<boolean>`CASE WHEN ${
|
||||
friendIds.length > 0
|
||||
? sql`${find.userId} IN (${sql.join(
|
||||
friendIds.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
: sql`FALSE`
|
||||
} THEN 1 ELSE 0 END`
|
||||
isLikedByUser: locals.user
|
||||
? sql<boolean>`CASE WHEN EXISTS(
|
||||
SELECT 1 FROM ${findLike}
|
||||
WHERE ${findLike.findId} = ${find.id}
|
||||
AND ${findLike.userId} = ${locals.user.id}
|
||||
) THEN 1 ELSE 0 END`
|
||||
: sql<boolean>`0`,
|
||||
isFromFriend: locals.user
|
||||
? sql<boolean>`CASE WHEN ${
|
||||
friendIds.length > 0
|
||||
? sql`${find.userId} IN (${sql.join(
|
||||
friendIds.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
: sql`FALSE`
|
||||
} THEN 1 ELSE 0 END`
|
||||
: sql<boolean>`0`
|
||||
})
|
||||
.from(find)
|
||||
.innerJoin(user, eq(find.userId, user.id))
|
||||
.innerJoin(location, eq(find.locationId, location.id))
|
||||
.leftJoin(findLike, eq(find.id, findLike.findId))
|
||||
.where(whereConditions)
|
||||
.groupBy(find.id, user.username, user.profilePictureUrl)
|
||||
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt)
|
||||
.limit(100);
|
||||
.groupBy(find.id, user.username, user.profilePictureUrl, location.locationName)
|
||||
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt);
|
||||
|
||||
// Get media for all finds
|
||||
const findIds = finds.map((f) => f.id);
|
||||
@@ -176,31 +157,24 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
// Generate signed URLs for all media items
|
||||
const mediaWithSignedUrls = await Promise.all(
|
||||
findMedia.map(async (mediaItem) => {
|
||||
// URLs in database are now paths, generate signed URLs directly
|
||||
const [signedUrl, signedThumbnailUrl] = await Promise.all([
|
||||
getSignedR2Url(mediaItem.url, 24 * 60 * 60), // 24 hours
|
||||
const localUrl = getLocalR2Url(mediaItem.url);
|
||||
const localThumbnailUrl =
|
||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||
? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60)
|
||||
: Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is
|
||||
]);
|
||||
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||
: mediaItem.thumbnailUrl;
|
||||
|
||||
return {
|
||||
...mediaItem,
|
||||
url: signedUrl,
|
||||
thumbnailUrl: signedThumbnailUrl
|
||||
url: localUrl,
|
||||
thumbnailUrl: localThumbnailUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate signed URL for user profile picture if it exists
|
||||
// Generate local proxy URL for user profile picture if it exists
|
||||
let userProfilePictureUrl = findItem.profilePictureUrl;
|
||||
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
||||
try {
|
||||
userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for user profile picture:', error);
|
||||
userProfilePictureUrl = null;
|
||||
}
|
||||
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -220,16 +194,17 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// POST endpoint creates a find (post) at a location
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const { title, description, latitude, longitude, locationName, category, isPublic, media } = data;
|
||||
const { locationId, title, description, category, isPublic, media } = data;
|
||||
|
||||
if (!title || !latitude || !longitude) {
|
||||
throw error(400, 'Title, latitude, and longitude are required');
|
||||
if (!title || !locationId) {
|
||||
throw error(400, 'Title and locationId are required');
|
||||
}
|
||||
|
||||
if (title.length > 100) {
|
||||
@@ -240,19 +215,28 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
throw error(400, 'Description must be 500 characters or less');
|
||||
}
|
||||
|
||||
const findId = generateFindId();
|
||||
// Verify location exists
|
||||
const locationExists = await db
|
||||
.select({ id: location.id })
|
||||
.from(location)
|
||||
.where(eq(location.id, locationId))
|
||||
.limit(1);
|
||||
|
||||
if (locationExists.length === 0) {
|
||||
throw error(404, 'Location not found');
|
||||
}
|
||||
|
||||
const findId = generateId();
|
||||
|
||||
// Create find
|
||||
const newFind = await db
|
||||
.insert(find)
|
||||
.values({
|
||||
id: findId,
|
||||
locationId,
|
||||
userId: locals.user.id,
|
||||
title,
|
||||
description,
|
||||
latitude: latitude.toString(),
|
||||
longitude: longitude.toString(),
|
||||
locationName,
|
||||
category,
|
||||
isPublic: isPublic ? 1 : 0
|
||||
})
|
||||
@@ -262,7 +246,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (media && media.length > 0) {
|
||||
const mediaRecords = media.map(
|
||||
(item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
|
||||
id: generateFindId(),
|
||||
id: generateId(),
|
||||
findId,
|
||||
type: item.type,
|
||||
url: item.url,
|
||||
|
||||
304
src/routes/api/finds/[findId]/+server.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { find, findMedia, user, findLike, findComment, location } from '$lib/server/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { getLocalR2Url, deleteFromR2 } from '$lib/server/r2';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const findId = params.findId;
|
||||
|
||||
if (!findId) {
|
||||
throw error(400, 'Find ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the find with user info and like count
|
||||
const findResult = await db
|
||||
.select({
|
||||
id: find.id,
|
||||
title: find.title,
|
||||
description: find.description,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
locationName: location.locationName,
|
||||
category: find.category,
|
||||
rating: find.rating,
|
||||
ratingCount: find.ratingCount,
|
||||
isPublic: find.isPublic,
|
||||
createdAt: find.createdAt,
|
||||
userId: find.userId,
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl,
|
||||
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
|
||||
commentCount: sql<number>`COALESCE((
|
||||
SELECT COUNT(*) FROM ${findComment}
|
||||
WHERE ${findComment.findId} = ${find.id}
|
||||
), 0)`,
|
||||
isLikedByUser: locals.user
|
||||
? sql<boolean>`CASE WHEN EXISTS(
|
||||
SELECT 1 FROM ${findLike}
|
||||
WHERE ${findLike.findId} = ${find.id}
|
||||
AND ${findLike.userId} = ${locals.user.id}
|
||||
) THEN 1 ELSE 0 END`
|
||||
: sql<boolean>`0`
|
||||
})
|
||||
.from(find)
|
||||
.innerJoin(location, eq(find.locationId, location.id))
|
||||
.innerJoin(user, eq(find.userId, user.id))
|
||||
.leftJoin(findLike, eq(find.id, findLike.findId))
|
||||
.where(eq(find.id, findId))
|
||||
.groupBy(
|
||||
find.id,
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
location.locationName,
|
||||
user.username,
|
||||
user.profilePictureUrl
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (findResult.length === 0) {
|
||||
throw error(404, 'Find not found');
|
||||
}
|
||||
|
||||
const findData = findResult[0];
|
||||
|
||||
// Check if the find is public or if user has access
|
||||
const isOwner = locals.user && findData.userId === locals.user.id;
|
||||
const isPublic = findData.isPublic === 1;
|
||||
|
||||
if (!isPublic && !isOwner) {
|
||||
throw error(403, 'This find is private');
|
||||
}
|
||||
|
||||
// Get media for the find
|
||||
const media = await db
|
||||
.select({
|
||||
id: findMedia.id,
|
||||
findId: findMedia.findId,
|
||||
type: findMedia.type,
|
||||
url: findMedia.url,
|
||||
thumbnailUrl: findMedia.thumbnailUrl,
|
||||
orderIndex: findMedia.orderIndex
|
||||
})
|
||||
.from(findMedia)
|
||||
.where(eq(findMedia.findId, findId))
|
||||
.orderBy(findMedia.orderIndex);
|
||||
|
||||
// Generate signed URLs for media
|
||||
const mediaWithSignedUrls = await Promise.all(
|
||||
media.map(async (mediaItem) => {
|
||||
const localUrl = getLocalR2Url(mediaItem.url);
|
||||
const localThumbnailUrl =
|
||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||
: mediaItem.thumbnailUrl;
|
||||
|
||||
return {
|
||||
...mediaItem,
|
||||
url: localUrl,
|
||||
thumbnailUrl: localThumbnailUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate local proxy URL for user profile picture
|
||||
let userProfilePictureUrl = findData.profilePictureUrl;
|
||||
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
||||
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
|
||||
}
|
||||
|
||||
return json({
|
||||
...findData,
|
||||
profilePictureUrl: userProfilePictureUrl,
|
||||
media: mediaWithSignedUrls,
|
||||
isLikedByUser: Boolean(findData.isLikedByUser)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading find:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to load find');
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const findId = params.findId;
|
||||
|
||||
if (!findId) {
|
||||
throw error(400, 'Find ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// First, verify the find exists and user owns it
|
||||
const existingFind = await db
|
||||
.select({ userId: find.userId })
|
||||
.from(find)
|
||||
.where(eq(find.id, findId))
|
||||
.limit(1);
|
||||
|
||||
if (existingFind.length === 0) {
|
||||
throw error(404, 'Find not found');
|
||||
}
|
||||
|
||||
if (existingFind[0].userId !== locals.user.id) {
|
||||
throw error(403, 'You do not have permission to edit this find');
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const data = await request.json();
|
||||
const { title, description, category, isPublic, media, mediaToDelete } = data;
|
||||
|
||||
// Validate required fields
|
||||
if (!title) {
|
||||
throw error(400, 'Title is required');
|
||||
}
|
||||
|
||||
if (title.length > 100) {
|
||||
throw error(400, 'Title must be 100 characters or less');
|
||||
}
|
||||
|
||||
if (description && description.length > 500) {
|
||||
throw error(400, 'Description must be 500 characters or less');
|
||||
}
|
||||
|
||||
// Delete media items if specified
|
||||
if (mediaToDelete && Array.isArray(mediaToDelete) && mediaToDelete.length > 0) {
|
||||
// Get media URLs before deleting from database
|
||||
const mediaToRemove = await db
|
||||
.select({ url: findMedia.url, thumbnailUrl: findMedia.thumbnailUrl })
|
||||
.from(findMedia)
|
||||
.where(
|
||||
sql`${findMedia.id} IN (${sql.join(
|
||||
mediaToDelete.map((id: string) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
);
|
||||
|
||||
// Delete from R2 storage
|
||||
for (const mediaItem of mediaToRemove) {
|
||||
try {
|
||||
await deleteFromR2(mediaItem.url);
|
||||
if (mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')) {
|
||||
await deleteFromR2(mediaItem.thumbnailUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting media from R2:', err);
|
||||
// Continue even if R2 deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await db.delete(findMedia).where(
|
||||
sql`${findMedia.id} IN (${sql.join(
|
||||
mediaToDelete.map((id: string) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
);
|
||||
}
|
||||
|
||||
// Update the find
|
||||
const updatedFind = await db
|
||||
.update(find)
|
||||
.set({
|
||||
title,
|
||||
description: description || null,
|
||||
category: category || null,
|
||||
isPublic: isPublic ? 1 : 0,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(find.id, findId))
|
||||
.returning();
|
||||
|
||||
// Add new media records if provided
|
||||
if (media && Array.isArray(media) && media.length > 0) {
|
||||
const newMediaRecords = media
|
||||
.filter((item: { id?: string }) => !item.id) // Only insert media without IDs (new uploads)
|
||||
.map((item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
|
||||
id: crypto.randomUUID(),
|
||||
findId,
|
||||
type: item.type,
|
||||
url: item.url,
|
||||
thumbnailUrl: item.thumbnailUrl || null,
|
||||
orderIndex: index
|
||||
}));
|
||||
|
||||
if (newMediaRecords.length > 0) {
|
||||
await db.insert(findMedia).values(newMediaRecords);
|
||||
}
|
||||
}
|
||||
|
||||
return json({ success: true, find: updatedFind[0] });
|
||||
} catch (err) {
|
||||
console.error('Error updating find:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to update find');
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const findId = params.findId;
|
||||
|
||||
if (!findId) {
|
||||
throw error(400, 'Find ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// First, verify the find exists and user owns it
|
||||
const existingFind = await db
|
||||
.select({ userId: find.userId })
|
||||
.from(find)
|
||||
.where(eq(find.id, findId))
|
||||
.limit(1);
|
||||
|
||||
if (existingFind.length === 0) {
|
||||
throw error(404, 'Find not found');
|
||||
}
|
||||
|
||||
if (existingFind[0].userId !== locals.user.id) {
|
||||
throw error(403, 'You do not have permission to delete this find');
|
||||
}
|
||||
|
||||
// Get all media for this find to delete from R2
|
||||
const mediaItems = await db
|
||||
.select({ url: findMedia.url, thumbnailUrl: findMedia.thumbnailUrl })
|
||||
.from(findMedia)
|
||||
.where(eq(findMedia.findId, findId));
|
||||
|
||||
// Delete media from R2 storage
|
||||
for (const mediaItem of mediaItems) {
|
||||
try {
|
||||
await deleteFromR2(mediaItem.url);
|
||||
if (mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')) {
|
||||
await deleteFromR2(mediaItem.thumbnailUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting media from R2:', err);
|
||||
// Continue even if R2 deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the find (cascade will handle media, likes, and comments)
|
||||
await db.delete(find).where(eq(find.id, findId));
|
||||
|
||||
return json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error deleting find:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to delete find');
|
||||
}
|
||||
};
|
||||