feat: Implemented Favouriting for a user
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
This commit is contained in:
40
src/lib/components/Favourite.svelte
Normal file
40
src/lib/components/Favourite.svelte
Normal 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>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
let openingDisplay = $state("");
|
let openingDisplay = $state("");
|
||||||
if (todayHours) {
|
if (todayHours) {
|
||||||
openingDisplay = todayHours.open_today_status
|
openingDisplay = todayHours.open_today_status
|
||||||
? "Open 24/7"
|
? "Open All Day"
|
||||||
: `${formatTime(todayHours.opens_at)} - ${formatTime(todayHours.closes_at)}`;
|
: `${formatTime(todayHours.opens_at)} - ${formatTime(todayHours.closes_at)}`;
|
||||||
} else {
|
} else {
|
||||||
openingDisplay = "Closed";
|
openingDisplay = "Closed";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CompulsoryTags from "./CompulsoryTags.svelte";
|
import CompulsoryTags from "./CompulsoryTags.svelte";
|
||||||
import OpeningTimes from "./OpeningTimes.svelte";
|
import OpeningTimes from "./OpeningTimes.svelte";
|
||||||
|
import Favourite from "./Favourite.svelte";
|
||||||
import type { Table } from "$lib";
|
import type { Table } from "$lib";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,13 +10,21 @@
|
|||||||
alt: string;
|
alt: string;
|
||||||
imgSrc: string;
|
imgSrc: string;
|
||||||
href?: 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>
|
</script>
|
||||||
|
|
||||||
<a class="card" {href}>
|
<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">
|
<div class="description">
|
||||||
<h1>{space.location}</h1>
|
<h1>{space.location}</h1>
|
||||||
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
|
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
|
||||||
@@ -84,4 +93,17 @@
|
|||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
font-size: 0.8rem;
|
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>
|
</style>
|
||||||
|
|||||||
36
src/lib/database.d.ts
vendored
36
src/lib/database.d.ts
vendored
@@ -202,6 +202,42 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
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: {
|
Views: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
|
|||||||
@@ -8,7 +8,25 @@ export const load: PageServerLoad = async ({ depends, locals: { supabase } }) =>
|
|||||||
.select("*, study_space_images(*), study_space_hours(*)");
|
.select("*, study_space_images(*), study_space_hours(*)");
|
||||||
if (err) error(500, "Failed to load study spaces");
|
if (err) error(500, "Failed to load study spaces");
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { session }
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
// Fetch this user’s 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 {
|
return {
|
||||||
studySpaces
|
studySpaces,
|
||||||
|
session,
|
||||||
|
favouriteIds
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,18 +7,46 @@
|
|||||||
import Button from "$lib/components/Button.svelte";
|
import Button from "$lib/components/Button.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
const { studySpaces, supabase, session, adminMode } = $derived(data);
|
const {
|
||||||
|
studySpaces,
|
||||||
|
supabase,
|
||||||
|
session,
|
||||||
|
adminMode,
|
||||||
|
favouriteIds: initialFavourites = []
|
||||||
|
} = $derived(data);
|
||||||
|
|
||||||
let selectedTags = $state<string[]>([]);
|
let selectedTags = $state<string[]>([]);
|
||||||
let tagFilter = $state("");
|
let tagFilter = $state("");
|
||||||
|
let tagFilterElem = $state<HTMLInputElement>();
|
||||||
|
|
||||||
let openingFilter = $state("");
|
let openingFilter = $state("");
|
||||||
let closingFilter = $state("");
|
let closingFilter = $state("");
|
||||||
let tagFilterElem = $state<HTMLInputElement>();
|
|
||||||
|
let favouriteIds = $derived<string[]>(initialFavourites);
|
||||||
|
let showFavourites = $state(false);
|
||||||
|
|
||||||
function categorySelected(category: string[]) {
|
function categorySelected(category: string[]) {
|
||||||
return category.some((tag) => selectedTags.includes(tag));
|
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(
|
let filteredTags = $derived(
|
||||||
allTags
|
allTags
|
||||||
.filter((tag) => tag.toLowerCase().includes(tagFilter.toLowerCase()))
|
.filter((tag) => tag.toLowerCase().includes(tagFilter.toLowerCase()))
|
||||||
@@ -44,6 +72,8 @@
|
|||||||
// Combine tag and time filtering
|
// Combine tag and time filtering
|
||||||
let filteredStudySpaces = $derived(
|
let filteredStudySpaces = $derived(
|
||||||
studySpaces
|
studySpaces
|
||||||
|
// only include favourites when showFavourites===true
|
||||||
|
.filter((space) => !showFavourites || favouriteIds?.includes(space.id))
|
||||||
// tag filtering
|
// tag filtering
|
||||||
.filter((space) => {
|
.filter((space) => {
|
||||||
if (selectedTags.length === 0) return true;
|
if (selectedTags.length === 0) return true;
|
||||||
@@ -114,6 +144,9 @@
|
|||||||
<a href="/space/new/edit">
|
<a href="/space/new/edit">
|
||||||
<img src={crossUrl} alt="new" class="new-space" />
|
<img src={crossUrl} alt="new" class="new-space" />
|
||||||
</a>
|
</a>
|
||||||
|
<button class="fav-button" onclick={() => (showFavourites = !showFavourites)} type="button">
|
||||||
|
{showFavourites ? "All spaces" : "My favourites"}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</Navbar>
|
</Navbar>
|
||||||
|
|
||||||
@@ -198,6 +231,8 @@
|
|||||||
imgSrc={imgUrl}
|
imgSrc={imgUrl}
|
||||||
space={studySpace}
|
space={studySpace}
|
||||||
hours={studySpace.study_space_hours}
|
hours={studySpace.study_space_hours}
|
||||||
|
isFavourite={favouriteIds.includes(studySpace.id)}
|
||||||
|
onToggleFavourite={() => handleToggleFavourite(studySpace.id)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</main>
|
</main>
|
||||||
@@ -356,6 +391,18 @@
|
|||||||
font-size: 1.2rem;
|
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) {
|
@media (max-width: 20rem) {
|
||||||
main {
|
main {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { gmapsLoader, daysOfWeek, formatTime, type Table } from "$lib";
|
import { gmapsLoader, daysOfWeek, formatTime, type Table } from "$lib";
|
||||||
import Button from "$lib/components/Button.svelte";
|
import Button from "$lib/components/Button.svelte";
|
||||||
|
import Favourite from "$lib/components/Favourite.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
const { space, supabase, adminMode } = $derived(data);
|
const { space, supabase, adminMode } = $derived(data);
|
||||||
@@ -65,6 +66,40 @@
|
|||||||
for (const entry of space.study_space_hours) {
|
for (const entry of space.study_space_hours) {
|
||||||
timingsPerDay[entry.day_of_week].push(entry);
|
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>
|
</script>
|
||||||
|
|
||||||
<Navbar>
|
<Navbar>
|
||||||
@@ -87,8 +122,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<main>
|
<main>
|
||||||
<Carousel urls={imgUrls} />
|
<Carousel urls={imgUrls} />
|
||||||
<div class="nameContainer">
|
<div class="titleContainer">
|
||||||
{space.location}
|
<div class="nameContainer">{space.location}</div>
|
||||||
|
<div class="title-fav">
|
||||||
|
<Favourite {isFavourite} onToggleFavourite={handleToggleFavourite} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if space.description != null && space.description.length > 0}
|
{#if space.description != null && space.description.length > 0}
|
||||||
<p class="descContainer">
|
<p class="descContainer">
|
||||||
@@ -300,4 +338,18 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: #eaffeb;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -658,34 +658,4 @@
|
|||||||
background-color: #eaffeb;
|
background-color: #eaffeb;
|
||||||
border-radius: 5rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -26,3 +26,16 @@ $$;
|
|||||||
CREATE TRIGGER users_handle_new_user
|
CREATE TRIGGER users_handle_new_user
|
||||||
AFTER INSERT ON auth.users
|
AFTER INSERT ON auth.users
|
||||||
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
|
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();
|
||||||
|
|||||||
@@ -26,3 +26,16 @@ $$;
|
|||||||
CREATE TRIGGER users_handle_new_user
|
CREATE TRIGGER users_handle_new_user
|
||||||
AFTER INSERT ON auth.users
|
AFTER INSERT ON auth.users
|
||||||
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
|
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user