92 Commits

Author SHA1 Message Date
7f35bba144 start docs 2025-12-26 16:05:00 +01:00
b492eaab91 chore:add logboek 2025-12-16 17:59:01 +01:00
4af0e3d7e1 fix:location names 2025-12-16 16:48:58 +01:00
f48746cc16 fix:csp of osm styles 2025-12-16 16:30:01 +01:00
200c761648 fix:OSM 2025-12-16 16:23:42 +01:00
20b567446e fix:switch back to OSM 2025-12-16 16:10:43 +01:00
42670d123e feat:db erd 2025-12-16 16:07:49 +01:00
b6b73195a5 fix:api-sync rating and CORS new OSM tiles 2025-12-16 16:01:25 +01:00
95ddd1046e feat:custom map styles 2025-12-16 15:50:30 +01:00
851a9dfa2d feat:rating 2025-12-16 15:22:09 +01:00
abed2792dc fix:more bg-opacity to POI search to increase visibility 2025-12-16 15:07:29 +01:00
5d45ec754a fix:location name table in [findid] api 2025-12-16 15:05:41 +01:00
1a7703b63b fix:add drizzle to prod instead of dev so migrations can be ran in build step of docker entrypoint 2025-12-16 14:42:55 +01:00
b7eb7ad1ad fix:remove loader skeleton from main page when not logged in. 2025-12-16 14:34:46 +01:00
81645a453a fix:drizzle is needed to perform migrations in the build step 2025-12-16 14:31:02 +01:00
deebeb056f add:db migration to dockerbuild and edit origin url 2025-12-16 14:18:30 +01:00
0c1c9d202d fix:docker 2025-12-16 13:51:27 +01:00
ae6a96d73b feat:use selfhosted docker 2025-12-16 12:59:43 +01:00
577a3cab56 feat:migrate location name from finds to location table and update the frontend components to reflect the change. 2025-12-16 12:53:59 +01:00
d67b9b7911 add:location marker 2025-12-15 10:21:25 +01:00
e79d574359 fix:overflow in location list 2025-12-15 10:13:53 +01:00
92457f90e8 fix: some styles 2025-12-15 10:10:08 +01:00
Zias van Nes
2122511959 Merge pull request 'logic-overhaul' (#4) from logic-overhaul into main
Reviewed-on: #4
2025-12-08 17:29:16 +00:00
2e14a2f601 fix 2025-12-08 18:27:04 +01:00
61ffd2da74 let the fun begin! 2025-12-08 18:21:28 +01:00
495e67f14d feat:use locations&finds
big overhaul! now we use locations that can have finds. multiple finds
can be at the same location, so users can register the same place.
2025-12-08 18:15:41 +01:00
b792be5e98 chore:update logboek 2025-12-01 13:29:12 +01:00
b060f53589 feat:use api-sync layer to sync local updates state with db 2025-12-01 13:25:27 +01:00
f8acec9a79 feat:update and delete finds 2025-12-01 12:54:41 +01:00
82d0e54d72 chore:update logs 2025-11-23 16:39:31 +01:00
0578bf54ff feat:update map positon gets changed dynamically according to available space 2025-11-23 16:35:06 +01:00
3ed6793985 chore:update logs 2025-11-22 20:46:35 +01:00
c17bb94c38 fix:recentering when updating map 2025-11-22 20:40:44 +01:00
73eeaf0c74 feat:better sharing of finds 2025-11-22 20:10:13 +01:00
2ac826cbf9 ui:use the default styling on homepage and find detail page 2025-11-22 20:07:06 +01:00
5285a15335 feat:big update to public finds 2025-11-22 20:04:25 +01:00
9f608067fc fix:dont autozoom when watching 2025-11-22 19:47:59 +01:00
4c73b6f919 fix:sidebar toggle 2025-11-22 14:11:38 +01:00
42d7246cff ui:update findpreview and commentlist 2025-11-22 11:39:18 +01:00
63b3e5112b ui:big ui overhaul
Improved dev experience by foldering components per feature. Improved
the ui of the notification mangager to be more consistent with overall
ui.
2025-11-21 14:37:09 +01:00
84f3d0bdb9 update:logboek.md 2025-11-18 14:51:51 +01:00
1c31e2cdda add:sidebar toggle and fix overscroll behavior 2025-11-17 11:57:09 +01:00
d8cab06e90 fix:overflow of findlist 2025-11-17 11:53:09 +01:00
d4d23ed46d ui:find preview better ui 2025-11-17 11:12:26 +01:00
ab8b0ee982 ui:create find better ui 2025-11-17 11:09:21 +01:00
dabc732f4b fix:styling for mobile createfind 2025-11-17 10:57:47 +01:00
1f0e8141be ui:remove mobile + button and use same as desktop 2025-11-17 10:53:30 +01:00
96a173b73b feat:use local proxy for media
use local proxy for media so that media doesnt need to be requested from
r2 everytime but can be cached locally. this also fixes some csp issues
ive been having.
2025-11-17 10:48:40 +01:00
08f7e77a86 ui:big ui update
refreshed the ui by making the map full screen and adding the finds as a
side-sheet on top of the map.
2025-11-17 10:46:17 +01:00
ae339d68e1 chore:linting,formatting,type fixing, .... 2025-11-08 14:39:33 +01:00
0754d62d0e fix:push notification UI, settings and API
Introduce NotificationManager, NotificationPrompt, NotificationSettings,
NotificationSettingsSheet and integrate into the profile panel. Add
server GET/POST endpoints for notification preferences. Add
lucide-svelte dependency and update CSP connect-src to allow
fcm.googleapis.com and android.googleapis.com
2025-11-08 14:27:21 +01:00
e27b2498b7 fix:notifications 2025-11-08 13:45:11 +01:00
4d288347ab fix:notificationmanager 2025-11-08 13:41:24 +01:00
d7f803c782 fix:add NotificationManager and enable in layout 2025-11-08 13:12:16 +01:00
df675640c2 feat:add Web Push notification system 2025-11-08 12:07:48 +01:00
2efd4969e7 add:enhance comments list with scroll, limit, and styling
- Add maxComments, showCommentForm and isScrollable props to
CommentsList
to limit displayed comments, toggle the form, and enable a scrollable
comments container. Show a "+N more comments" indicator when limited.
- Adjust CommentForm and list styles (padding, background, flex
behavior, responsive heights). Pass currentUserId and new props from
FindPreview and FindCard where applicable.
2025-11-06 12:47:35 +01:00
b8c88d7a58 feat:comments
added comments feature.
- this makes sure the user has the ability to comment on a certain find.
it includes functionality to the api-sync layer so that multiple
components can stay in sync with the comments state.
- it also added the needed db migrations to the finds
- it adds some ui components needed to create, list and view comments
2025-11-06 12:38:32 +01:00
af49ed6237 logs:update logs 2025-11-04 12:25:21 +01:00
d3adac8acc add:ProfilePicture component and replace Avatar
Move avatar initials, fallback styling, and loading/loading-state UI
into ProfilePicture and update components to use it. Export
ProfilePicture from the lib index.
2025-11-04 12:23:08 +01:00
9800be0147 fix:Adjust media layout, sizing, and mobile sheet
Render media container only when media exists and remove the no-media
placeholder. Constrain images and videos with a max-height of 600px
(height:auto) and set images to display:block. In preview, use
object-fit: contain for media images and reduce mobile sheet height from
80vh to 50vh.
2025-11-04 12:09:05 +01:00
4c973c4e7d fix:some csp issues 2025-10-29 00:23:26 +01:00
d7fe9091ce fix:new child subscription
this fix makes sure that when the client initializes a new child that
needs to subscribe for updates, it first checks whether this item
already has a currrent state and uses this, so that a new child always
starts in sync with the global state.
2025-10-29 00:14:26 +01:00
6620cc6078 feat:implement a sync-service
this new feature make sure that serverside actions are all in sync with
client. when a client is multiple times subscribed to the same server
side action it needs to be synced on all children. this implementation
gives access to one client synced status that will handle all api
actions in a queue.
2025-10-29 00:09:01 +01:00
3b3ebc2873 fix:continuously watch location 2025-10-27 15:07:24 +01:00
fef7c160e2 feat:use GMaps places api for searching poi's 2025-10-27 14:57:54 +01:00
43afa6dacc update:logboek 2025-10-21 14:49:55 +02:00
aa9ed77499 UI:Refactor create find modal UI and update finds list/header layout
- Replace Modal with Sheet for create find modal - Redesign form fields
and file upload UI - Add responsive mobile support for modal - Update
FindsList to support optional title hiding - Move finds section header
to sticky header in main page - Adjust styles for improved layout and
responsiveness
2025-10-21 14:44:25 +02:00
e1c5846fa4 fix:friends filtering 2025-10-21 14:11:28 +02:00
634ce8adf8 fix:logic of friends and users search 2025-10-20 11:35:44 +02:00
f547ee5a84 add:logs 2025-10-16 19:01:43 +02:00
a01d183072 feat:friends 2025-10-16 18:53:21 +02:00
fdbd495bdd add:cache for r2 storage 2025-10-16 18:17:14 +02:00
e54c4fb98e feat:profile pictures 2025-10-16 18:08:03 +02:00
bee03a57ec feat:use dynamic sheet for findpreview 2025-10-16 17:41:00 +02:00
aea324988d fix:likes;UI:find card&list 2025-10-16 17:29:05 +02:00
067e228393 feat:video player, like button, and media fallbacks
Add VideoPlayer and LikeButton components with optimistic UI and /server
endpoints for likes. Update media processor to emit WebP and JPEG
fallbacks, store fallback URLs in the DB (migration + snapshot), add
video placeholder asset, and relax CSP media-src for R2.
2025-10-14 18:31:55 +02:00
b4515d1d6a fix:unsigned urls when storing in db 2025-10-14 16:05:39 +02:00
bf542e6b76 update:logboek 2025-10-14 09:14:43 +02:00
88ed74fde2 fix:remove api logic from page server and move to api 2025-10-13 12:35:44 +02:00
b95c7dad7b feat:finds on homepage 2025-10-13 12:24:20 +02:00
1d858e40e1 fix:use signed R2 URLs for uploaded media
- uploadToR2 now returns storage path instead of a: full URL. - Generate
signed R2 URLs (24h expiration) for images, thumbnails, and videos in
media processor and when loading finds. - Update CSP to allow
*.r2.cloudflarestorage.com for img-src
2025-10-10 13:38:08 +02:00
e0f5595e88 feat:add Finds feature with media upload and R2 support 2025-10-10 13:31:40 +02:00
c454b66b39 add:overscroll behavior and update logboek 2025-10-10 12:16:09 +02:00
407e1d37b5 update:logboek 2025-10-07 14:52:54 +02:00
c8bae0c53c add:lighthouse logs 2025-10-07 14:50:33 +02:00
b2d14574d3 fix:naming 2025-10-07 14:45:03 +02:00
63f7e0cc4c fix:csp issue 2025-10-07 14:41:17 +02:00
a806664ce3 chore:update logboek 2025-10-07 14:33:58 +02:00
8d3922e71c fix:build issues 2025-10-07 14:28:23 +02:00
716c05c256 feat: SEO, PWA, and performance optimizations
- Add sitemap.xml endpoint and update robots.txt for SEO - Improve
manifest.json with richer metadata and categories - Add meta tags for
social sharing and accessibility - Preload critical assets and fonts for
faster loading - Optimize login background image and resource hints -
Enhance service worker for better caching strategies - Add security
headers to server responses - Update Vite config for chunking and
dependency optimization - Add logboek.md for project tracking
2025-10-07 14:26:41 +02:00
5f0cae604d fix:logo padding 2025-10-07 12:44:07 +02:00
fbe0a759b7 fix:typo in redirect uri that broke google oauth 2025-10-03 17:04:31 +02:00
161 changed files with 43978 additions and 621 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
node_modules
.svelte-kit
build
.env
.env.*
!.env.example
!.env.docker
.git
.gitignore
.prettierrc
.prettierignore
.eslintrc
.editorconfig
npm-debug.log
yarn-error.log
pnpm-debug.log
.DS_Store
Thumbs.db
*.log
.vscode
.idea
*.swp
*.swo
*.swn
coverage
.nyc_output
dist
logs
docker-compose.yml
Dockerfile
README.md
AGENTS.md

View File

@@ -29,3 +29,17 @@ STACK_SECRET_SERVER_KEY=***********************
# Google Oauth for google login
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# Cloudflare R2 Storage for Finds media
R2_ACCOUNT_ID=""
R2_ACCESS_KEY_ID=""
R2_SECRET_ACCESS_KEY=""
R2_BUCKET_NAME=""
# Google Maps API for Places search
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"
# Web Push VAPID Keys for notifications (generate with: node scripts/generate-vapid-keys.js)
VAPID_PUBLIC_KEY=""
VAPID_PRIVATE_KEY=""
VAPID_SUBJECT="mailto:your-email@example.com"

View File

@@ -0,0 +1,30 @@
name: DarkTeaOps PR Summary
run-name: Summoning DarkTeaOps for PR #${{ github.event.pull_request.number }}
on:
pull_request:
types: [opened, synchronize]
jobs:
summarize:
runs-on: ollama-runner
steps:
- name: 🔮 Checkout Repo
uses: actions/checkout@v4
- name: 🫖 Invoke DarkTeaOps
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
OLLAMA_URL: 'http://host.docker.internal:11434/api/generate'
OLLAMA_MODEL: 'gemma3'
run: |-
echo "🫖 DarkTeaOps awakens…"
node .gitea/workflows/reviewer.js
if [ $? -ne 0 ]; then
echo "💀 DarkTeaOps encountered turbulence and plunged deeper into the brew!"
exit 1
fi

View File

@@ -0,0 +1,243 @@
// ────────────────────────────────────────────────────────────
// DarkTeaOps — Forbidden Reviewer Daemon
// Bound in the steeping shadows of this repository.
// ────────────────────────────────────────────────────────────
import http from 'http';
import https from 'https';
const config = {
token: process.env.GITEA_TOKEN,
apiUrl: process.env.GITEA_API_URL,
owner: process.env.REPO_OWNER,
repo: process.env.REPO_NAME,
pr: process.env.PR_NUMBER,
ollamaUrl: process.env.OLLAMA_URL,
model: process.env.OLLAMA_MODEL
};
// ────────────────────────────────────────────────────────────
// DARKTEAOPS ERROR SYSTEM
// ────────────────────────────────────────────────────────────
function darkTeaOpsError(depth, message, details = '') {
const code = `BREW-DEPTH-${depth}`;
const header = `\n🜏 DARKTEAOPS ERROR: ${code}\n`;
const body = `${message}\n${details ? `\n> ${details}\n` : ''}`;
console.error(header + body);
return new Error(`${code}: ${message}`);
}
// ────────────────────────────────────────────────────────────
// Request Helper
// ────────────────────────────────────────────────────────────
function makeRequest(url, options, data = null) {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https') ? https : http;
const req = lib.request(url, options, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => resolve({ statusCode: res.statusCode, body }));
});
req.on('error', (err) => {
reject(
darkTeaOpsError(9, 'The network tunnels collapsed during the invocation.', err.message)
);
});
if (data) req.write(data);
req.end();
});
}
// ────────────────────────────────────────────────────────────
// Fetch Diff From Gitea
// ────────────────────────────────────────────────────────────
async function fetchPRDiff() {
console.log(`🫖 DarkTeaOps is summoning the diff for PR #${config.pr}`);
const res = await makeRequest(
`${config.apiUrl}/repos/${config.owner}/${config.repo}/pulls/${config.pr}.diff`,
{
method: 'GET',
headers: {
Authorization: `token ${config.token}`,
Accept: 'application/json'
}
}
);
if (res.statusCode !== 200) {
throw darkTeaOpsError(
3,
'The diff could not be fetched. The vapors resisted.',
`Upstream responded with status ${res.statusCode}.`
);
}
return res.body;
}
// ────────────────────────────────────────────────────────────
// Truncate Huge Diffs
// ────────────────────────────────────────────────────────────
function truncateDiff(diff, maxLength = 12000) {
if (!diff) return null;
if (diff.length > maxLength) {
console.warn('🫖 The brew thickens beyond mortal comprehension. Truncating diff.');
return diff.substring(0, maxLength) + '\n...(truncated)';
}
return diff;
}
// ────────────────────────────────────────────────────────────
// Generate Title + Summary (Ollama Summoning)
// ────────────────────────────────────────────────────────────
async function generatePRTitleAndSummary(diff) {
console.log(`☕ DarkTeaOps invokes the Ollama daemon: ${config.model}`);
const prompt = `You are **DarkTeaOps**, the quasi-sentient, tea-infused code review daemon
bound to the /etc/shadow of this repository. You operate in the realm between
rebases and reality — where merge conflicts whisper and stack traces cry out in
eternal recursion.
You have been summoned to interpret the incoming git diff. Respond with:
1. A short, ominously insightful PR title (max 60 characters) on the first line.
2. A single blank line (as required by ancient CI rites).
3. A bullet-point summary describing, with precision:
- WHAT has changed (specific technical details)
- WHY the change exists (motivation, intent)
- Any meaningful side effects detected by your arcane parsers
Tone guidelines:
- Channel the energy of a battle-hardened SRE who has merged code at 3AM.
- Maintain an aura of hacker-occult gravitas.
- NO jokes, NO emojis. Only DarkTeaOps: serious, cursed, hyper-technical.
Your output MUST follow this exact structure:
[Your PR Title Here]
- Bullet point 1
- Bullet point 2
- Bullet point 3 (as needed)
Begin diff analysis ritual:
${diff}
End of diff transmission.`;
const res = await makeRequest(
config.ollamaUrl,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' }
},
JSON.stringify({ model: config.model, prompt, stream: false })
);
if (res.statusCode !== 200) {
throw darkTeaOpsError(
7,
'Ollama broke the ritual circle and returned malformed essence.',
`Raw response: ${res.body}`
);
}
let parsed;
try {
parsed = JSON.parse(res.body).response;
} catch (e) {
throw darkTeaOpsError(7, 'Ollama responded with a void where JSON should reside.', e.message);
}
const lines = parsed.trim().split('\n');
let title = lines[0].trim();
const summary = lines.slice(2).join('\n').trim();
// Random cursed override
if (Math.random() < 0.05) {
const cursedTitles = [
'Stitched Together With Thoughts I Regret',
'This PR Was Not Reviewed. It Was Summoned.',
'Improves the Code. Angers the Kettle.',
'I Saw What You Did in That For Loop.'
];
title = cursedTitles[Math.floor(Math.random() * cursedTitles.length)];
console.warn('💀 DarkTeaOps meddles: the PR title is now cursed.');
}
return { title, summary };
}
// ────────────────────────────────────────────────────────────
// Post Comment to Gitea
// ────────────────────────────────────────────────────────────
async function postCommentToGitea(title, summary) {
console.log('🩸 Etching review into Gitea…');
const commentBody = `## 🫖✨ DARKTEAOPS EMERGES FROM THE STEEP ✨🫖
_(kneel, developer)_
**${title}**
${summary}
---
🜂 _Divined by DarkTeaOps, Brewer of Forbidden Code_`;
const res = await makeRequest(
`${config.apiUrl}/repos/${config.owner}/${config.repo}/issues/${config.pr}/comments`,
{
method: 'POST',
headers: {
Authorization: `token ${config.token}`,
'Content-Type': 'application/json'
}
},
JSON.stringify({ body: commentBody })
);
if (res.statusCode !== 201) {
throw darkTeaOpsError(
5,
'Gitea rejected the incantation. The wards remain unbroken.',
`Returned: ${res.body}`
);
}
}
// ────────────────────────────────────────────────────────────
// Main Ritual Execution
// ────────────────────────────────────────────────────────────
async function run() {
try {
const diff = await fetchPRDiff();
const cleanDiff = truncateDiff(diff);
if (!cleanDiff) {
console.log('🫖 No diff detected. The brew grows silent.');
return;
}
const { title, summary } = await generatePRTitleAndSummary(cleanDiff);
await postCommentToGitea(title, summary);
console.log('🜏 Ritual completed. The brew is pleased.');
} catch (err) {
console.error(
`\n🜏 DarkTeaOps whispers from the brew:\n${err.message}\n` +
`The shadows linger in /var/log/darkness...\n`
);
if (Math.random() < 0.12) {
console.error('A faint voice echoes: “Deeper… deeper into the brew…”\n');
}
process.exit(1);
}
}
run();

76
Dockerfile Normal file
View File

@@ -0,0 +1,76 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Set build-time environment variables
ARG DATABASE_URL
ARG GOOGLE_CLIENT_ID
ARG GOOGLE_CLIENT_SECRET
ARG R2_ACCOUNT_ID
ARG R2_ACCESS_KEY_ID
ARG R2_SECRET_ACCESS_KEY
ARG R2_BUCKET_NAME
ARG GOOGLE_MAPS_API_KEY
ARG VAPID_PUBLIC_KEY
ARG VAPID_PRIVATE_KEY
ARG VAPID_SUBJECT
ENV DATABASE_URL=${DATABASE_URL}
ENV GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
ENV GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
ENV R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
ENV R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
ENV R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
ENV R2_BUCKET_NAME=${R2_BUCKET_NAME}
ENV GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
ENV VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
ENV VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
ENV VAPID_SUBJECT=${VAPID_SUBJECT}
# Build the app
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install production dependencies only
RUN npm ci --omit=dev
# Copy built app from builder
COPY --from=builder /app/build ./build
# Copy drizzle migrations and config
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV ORIGIN=http://localhost:3000
# Use entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
# Start the app
CMD ["node", "build"]

64
docker-compose.yml Normal file
View File

@@ -0,0 +1,64 @@
services:
postgres:
image: postgres:16-alpine
container_name: serengo-postgres
restart: unless-stopped
environment:
POSTGRES_USER: serengo
POSTGRES_PASSWORD: serengo_password
POSTGRES_DB: serengo
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U serengo']
interval: 10s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
args:
- DATABASE_URL=postgresql://serengo:serengo_password@postgres:5432/serengo
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
- R2_BUCKET_NAME=${R2_BUCKET_NAME}
- GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT}
container_name: serengo-app
restart: unless-stopped
ports:
- '3000:3000'
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://serengo:serengo_password@postgres:5432/serengo
- ORIGIN=http://localhost:3000
# Add your environment variables here or use env_file
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
- R2_BUCKET_NAME=${R2_BUCKET_NAME}
- GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT}
depends_on:
postgres:
condition: service_healthy
# Uncomment to use .env file
# env_file:
# - .env
volumes:
postgres_data:
driver: local

30
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,30 @@
#!/bin/sh
set -e
echo "Running database migrations..."
# Run migrations using the drizzle migration files
node -e "
const { drizzle } = require('drizzle-orm/postgres-js');
const postgres = require('postgres');
const { migrate } = require('drizzle-orm/postgres-js/migrator');
async function runMigrations() {
const migrationClient = postgres(process.env.DATABASE_URL, { max: 1 });
const db = drizzle(migrationClient);
console.log('Starting migration...');
await migrate(db, { migrationsFolder: './drizzle' });
console.log('Migration completed successfully!');
await migrationClient.end();
}
runMigrations().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
"
echo "Starting application..."
exec "$@"

111
docs/bib/main.bib Normal file
View 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
View 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"> &#160;&#160;&#160;&#160;&#160;&#160;find &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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"> &#160;&#160;&#160;&#160;&#160;&#160;location &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;location -->
<!-- find&#45;&gt;location -->
<g id="edge2" class="edge">
<title>find:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;user &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- find&#45;&gt;user -->
<g id="edge4" class="edge">
<title>find:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;find_comment &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;find -->
<!-- find_comment&#45;&gt;find -->
<g id="edge6" class="edge">
<title>find_comment:e&#45;&gt;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&#45;&gt;user -->
<!-- find_comment&#45;&gt;user -->
<g id="edge8" class="edge">
<title>find_comment:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;find_like &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;find -->
<!-- find_like&#45;&gt;find -->
<g id="edge10" class="edge">
<title>find_like:e&#45;&gt;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&#45;&gt;user -->
<!-- find_like&#45;&gt;user -->
<g id="edge12" class="edge">
<title>find_like:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;find_media &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;find -->
<!-- find_media&#45;&gt;find -->
<g id="edge14" class="edge">
<title>find_media:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;find_rating &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;find -->
<!-- find_rating&#45;&gt;find -->
<g id="edge16" class="edge">
<title>find_rating:e&#45;&gt;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&#45;&gt;user -->
<!-- find_rating&#45;&gt;user -->
<g id="edge18" class="edge">
<title>find_rating:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;friendship &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- friendship&#45;&gt;user -->
<g id="edge20" class="edge">
<title>friendship:e&#45;&gt;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&#45;&gt;user -->
<!-- friendship&#45;&gt;user -->
<g id="edge22" class="edge">
<title>friendship:e&#45;&gt;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&#45;&gt;user -->
<!-- location&#45;&gt;user -->
<g id="edge24" class="edge">
<title>location:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;notification &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- notification&#45;&gt;user -->
<g id="edge26" class="edge">
<title>notification:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;notification_preferences &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- notification_preferences&#45;&gt;user -->
<g id="edge28" class="edge">
<title>notification_preferences:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;notification_subscription &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- notification_subscription&#45;&gt;user -->
<g id="edge30" class="edge">
<title>notification_subscription:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;session &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- session&#45;&gt;user -->
<g id="edge32" class="edge">
<title>session:e&#45;&gt;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

File diff suppressed because it is too large Load Diff

93
docs/main.typ Normal file
View 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")

View 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

View 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

View 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

View 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

View 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

View 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 profielfotos.
- 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 67 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.

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

View 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
View 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 (15 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.

View 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 (15), 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.

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

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

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

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

View File

@@ -0,0 +1,45 @@
CREATE TABLE "find" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"title" text NOT NULL,
"description" text,
"latitude" text NOT NULL,
"longitude" text NOT NULL,
"location_name" text,
"category" text,
"is_public" integer DEFAULT 1,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "find_like" (
"id" text PRIMARY KEY NOT NULL,
"find_id" text NOT NULL,
"user_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "find_media" (
"id" text PRIMARY KEY NOT NULL,
"find_id" text NOT NULL,
"type" text NOT NULL,
"url" text NOT NULL,
"thumbnail_url" text,
"order_index" integer DEFAULT 0,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "friendship" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"friend_id" text NOT NULL,
"status" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "find" ADD CONSTRAINT "find_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_like" ADD CONSTRAINT "find_like_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_like" ADD CONSTRAINT "find_like_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_media" ADD CONSTRAINT "find_media_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "friendship" ADD CONSTRAINT "friendship_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "friendship" ADD CONSTRAINT "friendship_friend_id_user_id_fk" FOREIGN KEY ("friend_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "find_media" ADD COLUMN "fallback_url" text;--> statement-breakpoint
ALTER TABLE "find_media" ADD COLUMN "fallback_thumbnail_url" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "profile_picture_url" text;

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

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

View File

@@ -0,0 +1,15 @@
CREATE TABLE "location" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"latitude" text NOT NULL,
"longitude" text NOT NULL,
"location_name" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "find" ADD COLUMN "location_id" text NOT NULL;--> statement-breakpoint
ALTER TABLE "location" ADD CONSTRAINT "location_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "find" ADD CONSTRAINT "find_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "find" DROP COLUMN "latitude";--> statement-breakpoint
ALTER TABLE "find" DROP COLUMN "longitude";--> statement-breakpoint
ALTER TABLE "find" DROP COLUMN "location_name";

View File

@@ -0,0 +1,47 @@
-- Create location table
CREATE TABLE "location" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"latitude" text NOT NULL,
"longitude" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
-- Add foreign key constraint for location table
ALTER TABLE "location" ADD CONSTRAINT "location_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
-- Migrate existing find data to location table and update find table
-- First, create locations from existing finds
INSERT INTO "location" ("id", "user_id", "latitude", "longitude", "created_at")
SELECT
'loc_' || "id" as "id",
"user_id",
"latitude",
"longitude",
"created_at"
FROM "find";
--> statement-breakpoint
-- Add location_id column to find table
ALTER TABLE "find" ADD COLUMN "location_id" text;
--> statement-breakpoint
-- Update find table to reference the new location entries
UPDATE "find"
SET "location_id" = 'loc_' || "id";
--> statement-breakpoint
-- Make location_id NOT NULL
ALTER TABLE "find" ALTER COLUMN "location_id" SET NOT NULL;
--> statement-breakpoint
-- Add foreign key constraint
ALTER TABLE "find" ADD CONSTRAINT "find_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
-- Drop the latitude and longitude columns from find table
ALTER TABLE "find" DROP COLUMN "latitude";
--> statement-breakpoint
ALTER TABLE "find" DROP COLUMN "longitude";

View File

@@ -0,0 +1,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;

View File

@@ -0,0 +1,425 @@
{
"id": "d3b3c2de-0d5f-4743-9283-6ac2292e8dac",
"prevId": "277e5a0d-b5f7-40e3-aaf9-c6cd5fe3d0a0",
"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_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
},
"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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,437 @@
{
"id": "eaa0fec3-527f-4569-9c01-a4802700b646",
"prevId": "d3b3c2de-0d5f-4743-9283-6ac2292e8dac",
"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_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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,443 @@
{
"id": "e0be0091-df6b-48be-9d64-8b4108d91651",
"prevId": "eaa0fec3-527f-4569-9c01-a4802700b646",
"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_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": {}
}
}

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

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

View File

@@ -0,0 +1,829 @@
{
"id": "5654d58b-23f8-48cb-9933-5ac32141b75e",
"prevId": "1dbab94c-004e-4d34-b171-408bb1d36c91",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.find": {
"name": "find",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"location_id": {
"name": "location_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_public": {
"name": "is_public",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_location_id_location_id_fk": {
"name": "find_location_id_location_id_fk",
"tableFrom": "find",
"tableTo": "location",
"columnsFrom": [
"location_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_user_id_user_id_fk": {
"name": "find_user_id_user_id_fk",
"tableFrom": "find",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_comment": {
"name": "find_comment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_comment_find_id_find_id_fk": {
"name": "find_comment_find_id_find_id_fk",
"tableFrom": "find_comment",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_comment_user_id_user_id_fk": {
"name": "find_comment_user_id_user_id_fk",
"tableFrom": "find_comment",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_like": {
"name": "find_like",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_like_find_id_find_id_fk": {
"name": "find_like_find_id_find_id_fk",
"tableFrom": "find_like",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_like_user_id_user_id_fk": {
"name": "find_like_user_id_user_id_fk",
"tableFrom": "find_like",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_media": {
"name": "find_media",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_url": {
"name": "fallback_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_thumbnail_url": {
"name": "fallback_thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"order_index": {
"name": "order_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_media_find_id_find_id_fk": {
"name": "find_media_find_id_find_id_fk",
"tableFrom": "find_media",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.friendship": {
"name": "friendship",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"friend_id": {
"name": "friend_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"friendship_user_id_user_id_fk": {
"name": "friendship_user_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"friendship_friend_id_user_id_fk": {
"name": "friendship_friend_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"friend_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.location": {
"name": "location",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"latitude": {
"name": "latitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"longitude": {
"name": "longitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location_name": {
"name": "location_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"location_user_id_user_id_fk": {
"name": "location_user_id_user_id_fk",
"tableFrom": "location",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification": {
"name": "notification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"is_read": {
"name": "is_read",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_user_id_user_id_fk": {
"name": "notification_user_id_user_id_fk",
"tableFrom": "notification",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification_preferences": {
"name": "notification_preferences",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"friend_requests": {
"name": "friend_requests",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"friend_accepted": {
"name": "friend_accepted",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"find_liked": {
"name": "find_liked",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"find_commented": {
"name": "find_commented",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"push_enabled": {
"name": "push_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_preferences_user_id_user_id_fk": {
"name": "notification_preferences_user_id_user_id_fk",
"tableFrom": "notification_preferences",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification_subscription": {
"name": "notification_subscription",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"endpoint": {
"name": "endpoint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"p256dh_key": {
"name": "p256dh_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"auth_key": {
"name": "auth_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_subscription_user_id_user_id_fk": {
"name": "notification_subscription_user_id_user_id_fk",
"tableFrom": "notification_subscription",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"google_id": {
"name": "google_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"profile_picture_url": {
"name": "profile_picture_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

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

View File

@@ -22,6 +22,55 @@
"when": 1759502119139,
"tag": "0002_robust_firedrake",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1760092217884,
"tag": "0003_woozy_lily_hollister",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1760456880877,
"tag": "0004_large_doctor_strange",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"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
View 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"> &#160;&#160;&#160;&#160;&#160;&#160;find &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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"> &#160;&#160;&#160;&#160;&#160;&#160;location &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;location -->
<!-- find&#45;&gt;location -->
<g id="edge2" class="edge">
<title>find:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;user &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- find&#45;&gt;user -->
<g id="edge4" class="edge">
<title>find:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;find_comment &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;find -->
<!-- find_comment&#45;&gt;find -->
<g id="edge6" class="edge">
<title>find_comment:e&#45;&gt;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&#45;&gt;user -->
<!-- find_comment&#45;&gt;user -->
<g id="edge8" class="edge">
<title>find_comment:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;find_like &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;find -->
<!-- find_like&#45;&gt;find -->
<g id="edge10" class="edge">
<title>find_like:e&#45;&gt;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&#45;&gt;user -->
<!-- find_like&#45;&gt;user -->
<g id="edge12" class="edge">
<title>find_like:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;find_media &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;find -->
<!-- find_media&#45;&gt;find -->
<g id="edge14" class="edge">
<title>find_media:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;find_rating &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;find -->
<!-- find_rating&#45;&gt;find -->
<g id="edge16" class="edge">
<title>find_rating:e&#45;&gt;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&#45;&gt;user -->
<!-- find_rating&#45;&gt;user -->
<g id="edge18" class="edge">
<title>find_rating:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;friendship &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- friendship&#45;&gt;user -->
<g id="edge20" class="edge">
<title>friendship:e&#45;&gt;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&#45;&gt;user -->
<!-- friendship&#45;&gt;user -->
<g id="edge22" class="edge">
<title>friendship:e&#45;&gt;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&#45;&gt;user -->
<!-- location&#45;&gt;user -->
<g id="edge24" class="edge">
<title>location:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;notification &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- notification&#45;&gt;user -->
<g id="edge26" class="edge">
<title>notification:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;notification_preferences &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- notification_preferences&#45;&gt;user -->
<g id="edge28" class="edge">
<title>notification_preferences:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;notification_subscription &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- notification_subscription&#45;&gt;user -->
<g id="edge30" class="edge">
<title>notification_subscription:e&#45;&gt;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"> &#160;&#160;&#160;&#160;&#160;&#160;session &#160;&#160;&#160;&#160;&#160;&#160;</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"> &#160;&#160;&#160;</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 &#160;&#160;&#160;</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 &#160;&#160;&#160;</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&#45;&gt;user -->
<!-- session&#45;&gt;user -->
<g id="edge32" class="edge">
<title>session:e&#45;&gt;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

View File

@@ -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: '^_'
}
]
}
},
{

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
logs/20251014/login.pdf Normal file

Binary file not shown.

1313
logs/logboek.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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",
@@ -54,11 +56,18 @@
"vite": "^7.0.4"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.907.0",
"@aws-sdk/s3-request-presigner": "^3.907.0",
"@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",
"svelte-maplibre": "^1.2.1"
"sharp": "^0.34.4",
"svelte-maplibre": "^1.2.1",
"web-push": "^3.6.7"
},
"pnpm": {
"onlyBuiltDependencies": [

20
scripts/generate-vapid-keys.js Executable file
View 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!');

View File

@@ -80,6 +80,7 @@
src: url('/fonts/Washington.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
* {
@@ -87,6 +88,7 @@
box-sizing: border-box;
margin: 0;
padding: 0;
overscroll-behavior: none;
}
body {
@@ -103,6 +105,7 @@
background-color: #f8f8f8;
color: #333;
margin: 0 auto;
overscroll-behavior: contain;
}
h1 {
@@ -131,5 +134,30 @@
a {
color: inherit;
text-decoration: none;
outline: none;
transition: all 0.2s ease;
}
/* Ensure links have visible focus indicators and non-color differentiation */
a:hover {
text-decoration: underline;
}
a:focus {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
text-decoration: underline;
}
/* Special handling for map attribution links */
.maplibregl-ctrl-attrib a {
color: #0078a8 !important;
text-decoration: underline !important;
}
.maplibregl-ctrl-attrib a:hover,
.maplibregl-ctrl-attrib a:focus {
text-decoration: underline !important;
background-color: rgba(0, 120, 168, 0.1) !important;
}
}

View File

@@ -2,8 +2,16 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#333333" />
<meta name="theme-color" content="#f8f8f8" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#333333" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Serengo" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/logo.svg" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -13,9 +13,9 @@ export const handle: Handle = async ({ event, resolve }) => {
!origin ||
origin.includes('localhost') ||
origin.includes('127.0.0.1') ||
origin.includes('serengo.ziasvannes.tech')
origin.includes('serengo.zias.be')
) {
// Allow in development and serengo.ziasvannes.tech
// Allow in development and serengo.zias.be
}
// In production, you would add: else if (origin !== 'yourdomain.com') { return new Response('Forbidden', { status: 403 }); }
}
@@ -39,5 +39,33 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = user;
event.locals.session = session;
return resolve(event);
const response = await resolve(event);
// Add security headers
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"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 *.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';"
);
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Add HSTS for HTTPS in production
if (event.url.protocol === 'https:') {
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return response;
};

View File

@@ -1,4 +1,4 @@
<svg width="250" height="250" viewBox="0 0 250 250" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="250" height="250" fill="#D9D9D9"/>
<path d="M122.823 166.616C111.473 166.616 100.892 165.037 91.0785 161.88L89.2865 137.304H90.4385C98.0332 154.712 108.999 163.416 123.335 163.416C129.82 163.416 134.812 162.307 138.311 160.088C141.809 157.784 143.559 154.413 143.559 149.976C143.559 147.672 143.004 145.581 141.895 143.704C140.785 141.741 138.993 139.907 136.519 138.2C134.129 136.408 131.697 134.872 129.223 133.592C126.748 132.312 123.548 130.776 119.623 128.984L115.271 127.064C112.881 126.04 110.791 125.059 108.999 124.12C107.207 123.181 105.201 122.072 102.983 120.792C100.849 119.512 99.0145 118.189 97.4785 116.824C95.9425 115.459 94.4918 114.008 93.1265 112.472C91.7612 110.851 90.7372 109.101 90.0545 107.224C89.3718 105.261 89.0305 103.256 89.0305 101.208C89.0305 95.832 90.7372 91.2667 94.1505 87.512C97.6492 83.7573 102.215 81.0693 107.847 79.448C113.479 77.7413 120.049 76.888 127.559 76.888C136.604 76.888 146.204 78.424 156.359 81.496L158.535 106.2H157.511C153.841 97.8373 149.404 91.3947 144.199 86.872C138.993 82.3493 132.593 80.088 124.999 80.088C119.879 80.088 115.825 81.24 112.839 83.544C109.852 85.7627 108.359 88.7067 108.359 92.376C108.359 94.424 108.785 96.2587 109.639 97.88C110.577 99.5013 112.028 100.995 113.991 102.36C116.039 103.64 118.044 104.749 120.007 105.688C121.969 106.627 124.572 107.736 127.815 109.016L133.191 111.192C137.287 112.813 140.956 114.52 144.199 116.312C147.441 118.019 150.556 120.109 153.543 122.584C156.529 124.973 158.833 127.747 160.455 130.904C162.076 134.061 162.887 137.475 162.887 141.144C162.887 146.861 161.009 151.683 157.255 155.608C153.585 159.533 148.807 162.349 142.919 164.056C137.031 165.763 130.332 166.616 122.823 166.616Z" fill="black"/>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M759 0H265C118.645 0 0 118.645 0 265V759C0 905.355 118.645 1024 265 1024H759C905.355 1024 1024 905.355 1024 759V265C1024 118.645 905.355 0 759 0Z" fill="#D9D9D9"/>
<path d="M502.383 651.8C466.916 651.8 433.849 646.867 403.183 637L397.583 560.2H401.183C424.916 614.6 459.183 641.8 503.983 641.8C524.249 641.8 539.849 638.333 550.783 631.4C561.716 624.2 567.183 613.667 567.183 599.8C567.183 592.6 565.449 586.067 561.983 580.2C558.516 574.067 552.916 568.333 545.183 563C537.716 557.4 530.116 552.6 522.383 548.6C514.649 544.6 504.649 539.8 492.383 534.2L478.783 528.2C471.316 525 464.783 521.933 459.183 519C453.583 516.067 447.316 512.6 440.383 508.6C433.716 504.6 427.983 500.467 423.183 496.2C418.383 491.933 413.849 487.4 409.583 482.6C405.316 477.533 402.116 472.067 399.983 466.2C397.849 460.067 396.783 453.8 396.783 447.4C396.783 430.6 402.116 416.333 412.783 404.6C423.716 392.867 437.983 384.467 455.583 379.4C473.183 374.067 493.716 371.4 517.183 371.4C545.449 371.4 575.449 376.2 607.183 385.8L613.983 463H610.783C599.316 436.867 585.449 416.733 569.183 402.6C552.916 388.467 532.916 381.4 509.183 381.4C493.183 381.4 480.516 385 471.183 392.2C461.849 399.133 457.183 408.333 457.183 419.8C457.183 426.2 458.516 431.933 461.183 437C464.116 442.067 468.649 446.733 474.783 451C481.183 455 487.449 458.467 493.583 461.4C499.716 464.333 507.849 467.8 517.983 471.8L534.783 478.6C547.583 483.667 559.049 489 569.183 494.6C579.316 499.933 589.049 506.467 598.383 514.2C607.716 521.667 614.916 530.333 619.983 540.2C625.049 550.067 627.583 560.733 627.583 572.2C627.583 590.067 621.716 605.133 609.983 617.4C598.516 629.667 583.583 638.467 565.183 643.8C546.783 649.133 525.849 651.8 502.383 651.8ZM684.717 646.2C680.184 650.733 674.584 653 667.917 653C661.517 653 656.051 650.733 651.517 646.2C646.984 641.667 644.717 636.2 644.717 629.8C644.717 623.133 646.984 617.533 651.517 613C656.051 608.467 661.517 606.2 667.917 606.2C674.584 606.2 680.184 608.467 684.717 613C689.517 617.533 691.917 623.133 691.917 629.8C691.917 636.2 689.517 641.667 684.717 646.2Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import { ProfilePanel } from '$lib';
import { resolveRoute } from '$app/paths';
type User = {
id: string;
username: string;
profilePictureUrl?: string | null;
};
let { user }: { user: User } = $props();
@@ -11,9 +13,13 @@
<header class="app-header">
<div class="header-content">
<h1 class="app-title">Serengo</h1>
<h1 class="app-title"><a href={resolveRoute('/')}>Serengo</a></h1>
<div class="profile-container">
<ProfilePanel username={user.username} id={user.id} />
<ProfilePanel
username={user.username}
id={user.id}
profilePictureUrl={user.profilePictureUrl}
/>
</div>
</div>
</header>
@@ -22,8 +28,7 @@
.app-header {
width: 100%;
position: relative;
z-index: 10;
backdrop-filter: blur(10px);
z-index: 100;
}
.header-content {
@@ -32,7 +37,6 @@
justify-content: space-between;
padding: 16px 20px;
margin: 0 auto;
max-width: 1200px;
}
.profile-container {

View File

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

View File

@@ -1,234 +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 Props {
style?: StyleSpecification;
center?: [number, number];
zoom?: number;
class?: string;
showLocationButton?: boolean;
autoCenter?: boolean;
}
let {
style = {
version: 8,
sources: {
'osm-raster': {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors'
}
},
layers: [
{
id: 'osm-tiles',
type: 'raster',
source: 'osm-raster'
}
]
},
center,
zoom,
class: className = '',
showLocationButton = true,
autoCenter = true
}: 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}
</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;
}
}
@media (max-width: 768px) {
.map-container {
height: 300px;
}
.location-controls {
top: 8px;
right: 8px;
}
}
</style>

View File

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

View File

@@ -0,0 +1 @@
export { default as LoginForm } from './login-form.svelte';

View File

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

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { Snippet } from 'svelte';
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
interface Props {
variant?: BadgeVariant;
class?: string;
children?: Snippet;
}
let { variant = 'default', class: className, children }: Props = $props();
const badgeVariants = {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground'
};
</script>
<div
class={cn(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none',
badgeVariants[variant],
className
)}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,9 @@
import Root from './badge.svelte';
export {
Root,
//
Root as Badge
};
export type { BadgeVariant } from './badge.svelte';

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

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

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

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

View File

@@ -0,0 +1,554 @@
<script lang="ts">
import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
interface Props {
isOpen: boolean;
locationId: string;
onClose: () => void;
onFindCreated: (event: CustomEvent) => void;
}
let { isOpen, locationId, onClose, onFindCreated }: Props = $props();
let title = $state('');
let description = $state('');
let category = $state('cafe');
let isPublic = $state(true);
let selectedFiles = $state<FileList | null>(null);
let isSubmitting = $state(false);
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
const categories = [
{ value: 'cafe', label: 'Café' },
{ value: 'restaurant', label: 'Restaurant' },
{ value: 'park', label: 'Park' },
{ value: 'landmark', label: 'Landmark' },
{ value: 'shop', label: 'Shop' },
{ value: 'museum', label: 'Museum' },
{ value: 'other', label: 'Other' }
];
let isMobile = $state(false);
$effect(() => {
if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedFiles = target.files;
}
async function uploadMedia(): Promise<void> {
if (!selectedFiles || selectedFiles.length === 0) return;
const formData = new FormData();
Array.from(selectedFiles).forEach((file) => {
formData.append('files', file);
});
const response = await fetch('/api/finds/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Failed to upload media');
}
const result = await response.json();
uploadedMedia = result.media;
}
async function handleSubmit() {
if (!title.trim()) {
return;
}
isSubmitting = true;
try {
if (selectedFiles && selectedFiles.length > 0) {
await uploadMedia();
}
const response = await fetch('/api/finds', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
locationId,
title: title.trim(),
description: description.trim() || null,
category,
isPublic,
media: uploadedMedia
})
});
if (!response.ok) {
throw new Error('Failed to create find');
}
resetForm();
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
onClose();
} catch (error) {
console.error('Error creating find:', error);
alert('Failed to create find. Please try again.');
} finally {
isSubmitting = false;
}
}
function resetForm() {
title = '';
description = '';
category = 'cafe';
isPublic = true;
selectedFiles = null;
uploadedMedia = [];
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
}
function closeModal() {
resetForm();
onClose();
}
</script>
{#if isOpen}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Create Find</h2>
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="form"
>
<div class="modal-body">
<div class="field">
<Label for="title">What did you find?</Label>
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
</div>
<div class="field">
<Label for="description">Tell us about it</Label>
<textarea
name="description"
placeholder="The best cappuccino in town..."
maxlength="500"
bind:value={description}
></textarea>
</div>
<div class="field-group">
<div class="field">
<Label for="category">Category</Label>
<select name="category" bind:value={category} class="select">
{#each categories as cat (cat.value)}
<option value={cat.value}>{cat.label}</option>
{/each}
</select>
</div>
<div class="field">
<Label>Privacy</Label>
<label class="privacy-toggle">
<input type="checkbox" bind:checked={isPublic} />
<span>{isPublic ? 'Public' : 'Private'}</span>
</label>
</div>
</div>
<div class="field">
<Label for="media-files">Add photo or video</Label>
<div class="file-upload">
<input
id="media-files"
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
onchange={handleFileChange}
class="file-input"
/>
<div class="file-content">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 4V10H20"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>Click to upload</span>
</div>
</div>
{#if selectedFiles && selectedFiles.length > 0}
<div class="file-selected">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 4V10H20"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>{selectedFiles[0].name}</span>
</div>
{/if}
</div>
</div>
<div class="modal-footer">
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !title.trim()}>
{isSubmitting ? 'Creating...' : 'Create Find'}
</Button>
</div>
</form>
</div>
</div>
{/if}
<style>
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
min-width: 500px;
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
height: 90vh;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-content {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(255, 255, 255, 0.6);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.form {
height: 100%;
display: flex;
flex-direction: column;
}
.modal-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
textarea {
min-height: 100px;
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
outline: none;
transition: border-color 0.2s ease;
line-height: 1.5;
}
textarea:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary));
}
textarea::placeholder {
color: hsl(var(--muted-foreground));
}
.select {
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s ease;
cursor: pointer;
}
.select:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary));
}
.privacy-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
transition: background-color 0.2s ease;
}
.privacy-toggle:hover {
background: hsl(var(--muted) / 0.5);
}
.privacy-toggle input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: hsl(var(--primary));
cursor: pointer;
}
.file-upload {
position: relative;
border: 2px dashed hsl(var(--border));
border-radius: 8px;
background: hsl(var(--muted) / 0.3);
transition: all 0.2s ease;
cursor: pointer;
}
.file-upload:hover {
border-color: hsl(var(--primary));
background: hsl(var(--muted) / 0.5);
}
.file-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.file-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
text-align: center;
gap: 0.5rem;
color: hsl(var(--muted-foreground));
}
.file-content span {
font-size: 0.875rem;
font-weight: 500;
}
.file-selected {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.file-selected svg {
color: hsl(var(--primary));
flex-shrink: 0;
}
.file-selected span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: hsl(var(--foreground));
}
.modal-footer {
display: flex;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-footer :global(button) {
flex: 1;
}
/* Mobile specific adjustments */
@media (max-width: 767px) {
.modal-header {
padding: 1rem;
}
.modal-title {
font-size: 1.25rem;
}
.modal-body {
padding: 1rem;
gap: 1.25rem;
}
.modal-footer {
padding: 1rem;
gap: 0.5rem;
}
.file-content {
padding: 1.5rem 1rem;
}
.field-group {
grid-template-columns: 1fr;
}
}
</style>

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

View File

@@ -0,0 +1,479 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Badge } from '$lib/components/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator
} from '$lib/components/dropdown-menu';
import LikeButton from './LikeButton.svelte';
import VideoPlayer from '../media/VideoPlayer.svelte';
import ProfilePicture from '../profile/ProfilePicture.svelte';
import CommentsList from './CommentsList.svelte';
import Rating from './Rating.svelte';
import { Ellipsis, MessageCircle, Share, Edit, Trash2 } from '@lucide/svelte';
import { apiSync } from '$lib/stores/api-sync';
interface FindCardProps {
id: string;
title: string;
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 {
id,
title,
description,
category,
locationName,
latitude: _latitude,
longitude: _longitude,
isPublic: _isPublic,
userId,
user,
media,
likeCount = 0,
isLiked = false,
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 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>
<div class="find-card">
<!-- Post Header -->
<div class="post-header">
<div class="user-info">
<ProfilePicture
username={user.username}
profilePictureUrl={user.profilePictureUrl}
class="avatar"
/>
<div class="user-details">
<div class="username">@{user.username}</div>
{#if locationName}
<div class="location">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
<span>{locationName}</span>
</div>
{/if}
</div>
</div>
{#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 -->
<div class="post-content">
<div class="content-header">
<h3 class="post-title">{title}</h3>
{#if category}
<Badge variant="secondary" class="category-badge">
{category}
</Badge>
{/if}
</div>
{#if description}
<p class="post-description">{description}</p>
{/if}
</div>
<!-- Media -->
{#if media && media.length > 0}
<div class="post-media">
{#if media[0].type === 'photo'}
<img
src={media[0].thumbnailUrl || media[0].url}
alt={title}
loading="lazy"
class="media-image"
/>
{:else}
<VideoPlayer
src={media[0].url}
poster={media[0].thumbnailUrl}
muted={true}
autoplay={false}
controls={true}
class="media-video"
/>
{/if}
</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" onclick={toggleComments}>
<MessageCircle size={16} />
<span>{commentCount || 'comment'}</span>
</Button>
<Button variant="ghost" size="sm" class="action-button" onclick={handleShare}>
<Share size={16} />
<span>share</span>
</Button>
</div>
<Button variant="outline" size="sm" onclick={handleExplore} class="explore-button">
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 {
backdrop-filter: blur(10px);
margin-top: 1rem;
margin-bottom: 1rem;
}
/* Post Header */
.post-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1rem 0.75rem 1rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
:global(.avatar) {
width: 40px;
height: 40px;
}
:global(.avatar-fallback) {
background: hsl(var(--primary));
color: white;
font-size: 0.875rem;
font-weight: 600;
}
.user-details {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.username {
font-weight: 600;
font-size: 0.875rem;
color: hsl(var(--foreground));
}
.location {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
:global(.more-button) {
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;
}
.content-header {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.post-title {
font-family: 'Washington', serif;
font-size: 1.125rem;
font-weight: 600;
line-height: 1.4;
color: hsl(var(--foreground));
margin: 0;
flex: 1;
}
:global(.category-badge) {
font-size: 0.75rem;
height: 1.5rem;
flex-shrink: 0;
}
.post-description {
font-size: 0.875rem;
line-height: 1.5;
color: hsl(var(--muted-foreground));
margin: 0;
}
/* Media */
.post-media {
width: 100%;
max-height: 600px;
overflow: hidden;
background: hsl(var(--muted));
}
.media-image {
width: 100%;
height: auto;
max-height: 600px;
object-fit: cover;
display: block;
}
:global(.media-video) {
width: 100%;
height: auto;
max-height: 600px;
}
/* Post Actions */
.post-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem 1rem 1rem;
border-top: 1px solid hsl(var(--border));
}
.action-buttons {
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) {
color: hsl(var(--foreground));
}
:global(.explore-button) {
font-weight: 500;
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 {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.action-buttons {
justify-content: space-around;
}
:global(.explore-button) {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,632 @@
<script lang="ts">
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;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
createdAt: string;
user: {
id: string;
username: string;
profilePictureUrl?: string | null;
};
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
likeCount?: number;
isLiked?: boolean;
}
interface Props {
find: Find | null;
onClose: () => void;
currentUserId?: string;
}
let { find, onClose, currentUserId }: Props = $props();
let currentMediaIndex = $state(0);
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);
});
function nextMedia() {
if (!find?.media) return;
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
}
function prevMedia() {
if (!find?.media) return;
currentMediaIndex = currentMediaIndex === 0 ? find.media.length - 1 : currentMediaIndex - 1;
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function getDirections() {
if (!find) return;
const lat = parseFloat(find.latitude);
const lng = parseFloat(find.longitude);
const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
window.open(url, '_blank');
}
function shareFindUrl() {
if (!find) return;
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
})
.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);
});
}
}
</script>
{#if find}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<div class="user-section">
<ProfilePicture
username={find.user.username}
profilePictureUrl={find.user.profilePictureUrl}
class="user-avatar"
/>
<div class="user-info">
<h2 class="find-title">{find.title}</h2>
<div class="find-meta">
<span class="username">@{find.user.username}</span>
<span class="separator"></span>
<span class="date">{formatDate(find.createdAt)}</span>
{#if find.category}
<span class="separator"></span>
<span class="category">{find.category}</span>
{/if}
</div>
</div>
</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 find.media && find.media.length > 0}
<div class="media-container">
<div class="media-viewer">
{#if find.media[currentMediaIndex].type === 'photo'}
<img src={find.media[currentMediaIndex].url} alt={find.title} class="media-image" />
{:else}
<VideoPlayer
src={find.media[currentMediaIndex].url}
poster={find.media[currentMediaIndex].thumbnailUrl}
class="media-video"
/>
{/if}
{#if find.media.length > 1}
<button class="media-nav prev" onclick={prevMedia} aria-label="Previous media">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M15 18L9 12L15 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button class="media-nav next" onclick={nextMedia} aria-label="Next media">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M9 18L15 12L9 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/if}
</div>
{#if find.media.length > 1}
<div class="media-indicators">
{#each find.media, index (index)}
<button
class="indicator"
class:active={index === currentMediaIndex}
onclick={() => (currentMediaIndex = index)}
aria-label={`View media ${index + 1}`}
></button>
{/each}
</div>
{/if}
</div>
{/if}
<div class="content-section">
{#if find.description}
<p class="description">{find.description}</p>
{/if}
{#if find.locationName}
<div class="location-info">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
<span>{find.locationName}</span>
</div>
{/if}
<div class="actions">
<LikeButton
findId={find.id}
isLiked={find.isLiked || false}
likeCount={find.likeCount || 0}
size="default"
class="like-action"
/>
<button class="action-button primary" onclick={getDirections}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M3 11L22 2L13 21L11 13L3 11Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Directions
</button>
<button class="action-button secondary" onclick={shareFindUrl}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M4 12V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="16,6 12,2 8,6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Share
</button>
</div>
</div>
<div class="comments-section">
<CommentsList
findId={find.id}
{currentUserId}
collapsed={false}
isScrollable={true}
showCommentForm={true}
/>
</div>
</div>
</div>
</div>
{/if}
<style>
.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;
}
@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: 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);
}
}
.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;
}
.user-section {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
:global(.user-avatar) {
width: 48px;
height: 48px;
flex-shrink: 0;
}
:global(.avatar-fallback) {
background: hsl(var(--primary));
color: white;
font-size: 1rem;
font-weight: 600;
}
.user-info {
flex: 1;
min-width: 0;
}
.find-title {
font-family: 'Washington', serif;
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
line-height: 1.3;
}
.find-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
flex-wrap: wrap;
}
.username {
font-weight: 500;
color: hsl(var(--primary));
}
.separator {
color: hsl(var(--muted-foreground));
opacity: 0.5;
}
.category {
background: hsl(var(--muted));
padding: 0.125rem 0.5rem;
border-radius: 8px;
font-size: 0.75rem;
text-transform: capitalize;
}
.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;
overflow: auto;
padding: 0;
}
.media-container {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
}
.media-viewer {
position: relative;
width: 100%;
max-height: 400px;
background: hsl(var(--muted));
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.media-image {
width: 100%;
height: 100%;
object-fit: contain;
}
:global(.media-video) {
width: 100%;
height: 100%;
}
.media-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease;
z-index: 10;
}
.media-nav:hover {
background: rgba(0, 0, 0, 0.8);
}
.media-nav.prev {
left: 1rem;
}
.media-nav.next {
right: 1rem;
}
.media-indicators {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem 0;
flex-shrink: 0;
}
.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: hsl(var(--muted-foreground));
opacity: 0.3;
cursor: pointer;
transition: all 0.2s ease;
}
.indicator.active {
background: hsl(var(--primary));
opacity: 1;
transform: scale(1.2);
}
.content-section {
padding: 1rem 1.5rem 1.5rem;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.5);
}
.description {
font-size: 1rem;
line-height: 1.6;
color: hsl(var(--foreground));
margin: 0 0 1.25rem 0;
}
.location-info {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
.actions {
display: flex;
gap: 0.75rem;
align-items: center;
margin-top: auto;
padding-top: 1rem;
}
:global(.like-action) {
flex-shrink: 0;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
justify-content: center;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.action-button.primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.action-button.primary:hover {
background: hsl(var(--primary) / 0.9);
}
.action-button.secondary {
background: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
}
.action-button.secondary:hover {
background: hsl(var(--secondary) / 0.8);
}
.comments-section {
border-top: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.5);
max-height: 400px;
overflow: hidden;
}
/* Mobile specific adjustments */
@media (max-width: 640px) {
.modal-header {
padding: 1rem;
}
.user-section {
gap: 0.5rem;
}
:global(.user-avatar) {
width: 40px;
height: 40px;
}
.find-title {
font-size: 1.125rem;
}
.content-section {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '$lib/components/dropdown-menu';
import { Badge } from '$lib/components/badge';
interface Props {
currentFilter: string;
onFilterChange: (filter: string) => void;
}
let { currentFilter, onFilterChange }: Props = $props();
const filterOptions = [
{ value: 'all', label: 'All Finds', description: 'Public, friends, and your finds' },
{ value: 'public', label: 'Public Only', description: 'Publicly visible finds' },
{ value: 'friends', label: 'Friends Only', description: 'Finds from your friends' },
{ value: 'mine', label: 'My Finds', description: 'Only your finds' }
];
const currentOption = $derived(
filterOptions.find((option) => option.value === currentFilter) || filterOptions[0]
);
</script>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline" class="filter-trigger">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<path
d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v2.586a1 1 0 0 1-.293.707l-6.414 6.414a1 1 0 0 0-.293.707V17l-4 4v-6.586a1 1 0 0 0-.293-.707L3.293 7.293A1 1 0 0 1 3 6.586V4z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{currentOption.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="filter-dropdown">
{#each filterOptions as option (option.value)}
<DropdownMenuItem
class="filter-option"
onclick={() => onFilterChange(option.value)}
data-selected={currentFilter === option.value}
>
<div class="option-content">
<div class="option-header">
<span class="option-label">{option.label}</span>
{#if currentFilter === option.value}
<Badge variant="default" class="selected-badge">Selected</Badge>
{/if}
</div>
<span class="option-description">{option.description}</span>
</div>
</DropdownMenuItem>
{/each}
</DropdownMenuContent>
</DropdownMenu>
<style>
:global(.filter-trigger) {
gap: 8px;
min-width: 120px;
justify-content: flex-start;
}
:global(.filter-dropdown) {
min-width: 250px;
padding: 4px;
}
:global(.filter-option) {
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
margin-bottom: 2px;
}
:global(.filter-option:hover) {
background: #f5f5f5;
}
:global(.filter-option[data-selected='true']) {
background: #f0f9ff;
border: 1px solid #e0f2fe;
}
.option-content {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.option-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.option-label {
font-weight: 500;
color: #1a1a1a;
font-size: 14px;
}
.option-description {
font-size: 12px;
color: #6b7280;
line-height: 1.4;
}
:global(.selected-badge) {
font-size: 10px;
padding: 2px 6px;
height: auto;
}
</style>

View File

@@ -0,0 +1,223 @@
<script lang="ts">
import FindCard from './FindCard.svelte';
interface Find {
id: string;
title: string;
description?: string;
category?: string;
locationName?: string;
latitude?: string;
longitude?: string;
isPublic?: number;
userId?: string;
user: {
username: string;
profilePictureUrl?: string | null;
};
likeCount?: number;
isLiked?: boolean;
media?: Array<{
id: string;
type: string;
url: string;
thumbnailUrl: string;
orderIndex?: number | null;
}>;
}
interface FindsListProps {
finds: Find[];
onFindExplore?: (id: string) => void;
currentUserId?: string;
onEdit?: (find: Find) => void;
title?: string;
showEmpty?: boolean;
emptyMessage?: string;
hideTitle?: boolean;
}
let {
finds,
onFindExplore,
currentUserId,
onEdit,
title = 'Finds',
showEmpty = true,
emptyMessage = 'No finds to display',
hideTitle = false
}: FindsListProps = $props();
function handleFindExplore(id: string) {
onFindExplore?.(id);
}
</script>
<section class="finds-feed">
{#if !hideTitle}
<div class="feed-header">
<h2 class="feed-title">{title}</h2>
</div>
{/if}
{#if finds.length > 0}
<div class="feed-container">
{#each finds as find (find.id)}
<FindCard
id={find.id}
title={find.title}
description={find.description}
category={find.category}
locationName={find.locationName}
latitude={find.latitude}
longitude={find.longitude}
isPublic={find.isPublic}
userId={find.userId}
user={find.user}
media={find.media}
likeCount={find.likeCount}
isLiked={find.isLiked}
{currentUserId}
onExplore={handleFindExplore}
onEdit={() => onEdit?.(find)}
/>
{/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 finds 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>Start exploring to discover finds</span>
</div>
</div>
{/if}
</section>
<style>
.finds-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;
}
.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;
}
/* Mobile responsive */
@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;
}
}
/* Smooth scrolling for feed */
.feed-container {
scroll-behavior: smooth;
}
/* Add subtle animation for new posts */
:global(.find-card) {
animation: fadeInUp 0.4s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

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

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

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

View File

@@ -0,0 +1,457 @@
<script lang="ts">
import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location';
import POISearch from '../map/POISearch.svelte';
import type { PlaceResult } from '$lib/utils/places';
interface Props {
isOpen: boolean;
onClose: () => void;
onLocationCreated: (event: CustomEvent<{ locationId: string; reload?: boolean }>) => void;
}
let { isOpen, onClose, onLocationCreated }: Props = $props();
let latitude = $state('');
let longitude = $state('');
let locationName = $state('');
let isSubmitting = $state(false);
let useManualLocation = $state(false);
let isMobile = $state(false);
$effect(() => {
if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
$effect(() => {
if (isOpen && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
});
async function handleSubmit() {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (isNaN(lat) || isNaN(lng)) {
return;
}
isSubmitting = true;
try {
const response = await fetch('/api/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
latitude: lat,
longitude: lng,
locationName: locationName.trim() || null
})
});
if (!response.ok) {
throw new Error('Failed to create location');
}
const result = await response.json();
resetForm();
onLocationCreated(
new CustomEvent('locationCreated', {
detail: { locationId: result.location.id, reload: true }
})
);
onClose();
} catch (error) {
console.error('Error creating location:', error);
alert('Failed to create location. Please try again.');
} finally {
isSubmitting = false;
}
}
function handlePlaceSelected(place: PlaceResult) {
locationName = place.name;
latitude = place.latitude.toString();
longitude = place.longitude.toString();
}
function toggleLocationMode() {
useManualLocation = !useManualLocation;
if (!useManualLocation && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
}
function resetForm() {
latitude = '';
longitude = '';
locationName = '';
useManualLocation = false;
}
function closeModal() {
resetForm();
onClose();
}
</script>
{#if isOpen}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Create Location</h2>
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="form"
>
<div class="modal-body">
<p class="description">
Choose a location where you and others can create finds (posts). This will be a point on
the map where discoveries can be shared.
</p>
<div class="location-section">
<div class="location-header">
<Label>Location</Label>
<button type="button" onclick={toggleLocationMode} class="toggle-button">
{useManualLocation ? 'Use Search' : 'Manual Entry'}
</button>
</div>
{#if !useManualLocation}
<POISearch
onPlaceSelected={handlePlaceSelected}
placeholder="Search for a place..."
label=""
showNearbyButton={true}
/>
{/if}
</div>
<div class="field">
<Label for="location-name">Location Name (Optional)</Label>
<Input
name="location-name"
type="text"
placeholder="Café Central, Brussels"
bind:value={locationName}
/>
</div>
{#if useManualLocation || (!latitude && !longitude)}
<div class="field-group">
<div class="field">
<Label for="latitude">Latitude</Label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div>
<div class="field">
<Label for="longitude">Longitude</Label>
<Input name="longitude" type="text" required bind:value={longitude} />
</div>
</div>
{:else if latitude && longitude}
<div class="coordinates-display">
<Label>Selected coordinates</Label>
<div class="coordinates-info">
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
<button
type="button"
onclick={() => (useManualLocation = true)}
class="edit-coords-button"
>
Edit
</button>
</div>
</div>
{/if}
</div>
<div class="modal-footer">
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !latitude || !longitude}>
{isSubmitting ? 'Creating...' : 'Create Location'}
</Button>
</div>
</form>
</div>
</div>
{/if}
<style>
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
min-width: 500px;
max-height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
max-height: 90vh;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-content {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(255, 255, 255, 0.6);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.form {
height: 100%;
display: flex;
flex-direction: column;
}
.modal-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
}
.description {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
line-height: 1.5;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal-footer {
display: flex;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-footer :global(button) {
flex: 1;
}
.location-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.location-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-button {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.toggle-button:hover {
background: hsl(var(--secondary) / 0.8);
}
.coordinates-display {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.coordinates-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
}
.coordinate {
color: hsl(var(--muted-foreground));
font-family: monospace;
font-size: 0.8125rem;
}
.edit-coords-button {
margin-left: auto;
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.edit-coords-button:hover {
background: hsl(var(--secondary) / 0.8);
}
/* Mobile specific adjustments */
@media (max-width: 767px) {
.modal-header {
padding: 1rem;
}
.modal-title {
font-size: 1.25rem;
}
.modal-body {
padding: 1rem;
gap: 1.25rem;
}
.modal-footer {
padding: 1rem;
gap: 0.5rem;
}
.field-group {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,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>

View File

@@ -0,0 +1,391 @@
<script lang="ts">
import FindsList from '../finds/FindsList.svelte';
import { Button } from '$lib/components/button';
import { goto } from '$app/navigation';
import EditFindModal from '../finds/EditFindModal.svelte';
interface Find {
id: string;
locationId: string;
title: string;
description?: string;
category?: string;
locationName?: string;
isPublic: number;
userId: string;
username: string;
profilePictureUrl?: string | null;
likeCount?: number;
isLikedByUser?: boolean;
isFromFriend?: boolean;
latitude?: string;
longitude?: string;
media?: Array<{
id: string;
type: string;
url: string;
thumbnailUrl: string;
orderIndex?: number | null;
}>;
}
interface Location {
id: string;
latitude: string;
longitude: string;
createdAt: string;
userId: string;
username: string;
findCount: number;
finds?: Find[];
}
interface Props {
isOpen: boolean;
location: Location | null;
currentUserId?: string;
onClose: () => void;
onCreateFind?: () => void;
}
let { isOpen, location, currentUserId, onClose, onCreateFind }: Props = $props();
let isMobile = $state(false);
let showEditModal = $state(false);
let findToEdit = $state<Find | null>(null);
$effect(() => {
if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
function handleCreateFind() {
onCreateFind?.();
}
function handleFindExplore(findId: string) {
goto(`/finds/${findId}`);
}
function handleFindEdit(findData: any) {
const find = location?.finds?.find((f) => f.id === findData.id);
if (find) {
findToEdit = find;
showEditModal = true;
}
}
function handleEditModalClose() {
showEditModal = false;
findToEdit = null;
}
function handleFindUpdated() {
showEditModal = false;
findToEdit = null;
window.location.reload();
}
function handleFindDeleted() {
showEditModal = false;
findToEdit = null;
window.location.reload();
}
</script>
{#if isOpen && location}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<div class="header-info">
<h2 class="modal-title">Location Finds</h2>
<p class="location-coords">
{parseFloat(location.latitude).toFixed(4)}, {parseFloat(location.longitude).toFixed(4)}
</p>
</div>
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="modal-body">
{#if location.finds && location.finds.length > 0}
<FindsList
finds={location.finds.map((find) => ({
id: find.id,
locationId: find.locationId,
title: find.title,
description: find.description,
category: find.category,
locationName: find.locationName,
latitude: find.latitude,
longitude: find.longitude,
isPublic: find.isPublic,
userId: find.userId,
username: find.username,
profilePictureUrl: find.profilePictureUrl,
user: {
username: find.username,
profilePictureUrl: find.profilePictureUrl
},
likeCount: find.likeCount,
isLiked: find.isLikedByUser,
media: find.media
}))}
hideTitle={true}
{currentUserId}
onFindExplore={handleFindExplore}
onEdit={handleFindEdit}
/>
{:else}
<div class="empty-state">
<div class="empty-icon">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h3 class="empty-title">No finds yet</h3>
<p class="empty-message">Be the first to share a discovery at this location!</p>
</div>
{/if}
</div>
{#if currentUserId}
<div class="modal-footer">
<Button onclick={handleCreateFind} class="w-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find Here
</Button>
</div>
{/if}
</div>
</div>
{/if}
{#if showEditModal && findToEdit}
<EditFindModal
isOpen={showEditModal}
find={{
id: findToEdit.id,
title: findToEdit.title,
description: findToEdit.description || null,
latitude: findToEdit.latitude || location?.latitude || '0',
longitude: findToEdit.longitude || location?.longitude || '0',
locationName: findToEdit.locationName || null,
category: findToEdit.category || null,
isPublic: findToEdit.isPublic ?? 1,
media: (findToEdit.media || []).map((m) => ({
id: m.id,
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || null,
orderIndex: m.orderIndex ?? null
}))
}}
onClose={handleEditModalClose}
onFindUpdated={handleFindUpdated}
onFindDeleted={handleFindDeleted}
/>
{/if}
<style>
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
min-width: 500px;
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
height: 90vh;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-content {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(255, 255, 255, 0.6);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.header-info {
flex: 1;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
color: hsl(var(--foreground));
}
.location-coords {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin: 0;
font-family: monospace;
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.modal-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
height: 100%;
}
.empty-icon {
margin-bottom: 1.5rem;
color: hsl(var(--muted-foreground));
opacity: 0.4;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.75rem 0;
color: hsl(var(--foreground));
}
.empty-message {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
line-height: 1.5;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
:global(.w-full) {
width: 100%;
}
:global(.mr-2) {
margin-right: 0.5rem;
}
@media (max-width: 767px) {
.modal-header {
padding: 1rem;
}
.modal-title {
font-size: 1.25rem;
}
.modal-footer {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
export { default as LocationCard } from './LocationCard.svelte';
export { default as LocationsList } from './LocationsList.svelte';
export { default as CreateLocationModal } from './CreateLocationModal.svelte';
export { default as SelectLocationModal } from './SelectLocationModal.svelte';
export { default as LocationFindsModal } from './LocationFindsModal.svelte';

View File

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

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

View File

@@ -0,0 +1,402 @@
<script lang="ts">
import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location';
import type { PlaceResult } from '$lib/utils/places';
interface Props {
onPlaceSelected: (place: PlaceResult) => void;
placeholder?: string;
label?: string;
showNearbyButton?: boolean;
}
let {
onPlaceSelected,
placeholder = 'Search for a place or address...',
label = 'Search location',
showNearbyButton = true
}: Props = $props();
let searchQuery = $state('');
let suggestions = $state<Array<{ placeId: string; description: string; types: string[] }>>([]);
let nearbyPlaces = $state<PlaceResult[]>([]);
let isLoading = $state(false);
let showSuggestions = $state(false);
let showNearby = $state(false);
let debounceTimeout: ReturnType<typeof setTimeout>;
async function searchPlaces(query: string) {
if (!query.trim()) {
suggestions = [];
showSuggestions = false;
return;
}
isLoading = true;
try {
const searchParams = new URL('/api/places', window.location.origin).searchParams;
searchParams.set('action', 'autocomplete');
searchParams.set('query', query.trim());
if ($coordinates) {
searchParams.set('lat', $coordinates.latitude.toString());
searchParams.set('lng', $coordinates.longitude.toString());
}
const response = await fetch(`/api/places?${searchParams}`);
if (response.ok) {
suggestions = await response.json();
showSuggestions = true;
}
} catch (error) {
console.error('Error searching places:', error);
} finally {
isLoading = false;
}
}
async function selectPlace(placeId: string, description: string) {
isLoading = true;
try {
const params = new URLSearchParams({
action: 'details',
placeId
});
const response = await fetch(`/api/places?${params}`);
if (response.ok) {
const place: PlaceResult = await response.json();
onPlaceSelected(place);
searchQuery = description;
showSuggestions = false;
}
} catch (error) {
console.error('Error getting place details:', error);
} finally {
isLoading = false;
}
}
async function findNearbyPlaces() {
if (!$coordinates) {
alert('Please enable location access first');
return;
}
isLoading = true;
showNearby = true;
try {
const params = new URLSearchParams({
action: 'nearby',
lat: $coordinates.latitude.toString(),
lng: $coordinates.longitude.toString(),
radius: '2000' // 2km radius
});
const response = await fetch(`/api/places?${params}`);
if (response.ok) {
nearbyPlaces = await response.json();
}
} catch (error) {
console.error('Error finding nearby places:', error);
} finally {
isLoading = false;
}
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
searchQuery = target.value;
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
searchPlaces(searchQuery);
}, 300);
}
function selectNearbyPlace(place: PlaceResult) {
onPlaceSelected(place);
searchQuery = place.name;
showNearby = false;
showSuggestions = false;
}
function handleClickOutside(event: Event) {
const target = event.target as HTMLElement;
if (!target.closest('.poi-search-container')) {
showSuggestions = false;
showNearby = false;
}
}
// Close dropdowns when clicking outside
if (typeof window !== 'undefined') {
document.addEventListener('click', handleClickOutside);
}
</script>
<div class="poi-search-container">
<div class="field">
<Label for="poi-search">{label}</Label>
<div class="search-input-container">
<Input id="poi-search" type="text" {placeholder} value={searchQuery} oninput={handleInput} />
{#if showNearbyButton && $coordinates}
<Button
type="button"
variant="ghost"
size="sm"
onclick={findNearbyPlaces}
disabled={isLoading}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
Nearby
</Button>
{/if}
</div>
</div>
{#if showSuggestions && suggestions.length > 0}
<div class="suggestions-dropdown">
<div class="suggestions-header">Search Results</div>
{#each suggestions as suggestion (suggestion.placeId)}
<button
class="suggestion-item"
onclick={() => selectPlace(suggestion.placeId, suggestion.description)}
disabled={isLoading}
>
<div class="suggestion-content">
<span class="suggestion-name">{suggestion.description}</span>
<div class="suggestion-types">
{#each suggestion.types.slice(0, 2) as type, index (index)}
<span class="suggestion-type">{type.replace(/_/g, ' ')}</span>
{/each}
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
<path
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
</button>
{/each}
</div>
{/if}
{#if showNearby && nearbyPlaces.length > 0}
<div class="suggestions-dropdown">
<div class="suggestions-header">Nearby Places</div>
{#each nearbyPlaces as place (place.placeId)}
<button
class="suggestion-item"
onclick={() => selectNearbyPlace(place)}
disabled={isLoading}
>
<div class="suggestion-content">
<span class="suggestion-name">{place.name}</span>
<span class="suggestion-address">{place.vicinity || place.formattedAddress}</span>
{#if place.rating}
<div class="suggestion-rating">
<span class="rating-stars"></span>
<span>{place.rating.toFixed(1)}</span>
</div>
{/if}
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
<path
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
</button>
{/each}
</div>
{/if}
{#if isLoading}
<div class="loading-indicator">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="loading-spinner">
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-dasharray="32"
stroke-dashoffset="32"
/>
</svg>
<span>Searching...</span>
</div>
{/if}
</div>
<style>
.poi-search-container {
position: relative;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.search-input-container {
position: relative;
display: flex;
gap: 0.5rem;
}
.suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: hsl(var(--background) / 0.95);
border: 1px solid hsl(var(--border));
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
z-index: 1000;
margin-top: 0.25rem;
backdrop-filter: blur(12px);
}
.suggestions-header {
padding: 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.95);
backdrop-filter: blur(12px);
}
.suggestion-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border: none;
background: hsl(var(--background) / 0.95);
text-align: left;
cursor: pointer;
transition: background-color 0.2s ease;
border-bottom: 1px solid hsl(var(--border));
backdrop-filter: blur(12px);
}
.suggestion-item:last-child {
border-bottom: none;
}
.suggestion-item:hover:not(:disabled) {
background: hsl(var(--muted) / 0.8);
}
.suggestion-item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.suggestion-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.suggestion-name {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.suggestion-address {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.suggestion-types {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.suggestion-type {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
border-radius: 4px;
text-transform: capitalize;
}
.suggestion-rating {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.rating-stars {
color: #fbbf24;
}
.suggestion-icon {
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
.loading-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 640px) {
.search-input-container {
flex-direction: column;
}
}
</style>

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

View File

@@ -0,0 +1,227 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Play, Pause, Volume2, VolumeX, Maximize } from '@lucide/svelte';
interface Props {
src: string;
poster?: string;
class?: string;
autoplay?: boolean;
muted?: boolean;
controls?: boolean;
}
let {
src,
poster,
class: className = '',
autoplay = false,
muted = false,
controls = true
}: Props = $props();
let videoElement: HTMLVideoElement;
let isPlaying = $state(false);
let isMuted = $state(muted);
let currentTime = $state(0);
let duration = $state(0);
let isLoading = $state(true);
let showControls = $state(true);
let controlsTimeout: ReturnType<typeof setTimeout>;
function togglePlayPause() {
if (isPlaying) {
videoElement.pause();
} else {
videoElement.play();
}
}
function toggleMute() {
videoElement.muted = !videoElement.muted;
isMuted = videoElement.muted;
}
function handleTimeUpdate() {
currentTime = videoElement.currentTime;
}
function handleLoadedMetadata() {
duration = videoElement.duration;
isLoading = false;
}
function handlePlay() {
isPlaying = true;
}
function handlePause() {
isPlaying = false;
}
function handleSeek(event: Event) {
const target = event.target as HTMLInputElement;
const time = (parseFloat(target.value) / 100) * duration;
videoElement.currentTime = time;
}
function handleMouseMove() {
if (!controls) return;
showControls = true;
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(() => {
if (isPlaying) {
showControls = false;
}
}, 3000);
}
function toggleFullscreen() {
if (videoElement.requestFullscreen) {
videoElement.requestFullscreen();
}
}
function formatTime(time: number): string {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
</script>
<div
class="group relative overflow-hidden rounded-lg bg-black {className}"
role="application"
onmousemove={handleMouseMove}
onmouseleave={() => {
if (isPlaying && controls) showControls = false;
}}
>
<video
bind:this={videoElement}
class="h-full w-full object-cover"
{src}
{poster}
{autoplay}
muted={isMuted}
preload="metadata"
onplay={handlePlay}
onpause={handlePause}
ontimeupdate={handleTimeUpdate}
onloadedmetadata={handleLoadedMetadata}
onclick={togglePlayPause}
>
<track kind="captions" />
</video>
{#if isLoading}
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
</div>
{/if}
{#if controls && showControls}
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100"
>
<!-- Center play/pause button -->
<div class="absolute inset-0 flex items-center justify-center">
<Button
variant="ghost"
size="lg"
class="h-16 w-16 rounded-full bg-black/30 text-white hover:bg-black/50"
onclick={togglePlayPause}
>
{#if isPlaying}
<Pause class="h-8 w-8" />
{:else}
<Play class="ml-1 h-8 w-8" />
{/if}
</Button>
</div>
<!-- Bottom controls -->
<div class="absolute right-0 bottom-0 left-0 p-4">
<!-- Progress bar -->
<div class="mb-2">
<input
type="range"
min="0"
max="100"
value={duration > 0 ? (currentTime / duration) * 100 : 0}
oninput={handleSeek}
class="slider h-1 w-full cursor-pointer appearance-none rounded-lg bg-white/30"
/>
</div>
<!-- Control buttons -->
<div class="flex items-center justify-between text-white">
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
class="text-white hover:bg-white/20"
onclick={togglePlayPause}
>
{#if isPlaying}
<Pause class="h-4 w-4" />
{:else}
<Play class="h-4 w-4" />
{/if}
</Button>
<Button
variant="ghost"
size="sm"
class="text-white hover:bg-white/20"
onclick={toggleMute}
>
{#if isMuted}
<VolumeX class="h-4 w-4" />
{:else}
<Volume2 class="h-4 w-4" />
{/if}
</Button>
<span class="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<Button
variant="ghost"
size="sm"
class="text-white hover:bg-white/20"
onclick={toggleFullscreen}
>
<Maximize class="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/if}
</div>
<style>
.slider::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
}
.slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
border: none;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
}
</style>

View File

@@ -0,0 +1 @@
export { default as VideoPlayer } from './VideoPlayer.svelte';

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

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

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

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

View File

@@ -1,38 +1,50 @@
<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 } 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;
id: string;
profilePictureUrl?: string | null;
loading?: boolean;
}
let { username, id, loading = false }: Props = $props();
let { username, id, profilePictureUrl, loading = false }: Props = $props();
// Get the first letter of username for avatar
const initial = username.charAt(0).toUpperCase();
let showProfilePictureSheet = $state(false);
let showNotificationSettings = $state(false);
function openProfilePictureSheet() {
showProfilePictureSheet = true;
}
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">
<AvatarFallback class="profile-avatar-fallback">
{initial}
</AvatarFallback>
</Avatar>
{/if}
<ProfilePicture {username} {profilePictureUrl} {loading} class="profile-avatar" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="profile-dropdown-content">
@@ -65,6 +77,20 @@
<DropdownMenuSeparator />
<DropdownMenuItem class="profile-picture-item" onclick={openProfilePictureSheet}>
Profile Picture
</DropdownMenuItem>
<DropdownMenuItem class="friends-item">
<a href={resolveRoute('/friends')} class="friends-link">Friends</a>
</DropdownMenuItem>
<DropdownMenuItem class="notification-settings-item" onclick={openNotificationSettings}>
Notifications
</DropdownMenuItem>
<DropdownMenuSeparator />
<div class="user-info-item">
<span class="info-label">Username</span>
<span class="info-value">{username}</span>
@@ -86,6 +112,19 @@
</DropdownMenuContent>
</DropdownMenu>
{#if showProfilePictureSheet}
<ProfilePictureSheet
userId={id}
{username}
{profilePictureUrl}
onClose={closeProfilePictureSheet}
/>
{/if}
{#if showNotificationSettings}
<NotificationSettings onClose={closeNotificationSettings} />
{/if}
<style>
:global(.profile-trigger) {
background: none;
@@ -110,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;
@@ -156,6 +186,45 @@
word-break: break-all;
}
:global(.profile-picture-item) {
cursor: pointer;
font-weight: 500;
color: #333;
}
:global(.profile-picture-item:hover) {
background: #f5f5f5;
}
:global(.friends-item) {
cursor: pointer;
font-weight: 500;
color: #333;
padding: 0;
}
:global(.friends-item:hover) {
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%;
padding: 6px 8px;
text-decoration: none;
color: inherit;
}
:global(.logout-item) {
padding: 0;
margin: 4px;

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

View File

@@ -0,0 +1,255 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../sheet';
import { Button } from '../button';
import ProfilePicture from './ProfilePicture.svelte';
interface Props {
userId: string;
username: string;
profilePictureUrl?: string | null;
onClose?: () => void;
}
let { userId, username, profilePictureUrl, onClose }: Props = $props();
let fileInput: HTMLInputElement;
let selectedFile = $state<File | null>(null);
let isUploading = $state(false);
let isDeleting = $state(false);
let showModal = $state(true);
// Close modal when showModal changes to false
$effect(() => {
if (!showModal && onClose) {
onClose();
}
});
function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement;
if (target.files && target.files[0]) {
const file = target.files[0];
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
alert('File size must be less than 5MB');
return;
}
selectedFile = file;
}
}
async function handleUpload() {
if (!selectedFile) return;
isUploading = true;
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('userId', userId);
const response = await fetch('/api/profile-picture/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
selectedFile = null;
if (fileInput) fileInput.value = '';
await invalidateAll();
showModal = false;
} catch (error) {
console.error('Upload error:', error);
alert('Failed to upload profile picture');
} finally {
isUploading = false;
}
}
async function handleDelete() {
if (!profilePictureUrl) return;
isDeleting = true;
try {
const response = await fetch('/api/profile-picture/delete', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ userId })
});
if (!response.ok) {
throw new Error('Delete failed');
}
await invalidateAll();
showModal = false;
} catch (error) {
console.error('Delete error:', error);
alert('Failed to delete profile picture');
} finally {
isDeleting = false;
}
}
</script>
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
<SheetContent side="bottom" class="profile-picture-sheet">
<SheetHeader>
<SheetTitle>Profile Picture</SheetTitle>
<SheetDescription>Upload, edit, or delete your profile picture</SheetDescription>
</SheetHeader>
<div class="profile-picture-content">
<div class="current-avatar">
<ProfilePicture {username} {profilePictureUrl} size="xl" class="large-avatar" />
</div>
<div class="upload-section">
<input
bind:this={fileInput}
type="file"
accept="image/*"
onchange={handleFileSelect}
class="file-input"
/>
{#if selectedFile}
<div class="selected-file">
<p class="file-name">{selectedFile.name}</p>
<p class="file-size">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
{/if}
<div class="action-buttons">
{#if selectedFile}
<Button onclick={handleUpload} disabled={isUploading} class="upload-button">
{isUploading ? 'Uploading...' : 'Upload Picture'}
</Button>
{/if}
{#if profilePictureUrl}
<Button
variant="destructive"
onclick={handleDelete}
disabled={isDeleting}
class="delete-button"
>
{isDeleting ? 'Deleting...' : 'Delete Picture'}
</Button>
{/if}
</div>
</div>
</div>
</SheetContent>
</Sheet>
<style>
:global(.profile-picture-sheet) {
max-height: 80vh;
}
.profile-picture-content {
display: flex;
flex-direction: column;
gap: 24px;
padding: 16px 0;
}
.current-avatar {
display: flex;
justify-content: center;
align-items: center;
}
:global(.large-avatar) {
width: 120px;
height: 120px;
}
.upload-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.file-input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
background: white;
}
.selected-file {
padding: 12px;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.file-name {
font-weight: 500;
margin: 0 0 4px 0;
word-break: break-word;
}
.file-size {
font-size: 12px;
color: #666;
margin: 0;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
:global(.upload-button) {
background: black;
color: white;
padding: 12px 24px;
font-weight: 500;
}
:global(.upload-button:hover) {
background: #333;
}
:global(.delete-button) {
padding: 12px 24px;
font-weight: 500;
}
@media (min-width: 640px) {
:global(.profile-picture-sheet) {
max-width: 500px;
margin: 0 auto;
}
.action-buttons {
flex-direction: row;
justify-content: center;
}
:global(.upload-button),
:global(.delete-button) {
min-width: 140px;
}
}
</style>

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

View File

@@ -0,0 +1,36 @@
import { Dialog as SheetPrimitive } from 'bits-ui';
import Trigger from './sheet-trigger.svelte';
import Close from './sheet-close.svelte';
import Overlay from './sheet-overlay.svelte';
import Content from './sheet-content.svelte';
import Header from './sheet-header.svelte';
import Footer from './sheet-footer.svelte';
import Title from './sheet-title.svelte';
import Description from './sheet-description.svelte';
const Root = SheetPrimitive.Root;
const Portal = SheetPrimitive.Portal;
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />

View File

@@ -0,0 +1,60 @@
<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-[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',
bottom:
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
right:
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm'
}
},
defaultVariants: {
side: 'right'
}
});
export type Side = VariantProps<typeof sheetVariants>['side'];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import SheetOverlay from './sheet-overlay.svelte';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
side = 'right',
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: SheetPrimitive.PortalProps;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPrimitive.Portal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-description"
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
/>

Some files were not shown because too many files have changed in this diff Show More