Merge branch 'feat/faves' into 'master'

merge: Merge feat/faves into master

See merge request gk1623/drp-48!19

Co-authored-by: Caspar Jojo Asaam <caspar@dyn3155-98.wlan.ic.ac.uk>
This commit is contained in:
Caspar Jojo Asaam
2025-06-13 02:40:19 +00:00
10 changed files with 249 additions and 38 deletions

View File

@@ -0,0 +1,40 @@
<script lang="ts">
interface Props {
isFavourite: boolean;
onToggleFavourite: () => void;
}
const { isFavourite, onToggleFavourite }: Props = $props();
function handleClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
onToggleFavourite();
}
</script>
<button
type="button"
class="fav-button"
onclick={handleClick}
aria-label="Toggle favourite"
style="color: {isFavourite ? 'red' : '#eaffeb'}"
>
{#if isFavourite}
❤️
{:else}
🤍
{/if}
</button>
<style>
.fav-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
display: block;
margin: 0.5rem auto 0;
line-height: 1;
}
</style>

View File

@@ -19,7 +19,7 @@
let openingDisplay = $state("");
if (todayHours) {
openingDisplay = todayHours.open_today_status
? "Open 24/7"
? "Open All Day"
: `${formatTime(todayHours.opens_at)} - ${formatTime(todayHours.closes_at)}`;
} else {
openingDisplay = "Closed";

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import CompulsoryTags from "./CompulsoryTags.svelte";
import OpeningTimes from "./OpeningTimes.svelte";
import Favourite from "./Favourite.svelte";
import type { Table } from "$lib";
interface Props {
@@ -9,13 +10,21 @@
alt: string;
imgSrc: string;
href?: string;
isFavourite: boolean;
onToggleFavourite: () => void;
}
const { space, hours, alt, imgSrc, href }: Props = $props();
const { space, hours, alt, imgSrc, href, isFavourite, onToggleFavourite }: Props = $props();
</script>
<a class="card" {href}>
<img src={imgSrc} {alt} />
<!-- <img src={imgSrc} {alt} /> -->
<div class="image-container">
<img src={imgSrc} {alt} />
<div class="fav-button">
<Favourite {isFavourite} {onToggleFavourite} />
</div>
</div>
<div class="description">
<h1>{space.location}</h1>
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
@@ -84,4 +93,17 @@
gap: 0.3rem;
font-size: 0.8rem;
}
.image-container {
position: relative;
}
.image-container .fav-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(11, 128, 34, 0.4);
border-radius: 50%;
padding: 0.25rem;
z-index: 1;
}
</style>

36
src/lib/database.d.ts vendored
View File

@@ -202,6 +202,42 @@ export type Database = {
}
Relationships: []
}
favourite_study_spaces: {
Row: {
user_id: string
study_space_id: string
created_at: string | null
updated_at: string | null
}
Insert: {
user_id: string
study_space_id: string
created_at?: string | null
updated_at?: string | null
}
Update: {
user_id?: string
study_space_id?: string
created_at?: string | null
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "favourite_study_spaces_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
{
foreignKeyName: "favourite_study_spaces_study_space_id_fkey"
columns: ["study_space_id"]
isOneToOne: false
referencedRelation: "study_spaces"
referencedColumns: ["id"]
}
]
}
}
Views: {
[_ in never]: never

View File

@@ -8,7 +8,25 @@ export const load: PageServerLoad = async ({ depends, locals: { supabase } }) =>
.select("*, study_space_images(*), study_space_hours(*)");
if (err) error(500, "Failed to load study spaces");
const {
data: { session }
} = await supabase.auth.getSession();
// Fetch this users favourites
let favouriteIds: string[] = [];
if (session?.user?.id) {
const { data: favs, error: favErr } = await supabase
.from("favourite_study_spaces")
.select("study_space_id")
.eq("user_id", session.user.id);
if (!favErr && favs) {
favouriteIds = favs.map((f) => f.study_space_id);
}
}
return {
studySpaces
studySpaces,
session,
favouriteIds
};
};

View File

@@ -7,18 +7,46 @@
import Button from "$lib/components/Button.svelte";
const { data } = $props();
const { studySpaces, supabase, session, adminMode } = $derived(data);
const {
studySpaces,
supabase,
session,
adminMode,
favouriteIds: initialFavourites = []
} = $derived(data);
let selectedTags = $state<string[]>([]);
let tagFilter = $state("");
let tagFilterElem = $state<HTMLInputElement>();
let openingFilter = $state("");
let closingFilter = $state("");
let tagFilterElem = $state<HTMLInputElement>();
let favouriteIds = $derived<string[]>(initialFavourites);
let showFavourites = $state(false);
function categorySelected(category: string[]) {
return category.some((tag) => selectedTags.includes(tag));
}
// Toggle a space in/out of favourites
async function handleToggleFavourite(id: string) {
if (!session?.user) return;
const already = favouriteIds.includes(id);
if (already) {
await supabase
.from("favourite_study_spaces")
.delete()
.match({ user_id: session.user.id, study_space_id: id });
favouriteIds = favouriteIds.filter((x) => x !== id);
} else {
await supabase
.from("favourite_study_spaces")
.insert([{ user_id: session.user.id, study_space_id: id }]);
favouriteIds = [...favouriteIds, id];
}
}
let filteredTags = $derived(
allTags
.filter((tag) => tag.toLowerCase().includes(tagFilter.toLowerCase()))
@@ -44,6 +72,8 @@
// Combine tag and time filtering
let filteredStudySpaces = $derived(
studySpaces
// only include favourites when showFavourites===true
.filter((space) => !showFavourites || favouriteIds?.includes(space.id))
// tag filtering
.filter((space) => {
if (selectedTags.length === 0) return true;
@@ -114,6 +144,9 @@
<a href="/space/new/edit">
<img src={crossUrl} alt="new" class="new-space" />
</a>
<button class="fav-button" onclick={() => (showFavourites = !showFavourites)} type="button">
{showFavourites ? "All spaces" : "My favourites"}
</button>
{/if}
</Navbar>
@@ -198,6 +231,8 @@
imgSrc={imgUrl}
space={studySpace}
hours={studySpace.study_space_hours}
isFavourite={favouriteIds.includes(studySpace.id)}
onToggleFavourite={() => handleToggleFavourite(studySpace.id)}
/>
{/each}
</main>
@@ -356,6 +391,18 @@
font-size: 1.2rem;
}
.fav-button {
background: none;
border: none;
color: #eaffeb;
font-size: 1rem;
margin-right: 1rem;
cursor: pointer;
}
.fav-button:hover {
text-decoration: underline;
}
@media (max-width: 20rem) {
main {
grid-template-columns: 1fr;

View File

@@ -9,6 +9,7 @@
import { onMount } from "svelte";
import { gmapsLoader, daysOfWeek, formatTime, type Table } from "$lib";
import Button from "$lib/components/Button.svelte";
import Favourite from "$lib/components/Favourite.svelte";
const { data } = $props();
const { space, supabase, adminMode } = $derived(data);
@@ -65,6 +66,40 @@
for (const entry of space.study_space_hours) {
timingsPerDay[entry.day_of_week].push(entry);
}
let isFavourite = $state(false);
onMount(async () => {
const {
data: { session }
} = await supabase.auth.getSession();
if (!session?.user) return;
const { data: fav } = await supabase
.from("favourite_study_spaces")
.select("study_space_id")
.match({ user_id: session.user.id, study_space_id: space.id })
.single();
isFavourite = !!fav;
});
// Toggle a space in/out of favourites
async function handleToggleFavourite() {
const {
data: { session }
} = await supabase.auth.getSession();
if (!session?.user) return;
if (isFavourite) {
await supabase
.from("favourite_study_spaces")
.delete()
.match({ user_id: session.user.id, study_space_id: space.id });
isFavourite = false;
} else {
await supabase
.from("favourite_study_spaces")
.insert([{ user_id: session.user.id, study_space_id: space.id }]);
isFavourite = true;
}
}
</script>
<Navbar>
@@ -87,8 +122,11 @@
{/if}
<main>
<Carousel urls={imgUrls} />
<div class="nameContainer">
{space.location}
<div class="titleContainer">
<div class="nameContainer">{space.location}</div>
<div class="title-fav">
<Favourite {isFavourite} onToggleFavourite={handleToggleFavourite} />
</div>
</div>
{#if space.description != null && space.description.length > 0}
<p class="descContainer">
@@ -300,4 +338,18 @@
font-family: monospace;
color: #eaffeb;
}
.titleContainer {
display: flex;
align-items: center;
justify-content: space-between;
margin: -0.5rem 0 1rem;
}
.title-fav {
font-size: 2rem;
background: rgba(0, 0, 0, 0.4);
border-radius: 50%;
padding: 0.25rem;
box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.7);
color: red;
}
</style>

View File

@@ -658,34 +658,4 @@
background-color: #eaffeb;
border-radius: 5rem;
}
.opening-times {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.opening-time-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.opening-time-item label {
margin-top: 0;
width: 6rem;
}
.opening-time-item input[type="time"] {
padding: 0.5rem;
height: 2.5rem;
border-radius: 0.5rem;
border: 2px solid #eaffeb;
background: none;
color: #eaffeb;
}
.opening-time-item span {
margin: 0 0.5rem;
color: #eaffeb;
}
</style>

View File

@@ -26,3 +26,16 @@ $$;
CREATE TRIGGER users_handle_new_user
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- Table to store users' favourite study spaces
CREATE TABLE favourite_study_spaces (
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
study_space_id uuid REFERENCES study_spaces(id) ON DELETE CASCADE,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
PRIMARY KEY (user_id, study_space_id)
);
CREATE TRIGGER favourite_study_spaces_updated_at
AFTER UPDATE ON favourite_study_spaces
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();

View File

@@ -26,3 +26,16 @@ $$;
CREATE TRIGGER users_handle_new_user
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- Table to store users' favourite study spaces
CREATE TABLE favourite_study_spaces (
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
study_space_id uuid REFERENCES study_spaces(id) ON DELETE CASCADE,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
PRIMARY KEY (user_id, study_space_id)
);
CREATE TRIGGER favourite_study_spaces_updated_at
AFTER UPDATE ON favourite_study_spaces
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();