20 Commits

Author SHA1 Message Date
TadiosT
c7d8ed433d feat: Added checkboxes for properties of a study space on the upload page.
Co-Authored By: Caspar Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-05 23:57:00 +01:00
ebf33d47a2 Merge branch 'feat/multiple-images' into 'master'
feat: multi-image uploads

See merge request gk1623/drp-48!5
2025-06-05 16:53:16 +00:00
Ling, Alex
ff414f242d Merge branch 'tags-setup' into 'master'
feat: Added avaliable tags and tag column to table

See merge request gk1623/drp-48!4

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
Co-authored-by: Gleb Koval <gleb@koval.net>
2025-06-05 16:49:36 +00:00
485063f8d2 feat: multi-image uploads 2025-06-05 17:47:08 +01:00
Barf-Vader
0e074e9301 feat: Added avaliable tags and tag column to table
Coauthored-by: gk1623
2025-06-05 17:38:49 +01:00
6e45851892 feat: carousel image display 2025-06-05 17:01:03 +01:00
d4a9d5559e feat: logos
Co-authored-by: Barf-Vader <Barf-Vader@users.noreply.github.com>
2025-06-05 17:00:55 +01:00
a180e49466 feat: disable submit until valid 2025-06-05 14:48:28 +01:00
Caspar Jojo Asaam
a03a80a186 Merge branch 'feat/study-card-visual-improvements' into 'master'
feat: improved on ui so that user can see the spaces they want to view easier

See merge request gk1623/drp-48!3

Co-authored-by: Gleb Koval <gleb@koval.net>
Co-authored-by: Caspar Jojo Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-05 11:03:37 +00:00
11a040a677 fix: don't stretch image in spacecard 2025-06-05 11:54:37 +01:00
f85adf9edc fix: remove navbar overflow on main page 2025-06-05 11:53:10 +01:00
Caspar Jojo Asaam
6e72580a6a refactor: allowed for SpaceCard.svelte to take in study_spaces Table instead of Snippets
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-05 11:43:04 +01:00
Caspar Jojo Asaam
1d1bd940bf feat: Added rounded corners and removed blue underlining
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-05 11:25:02 +01:00
Caspar Jojo Asaam
f9878d1e48 feat: adjusted the cards such that it's only one column for very small widths and the cards resize for smaller widths
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-05 11:16:31 +01:00
Caspar Jojo Asaam
4ee33398c1 Merge branch 'feat/space-card-style-change' into 'master'
feat: changed card style and formatting to improve clarity for users

See merge request gk1623/drp-48!2

Co-authored-by: Caspar Jojo Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-05 00:33:54 +00:00
Caspar Jojo Asaam
55d9646b07 feat: changed card style and formatting to improve clarity for users
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-05 01:26:45 +01:00
b49f937dcb chore: rename address to building location 2025-06-04 23:35:02 +01:00
c1de092525 fix: require all study space fields 2025-06-04 21:00:25 +01:00
2ef82a5d41 fix: bind textarea value 2025-06-04 18:46:44 +01:00
b17b9ddb82 Merge branch 'feat/initial-study-space-view' into 'master'
feat: initial uploads and single study space view

See merge request gk1623/drp-48!1
2025-06-04 17:20:17 +00:00
15 changed files with 433 additions and 54 deletions

View File

@@ -0,0 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 24H38M38 24L24 10M38 24L24 38" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -27,4 +27,8 @@
button:focus {
outline: 2px solid #007bff;
}
button:disabled {
background: linear-gradient(-18deg, #66697b, #4e4e5e);
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import arrowRightUrl from "$lib/assets/arrow_right.svg";
import crossUrl from "$lib/assets/cross.svg";
import { onMount } from "svelte";
interface Props {
urls?: string[];
ondelete?: (idx: number) => void;
}
const { urls = [], ondelete }: Props = $props();
let carousel = $state<HTMLDivElement>();
let scrollPosition = $state(0);
let scrollWidth = $state(0);
let clientWidth = $state(1);
function updateScroll() {
scrollPosition = carousel?.scrollLeft || 0;
scrollWidth = carousel?.scrollWidth || 0;
clientWidth = carousel?.clientWidth || 1;
}
onMount(() => {
const id = setInterval(() => {
if (carousel) {
updateScroll();
}
}, 1000);
return () => clearInterval(id);
});
</script>
<div class="controls">
<div class="carousel" bind:this={carousel} onscroll={updateScroll} onscrollend={updateScroll}>
{#each urls as url, idx (`${idx}|${url}`)}
<div class="item">
<img src={url} alt="carousel item" />
{#if ondelete}
<button class="delete" onclick={() => ondelete(idx)}>
<img src={crossUrl} alt="delete item" />
</button>
{/if}
</div>
{/each}
</div>
{#if scrollPosition > clientWidth / 2}
<button
class="arrow left"
onclick={(e) => {
e.preventDefault();
if (carousel) carousel.scrollLeft -= carousel.clientWidth;
}}
>
<img src={arrowRightUrl} alt="go to previous" />
</button>
{/if}
{#if scrollPosition < scrollWidth - clientWidth * 1.5}
<button
class="arrow right"
onclick={(e) => {
e.preventDefault();
if (carousel) carousel.scrollLeft += carousel.clientWidth;
}}
>
<img src={arrowRightUrl} alt="go to next" />
</button>
{/if}
<span class="position">
{Math.round(scrollPosition / clientWidth) + 1} / {urls.length}
</span>
</div>
<style>
.carousel {
display: flex;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
}
.controls {
position: relative;
}
.item {
position: relative;
min-width: 100%;
max-width: 100%;
scroll-snap-align: center;
}
.item img {
height: 100%;
width: 100%;
object-fit: contain;
}
.delete,
.position,
.arrow {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
z-index: 10;
border: none;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 9999px;
}
.arrow,
.delete {
cursor: pointer;
width: 2rem;
aspect-ratio: 1 / 1;
}
.arrow img,
.delete img {
width: 1.4rem;
}
.arrow:hover {
opacity: 0.8;
}
.arrow.left {
left: 0.2rem;
top: 50%;
transform: translateY(-50%);
}
.arrow.left img {
transform: rotate(180deg);
}
.arrow.right {
right: 0.2rem;
top: 50%;
transform: translateY(-50%);
}
.delete {
top: 0.4rem;
right: 0.2rem;
}
.position {
font-size: 0.8rem;
top: 0.4rem;
border-radius: 1rem;
left: 0.2rem;
padding: 0.3rem 0.5rem;
background-color: rgba(0, 0, 0, 0.7);
}
</style>

View File

@@ -24,15 +24,19 @@
height: 4rem;
top: 0;
left: 0;
right: 0;
background: linear-gradient(-77deg, #2e4653, #3a5b56);
box-shadow: 0rem 0rem 0.5rem #182125;
align-items: center;
overflow: hidden;
z-index: 100;
}
.logo {
display: block;
height: 100%;
}
.logo img {
height: 100%;
}

View File

@@ -1,20 +1,21 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Table } from "$lib";
interface Props {
space: Table<"study_spaces">;
alt: string;
imgSrc: string;
description?: Snippet;
href?: string;
}
const { alt, imgSrc, description, href }: Props = $props();
const { space, alt, imgSrc, href }: Props = $props();
</script>
<a class="card" {href}>
<img src={imgSrc} {alt} />
<div class="description">
{@render description?.()}
<h1>{space.location}</h1>
<p>{space.description}</p>
</div>
</a>
@@ -22,7 +23,12 @@
.card {
display: flex;
flex-direction: column;
background-color: #38353f;
background-color: #49bd85;
width: 100%;
max-width: 20rem;
border-radius: 0.5rem;
overflow: hidden;
text-decoration: none;
}
.description {
padding: 0.5rem;
@@ -30,7 +36,9 @@
font-size: 0.875rem;
}
img {
width: 16rem;
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
object-fit: cover;
}
</style>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import cameraUrl from "$lib/assets/camera.svg";
import Carousel from "../Carousel.svelte";
interface Props {
name: string;
@@ -18,14 +19,37 @@
class:no-bg={files && files.length > 0}
>
{#if files && files.length > 0}
<img src={URL.createObjectURL(files[0])} alt="uploaded study space" class="preview" />
<Carousel
urls={files
? Array(files.length)
.keys()
.map((i) => URL.createObjectURL(files![i]))
.toArray()
: []}
ondelete={(idx) => {
if (!files) return;
const dt = new DataTransfer();
for (let i = 0; i < files.length; i++) {
if (i !== idx) dt.items.add(files[i]);
}
files = dt.files;
}}
/>
{:else}
<div class="message">
<img src={cameraUrl} class="icon" alt="camera icon" />
<span>Click to upload a photo</span>
</div>
{/if}
<input type="file" id={name} {name} accept=".png, .jpg, .jpeg, .svg" {...rest} bind:files />
<input
type="file"
id={name}
{name}
multiple
accept=".png, .jpg, .jpeg, .svg"
{...rest}
bind:files
/>
</label>
<style>

View File

@@ -11,7 +11,7 @@
let { value = $bindable(), name, ...rest }: Props = $props();
</script>
<textarea id={name} {name} {...rest}></textarea>
<textarea id={name} {name} bind:value {...rest}></textarea>
<style>
textarea {

View File

@@ -65,7 +65,7 @@ export type Database = {
}
study_spaces: {
Row: {
building_address: string | null
building_location: string | null
created_at: string | null
description: string | null
id: string
@@ -73,7 +73,7 @@ export type Database = {
updated_at: string | null
}
Insert: {
building_address?: string | null
building_location?: string | null
created_at?: string | null
description?: string | null
id?: string
@@ -81,7 +81,7 @@ export type Database = {
updated_at?: string | null
}
Update: {
building_address?: string | null
building_location?: string | null
created_at?: string | null
description?: string | null
id?: string

View File

@@ -3,3 +3,25 @@ import type { Database } from "./database.d.ts";
export type Table<T extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][T]["Row"];
export type Enum<T extends keyof Database["public"]["Enums"]> = Database["public"]["Enums"][T];
export const availableStudySpaceTags = [
"Quiet",
"Loud",
"Silent",
"Group study",
"Individual study",
"Power outlets",
"No power outlets",
"24/7",
"Food allowed",
"No food allowed",
"Good wifi",
"Bad wifi",
"No wifi",
"Whiteboard",
"Restricted access",
"Hot",
"Air conditioned",
"Cold",
"Cringe"
];

View File

@@ -26,24 +26,30 @@
alt="Photo of {studySpace.description}"
href="/space/{studySpace.id}"
imgSrc={imgUrl}
>
{#snippet description()}
<p>{studySpace.description}</p>
{/snippet}
</SpaceCard>
space={studySpace}
/>
{/each}
</main>
<style>
main {
display: grid;
box-sizing: border-box;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 1rem;
width: min(600px, 100vw);
max-width: 600px;
width: 100%;
margin: 0 auto;
}
.new-space {
transform: rotate(45deg);
}
@media (max-width: 20rem) {
main {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -5,22 +5,22 @@
import Navbar from "$lib/components/Navbar.svelte";
import crossUrl from "$lib/assets/cross.svg";
import Button from "$lib/components/Button.svelte";
import Image from "$lib/components/inputs/Image.svelte";
import Images from "$lib/components/inputs/Images.svelte";
import type { Table } from "$lib";
import { availableStudySpaceTags } from "$lib/index"
const { data } = $props();
const { supabase } = $derived(data);
let spaceImg = $state<FileList>();
let spaceImgs = $state<FileList>();
let studySpaceData = $state<Omit<Table<"study_spaces">, "id" | "created_at" | "updated_at">>({
description: "",
building_address: "",
building_location: "",
location: ""
});
async function uploadStudySpace() {
const imageFile = spaceImg?.[0];
if (!imageFile) return alert("Please select an image file.");
if (!spaceImgs || spaceImgs.length < 1) return alert("Please select an image file.");
const { data: studySpaceInsert, error: studySpaceError } = await supabase
.from("study_spaces")
@@ -30,21 +30,31 @@
if (studySpaceError)
return alert(`Error uploading study space: ${studySpaceError.message}`);
const { data: imgUpload, error: imageError } = await supabase.storage
.from("files_bucket")
.upload(`public/${studySpaceInsert.id}-${imageFile.name}`, imageFile, {
contentType: imageFile.type
});
const imgUploads = await Promise.all(
Array(spaceImgs.length)
.keys()
.map(async (i) => {
const imageFile = spaceImgs![i];
const resp = await supabase.storage
.from("files_bucket")
.upload(`public/${studySpaceInsert.id}-${imageFile.name}`, imageFile, {
contentType: imageFile.type
});
return resp;
})
);
const imageError = imgUploads.find(({ error }) => error)?.error;
if (imageError) return alert(`Error uploading image: ${imageError.message}`);
const { error: imageInsertError } = await supabase
.from("study_space_images")
.insert({
study_space_id: studySpaceInsert.id,
image_path: imgUpload.path
})
.select()
.single();
.insert(
imgUploads.map(({ data }) => ({
study_space_id: studySpaceInsert.id,
image_path: data!.path
}))
)
.select();
if (imageInsertError) return alert(`Error creating image: ${imageInsertError.message}`);
alert("Thank you for your contribution!");
@@ -53,6 +63,17 @@
invalidate: ["db:study_spaces"]
});
}
let selectedTags = $state<string[]>([]);
function toggleTag(tag: string) {
if (selectedTags.includes(tag)) {
selectedTags = selectedTags.filter(t => t !== tag);
} else {
selectedTags = [...selectedTags, tag];
}
}
</script>
<Navbar>
@@ -67,32 +88,156 @@
await uploadStudySpace();
}}
>
<Image name="study-space-image" minHeight="16rem" bind:files={spaceImg} />
<Images name="study-space-image" minHeight="16rem" bind:files={spaceImgs} required />
<label for="location">Enter the name:</label>
<Text name="location" bind:value={studySpaceData.location} placeholder="Room 123, Floor 1" />
<Text
name="location"
bind:value={studySpaceData.location}
placeholder="Room 123, Floor 1"
required
/>
<fieldset>
<legend>Select applicable tags:</legend>
<div class="tag-list">
{#each availableStudySpaceTags as tag}
<label class="tag-checkbox">
<input
type="checkbox"
checked={selectedTags.includes(tag)}
onchange={() => toggleTag(tag)}
/>
{tag}
</label>
{/each}
</div>
</fieldset>
<label for="description">Add a description:</label>
<Textarea
name="description"
bind:value={studySpaceData.description}
placeholder="A quiet, but small study space..."
placeholder="A quiet and well-lit, but small space..."
rows={5}
required
/>
<label for="address">Add an address:</label>
<label for="building-location">Add the building location:</label>
<Text
name="address"
bind:value={studySpaceData.building_address}
placeholder="180 Queen's Gate, London, SW7 5HF"
name="building-location"
bind:value={studySpaceData.building_location}
placeholder="Huxley Building, Imperial South Kensington Campus"
required
/>
<div class="submit">
<Button type="submit">Share this study space!</Button>
<Button
type="submit"
disabled={(spaceImgs?.length || 0) === 0 ||
!studySpaceData.location ||
!studySpaceData.description ||
!studySpaceData.building_location}
>
Share this study space!
</Button>
</div>
</form>
<style>
/* .tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.tag-checkbox {
background: #2e4653;
color: white;
border-radius: 0.5rem;
padding: 0.25rem 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 1rem;
}
input[type="checkbox"] {
accent-color: #49bd85;
} */
fieldset {
border: none;
padding: 0;
margin-top: 1rem;
}
legend {
font-size: 1rem;
color: #ffffff;
margin-bottom: 0.5rem;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-checkbox {
display: flex;
align-items: center;
background-color: #2e4653;
color: #ffffff;
padding: 0.4rem 0.8rem;
border-radius: 9999px; /* pill shape */
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.tag-checkbox:hover {
background-color: #3a5b56;
transform: scale(1.03);
}
.tag-checkbox input[type="checkbox"] {
appearance: none;
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
border-radius: 0.25rem;
border: 2px solid #49bd85;
background-color: transparent;
position: relative;
}
.tag-checkbox input[type="checkbox"]:checked {
background-color: #49bd85;
border-color: #49bd85;
}
.tag-checkbox input[type="checkbox"]::after {
content: "";
display: block;
width: 0.25rem;
height: 0.5rem;
border: solid #1f2a2f;
border-width: 0 0.125rem 0.125rem 0;
transform: rotate(45deg);
position: absolute;
top: 0.15rem;
left: 0.32rem;
opacity: 0;
}
.tag-checkbox input[type="checkbox"]:checked::after {
opacity: 1;
}
form {
display: flex;
flex-direction: column;

View File

@@ -2,16 +2,19 @@
import Navbar from "$lib/components/Navbar.svelte";
import crossUrl from "$lib/assets/cross.svg";
import placeholder from "$lib/assets/study_space.png";
import Carousel from "$lib/components/Carousel.svelte";
const { data } = $props();
const { space, supabase } = $derived(data);
const imgUrl = $derived(
space.study_space_images.length > 0
? supabase.storage
.from("files_bucket")
.getPublicUrl(space.study_space_images[0].image_path).data.publicUrl
: placeholder
const imgUrls = $derived(
space.study_space_images.length === 0
? [placeholder]
: space.study_space_images.map(
(img) =>
supabase.storage.from("files_bucket").getPublicUrl(img.image_path).data
.publicUrl
)
);
</script>
@@ -22,7 +25,7 @@
</Navbar>
<main>
<img src={imgUrl} alt="the study space" />
<Carousel urls={imgUrls} />
<div class="nameContainer">
{space.location}
</div>
@@ -32,7 +35,7 @@
<hr />
<div class="whereSubtitle">Where it is:</div>
<p class="addrContainer">
{space.building_address}
{space.building_location}
</p>
</main>
@@ -61,6 +64,7 @@
margin: 0 auto;
}
.nameContainer {
z-index: 10;
display: block;
width: 100%;
padding: 0.6rem;

View File

@@ -0,0 +1 @@
ALTER TABLE study_spaces RENAME COLUMN "building_address" TO "building_location";

View File

@@ -11,7 +11,8 @@ CREATE TABLE study_spaces (
description text,
-- Location within building, e.g., "Room 101"
location text,
building_address text,
building_location text,
tags text[] NOT NULL DEFAULT array[]::text[],
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
@@ -24,6 +25,7 @@ CREATE TABLE study_space_images (
PRIMARY KEY (study_space_id, image_path)
);
-- Triggers
CREATE TRIGGER study_spaces_updated_at
AFTER UPDATE ON study_spaces