5 Commits

Author SHA1 Message Date
cb428bceb2 feat:user rating to api-sync 2026-01-18 20:12:20 +01:00
e9ef0fbf22 feat:add presentatie 2026-01-17 11:45:37 +01:00
6b0ff54802 feat:add reflectie 2026-01-03 13:24:48 +01:00
70503296b7 chore:format&lint 2026-01-01 14:57:02 +01:00
ff8fc1717a docs:replace placeholders with images 2026-01-01 14:56:20 +01:00
23 changed files with 24754 additions and 8175 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ pnpm-lock.yaml
# OS
.DS_Store
Thumbs.db
.duckversions
# Env
.env

1601
bun.lock Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -53,7 +53,7 @@
// Abstract
#align(center)[
#text(size: 12pt, weight: "bold")[Samenvatting]
#text(size: 12pt, weight: "bold")[Abstract]
]
#par(first-line-indent: 0pt)[

BIN
docs/presentatie/slides.af Normal file

Binary file not shown.

BIN
docs/presentatie/slides.pdf Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,60 @@
// Author: van Nes Zias
// Date: 26-dec-2025
// Persoonlijke reflectie Serengo
#set document(
title: "Persoonlijke reflectie",
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")[Persoonlijke reflectie]
#v(0.8em)
#text(size: 12pt)[Serengo]
#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()
= Persoonlijke reflectie
In dit project heb ik volledig zelfstandig gewerkt. Dat was soms uitdagend, maar tegelijk ook motiverend, omdat ik zelf alle beslissingen moest nemen en volledig verantwoordelijk was voor het eindresultaat. Vooral het ontwerpen en implementeren van de API-sync layer vond ik interessant. Daarbij moest ik nadenken over een correcte communicatie tussen frontend en backend, inclusief caching en foutafhandeling. Dit vergde veel tijd en denkwerk en was achteraf gezien misschien niet de meest efficiënte keuze binnen dit vak, maar het heeft mij wel veel bijgebracht.
Daarnaast heb ik met veel plezier met Svelte gewerkt. Het framework voelt eenvoudig en intuïtief aan, maar biedt tegelijk krachtige mogelijkheden. Tijdens de ontwikkeling van de PWA heb ik een beter inzicht gekregen in hoe service workers, webmanifesten en offline functionaliteit samenkomen in een realistische applicatie.
Ik ben ook trots op het feit dat ik het volledige project zelf heb gehost. Naast het programmeren heb ik de volledige deployment en serverconfiguratie uitgevoerd en problemen opgelost op een Linux VPS. Dit gaf mij een breder inzicht in het volledige traject van ontwikkeling tot productie. De belangrijkste les die ik dit semester heb geleerd, is hoe ik zelfstandig een volledige webapplicatie kan bouwen, hosten en onderhouden, en hoe ik bewuster kan afwegen tussen technische complexiteit en de beschikbare tijd.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

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

Before

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View File

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

Before

Width:  |  Height:  |  Size: 346 B

BIN
docs/screenshots/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

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

Before

Width:  |  Height:  |  Size: 343 B

BIN
docs/screenshots/mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

View File

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

Before

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 KiB

View File

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

Before

Width:  |  Height:  |  Size: 352 B

View File

@@ -1,31 +1,28 @@
#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.
In dit hoofdstuk worden enkele representatieve screenshots van de applicatie opgenomen.
#figure(
image("../screenshots/home.svg"),
image("../screenshots/home.png"),
caption: [Startscherm met fullscreen kaart en zijbalk met finds]
)
#figure(
image("../screenshots/find-detail.svg"),
image("../screenshots/find-detail.png"),
caption: [Detailpagina van een find met media, likes, comments en rating]
)
#figure(
image("../screenshots/friends.svg"),
image("../screenshots/friends.png"),
caption: [Vriendenoverzicht en privacy-bewuste filtering van finds]
)
#figure(
image("../screenshots/notifications.svg"),
image("../screenshots/notifications.png"),
caption: [In-app notificaties en Web Push-permissies]
)
#figure(
image("../screenshots/mobile.svg"),
image("../screenshots/mobile.png"),
caption: [Mobiele weergave met zijbalk en dynamische mapcentrering]
)

View File

@@ -28,7 +28,7 @@
**Commits:**
- 4af0e3d - fix:location names
- f48746c - fix:csp of osm styles
- f48746c - fix:csp of osm styles
- 200c761 - fix:OSM
- 20b5674 - fix:switch back to OSM
- 42670d1 - feat:db erd

View File

@@ -31,6 +31,7 @@
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.1.13",
"@types/node": "^22",
"@types/web-push": "^3.6.4",
"bits-ui": "^2.11.4",
"clsx": "^2.1.1",
"drizzle-erd": "0.0.1-alpha.11",

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { Star } from 'lucide-svelte';
import { onMount } from 'svelte';
import { apiSync } from '$lib/stores/api-sync';
interface Props {
findId: string;
@@ -25,34 +27,97 @@
let hoverRating = $state(0);
let isSubmitting = $state(false);
// Prefer the centralized apiSync state and subscribe to updates so the finds panel
// always shows up-to-date information and benefits from optimistic updates.
onMount(() => {
if (!findId) return;
// Subscribe to the global find entity state
const entitySub = apiSync.subscribe<import('$lib/stores/api-sync').FindState>('find', findId);
const unsubscribe = entitySub.subscribe((s) => {
if (s && s.data) {
const findData = s.data as import('$lib/stores/api-sync').FindState;
// Backend may store rating as integer * 100 or as a normalized float.
if (typeof findData.rating === 'number') {
rating = findData.rating > 10 ? findData.rating / 100 : findData.rating;
} else {
rating = initialRating;
}
ratingCount =
typeof findData.ratingCount === 'number' ? findData.ratingCount : initialCount;
currentUserRating = findData.userRating ?? userRating ?? null;
} else {
// fallback to provided initial props
rating = initialRating;
ratingCount = initialCount;
currentUserRating = userRating;
}
});
// Ensure apiSync has at least a minimal entity state so optimistic updates can apply
const existing = apiSync.getEntityState('find', findId);
if (!existing) {
// Minimal shape to avoid runtime crashes; other fields may be filled later.
apiSync.setEntityState(
'find',
findId,
{
id: findId,
title: '',
latitude: '',
longitude: '',
isPublic: true,
createdAt: new Date(),
userId: '',
username: '',
isLikedByUser: false,
likeCount: 0,
commentCount: 0,
isFromFriend: false,
rating: initialRating || 0,
ratingCount: initialCount,
userRating: userRating ?? null
} as import('$lib/stores/api-sync').FindState,
false
);
}
return () => unsubscribe();
});
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 })
});
// Use the centralized apiSync to perform optimistic update + queued server sync
await apiSync.rateFind(findId, stars);
if (!response.ok) {
throw new Error('Failed to rate find');
// apiSync will update global state; read it back to sync local component values
const s = apiSync.getEntityState('find', findId) as
| import('$lib/stores/api-sync').FindState
| null;
if (s) {
const serverRating = s.rating ?? 0;
rating =
typeof serverRating === 'number'
? serverRating > 10
? serverRating / 100
: serverRating
: rating;
ratingCount = s.ratingCount ?? ratingCount;
currentUserRating = s?.userRating ?? stars;
} else {
// Fallback: use the new user rating optimistically
currentUserRating = stars;
}
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);
console.error('Error rating find via apiSync:', error);
} finally {
isSubmitting = false;
}
@@ -68,7 +133,7 @@
<div class="rating-container">
<div class="stars">
{#each [1, 2, 3, 4, 5] as star}
{#each [1, 2, 3, 4, 5] as star (star)}
<button
class="star-button"
class:filled={star <= getDisplayRating()}

View File

@@ -59,6 +59,8 @@ export interface FindState {
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
// Per-user rating (optional) — stored on the find entity so UI components can highlight user's rating
userRating?: number | null;
isLikedByUser: boolean;
likeCount: number;
commentCount: number;
@@ -401,7 +403,16 @@ class APISync {
let response: Response;
if (entityType === 'find' && action === 'like') {
if (entityType === 'find' && action === 'rate') {
// Handle rating operations
response = await fetch(`/api/finds/${entityId}/rate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ rating: (data as { rating?: number })?.rating })
});
} else if (entityType === 'find' && action === 'like') {
// Handle like operations
const method = (data as { isLiked?: boolean })?.isLiked ? 'POST' : 'DELETE';
response = await fetch(`/api/finds/${entityId}/like`, {
@@ -456,7 +467,45 @@ class APISync {
const result = await response.json();
// Update entity state with successful result
if (entityType === 'find' && action === 'like') {
if (entityType === 'find' && action === 'rate') {
// Server should return authoritative rating and count (and optionally userRating)
const serverRating = result.rating;
const serverCount = result.ratingCount;
const serverUserRating = result.userRating ?? (data as { rating?: number })?.rating ?? null;
const store = this.getEntityStore('find');
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(entityId);
if (existing && existing.data) {
const findData = existing.data as FindState;
// Normalize rating if backend returns scaled value (e.g. stored as integer * 100)
let normalizedRating = findData.rating;
if (typeof serverRating === 'number') {
normalizedRating = serverRating > 10 ? serverRating / 100 : serverRating;
}
newEntities.set(entityId, {
...existing,
data: {
...findData,
rating: normalizedRating,
ratingCount: typeof serverCount === 'number' ? serverCount : findData.ratingCount,
// persist user's rating on the entity for UI consumers
userRating: serverUserRating
},
isLoading: false,
error: null,
lastUpdated: new Date()
});
}
return newEntities;
});
} else if (entityType === 'find' && action === 'like') {
this.updateFindLikeState(entityId, result.isLiked, result.likeCount);
} else if (entityType === 'find' && op === 'update') {
// Reload the find data to get the updated state
@@ -564,6 +613,60 @@ class APISync {
await this.queueOperation('find', findId, 'update', 'like', { isLiked: newIsLiked });
}
/**
* Rate a find with optimistic update and queued server sync
*/
async rateFind(findId: string, stars: number): Promise<void> {
const store = this.getEntityStore('find');
const currentState = this.getEntityState<FindState>('find', findId);
if (!currentState) {
console.warn(`Cannot rate find ${findId}: find state not found`);
return;
}
// Read previous values
const prevRating = currentState.rating ?? 0;
const prevCount = currentState.ratingCount ?? 0;
const prevUserRating = currentState.userRating ?? null;
let newCount = prevCount;
let newRating = prevRating;
if (prevUserRating == null) {
// New rater
newCount = prevCount + 1;
newRating = (prevRating * prevCount + stars) / newCount;
} else {
// Updating existing user's rating
newRating = (prevRating * prevCount - prevUserRating + stars) / Math.max(1, prevCount);
}
// Apply optimistic update (store userRating on find object so UI can read it)
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(findId);
if (existing && existing.data) {
const findData = existing.data as FindState;
newEntities.set(findId, {
...existing,
data: {
...findData,
rating: newRating,
ratingCount: newCount,
// attach user's rating for UI
userRating: stars
},
isLoading: true
});
}
return newEntities;
});
// Queue the operation using action 'rate' so executeOperation will POST to /rate
await this.queueOperation('find', findId, 'update', 'rate', { rating: stars });
}
/**
* Add comment to find comments state
*/
@@ -801,7 +904,7 @@ class APISync {
...(data.isPublic !== undefined && { isPublic: data.isPublic }),
...(data.media !== undefined && {
media: data.media.map((m, index) => ({
id: (m as any).id || `temp-${index}`,
id: (m as { id?: string }).id || `temp-${index}`,
findId: findId,
type: m.type,
url: m.url,