feat: initial uploads and single study space view

Co-Authored-By: Alex Ling <al443@ic.ac.uk>
This commit is contained in:
2025-06-04 18:10:45 +01:00
parent b02f2b2303
commit 40435df5e2
16 changed files with 486 additions and 86 deletions

View File

@@ -3,6 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
@font-face {
font-family: Inter;
src: url("%sveltekit.assets%/Inter.ttf") format("truetype");
}
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

11
src/lib/assets/camera.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_117_282)">
<path d="M36.4168 30.0833C36.4168 30.9232 36.0832 31.7286 35.4893 32.3225C34.8955 32.9164 34.09 33.25 33.2502 33.25H4.75016C3.91031 33.25 3.10486 32.9164 2.51099 32.3225C1.91713 31.7286 1.5835 30.9232 1.5835 30.0833V12.6667C1.5835 11.8268 1.91713 11.0214 2.51099 10.4275C3.10486 9.83363 3.91031 9.5 4.75016 9.5H11.0835L14.2502 4.75H23.7502L26.9168 9.5H33.2502C34.09 9.5 34.8955 9.83363 35.4893 10.4275C36.0832 11.0214 36.4168 11.8268 36.4168 12.6667V30.0833Z" stroke="#49BD85" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.0002 26.9167C22.498 26.9167 25.3335 24.0811 25.3335 20.5833C25.3335 17.0855 22.498 14.25 19.0002 14.25C15.5024 14.25 12.6668 17.0855 12.6668 20.5833C12.6668 24.0811 15.5024 26.9167 19.0002 26.9167Z" stroke="#49BD85" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_117_282">
<rect width="38" height="38" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

3
src/lib/assets/cross.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43.8404 24.0416L24.0414 43.8406M24.0414 24.0416L43.8404 43.8406" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
onclick?: (event: MouseEvent) => void;
disabled?: boolean;
type?: "button" | "submit" | "reset";
children?: Snippet;
}
const { children, ...rest }: Props = $props();
</script>
<button {...rest}>
{@render children?.()}
</button>
<style>
button {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: linear-gradient(-83deg, #3fb095, #49bd85);
box-shadow: 0rem 0rem 0.5rem #182125;
color: #eaffeb;
border: none;
cursor: pointer;
}
button:focus {
outline: 2px solid #007bff;
}
</style>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import logoUrl from "$lib/assets/logo.svg";
import type { Snippet } from "svelte";
interface Props {
children?: Snippet;
}
const { children }: Props = $props();
</script>
<nav>
<a href="/" class="logo">
<img src={logoUrl} alt="logo" />
</a>
<div class="rightButton">{@render children?.()}</div>
</nav>
<style>
nav {
display: flex;
position: sticky;
width: 100%;
height: 4rem;
top: 0;
left: 0;
background: linear-gradient(-77deg, #2e4653, #3a5b56);
box-shadow: 0rem 0rem 0.5rem #182125;
align-items: center;
}
.logo {
display: block;
height: 100%;
}
.logo img {
height: 100%;
}
.rightButton {
display: flex;
flex-direction: row-reverse;
flex: 1;
}
</style>

View File

@@ -5,17 +5,18 @@
alt: string;
imgSrc: string;
description?: Snippet;
href?: string;
}
const { alt, imgSrc, description }: Props = $props();
const { alt, imgSrc, description, href }: Props = $props();
</script>
<div class="card">
<a class="card" {href}>
<img src={imgSrc} {alt} />
<div class="description">
{@render description?.()}
</div>
</div>
</a>
<style>
.card {

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import cameraUrl from "$lib/assets/camera.svg";
interface Props {
name: string;
height?: string;
minHeight?: string;
files?: FileList;
required?: boolean;
}
let { name, height, minHeight, files = $bindable(), ...rest }: Props = $props();
</script>
<label
for={name}
style="height: {height || 'auto'}; min-height: {minHeight || 'auto'};"
class:no-bg={files && files.length > 0}
>
{#if files && files.length > 0}
<img src={URL.createObjectURL(files[0])} alt="uploaded study space" class="preview" />
{: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 />
</label>
<style>
label {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
background-color: #eaffeb;
cursor: pointer;
}
label.no-bg {
background-color: transparent;
padding: 0;
}
label input {
display: none;
}
.message {
display: flex;
flex-direction: column;
align-items: center;
color: #49bd85;
}
.preview {
max-height: 100%;
max-width: 100%;
}
.message .icon {
width: 2rem;
}
</style>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
interface Props {
name: string;
value?: string | null;
placeholder?: string;
required?: boolean;
}
let { value = $bindable(), name, ...rest }: Props = $props();
</script>
<input type="text" id={name} {name} bind:value {...rest} />
<style>
input {
width: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
border: 2px solid #eaffeb;
background: none;
color: #eaffeb;
font-size: 1rem;
}
input:focus {
border-color: #007bff;
outline: none;
}
</style>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
interface Props {
name: string;
value?: string | null;
placeholder?: string;
required?: boolean;
rows?: number;
cols?: number;
}
let { value = $bindable(), name, ...rest }: Props = $props();
</script>
<textarea id={name} {name} {...rest}></textarea>
<style>
textarea {
width: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
resize: vertical;
border: 2px solid #eaffeb;
background: none;
color: #eaffeb;
font-size: 1rem;
}
textarea:focus {
border-color: #007bff;
outline: none;
}
</style>

View File

@@ -13,7 +13,22 @@
<style>
:global(body) {
margin: 0;
padding: 0;
width: 100vw;
}
:global(html) {
background-color: #182125;
color: #eaffeb;
}
:global(*) {
box-sizing: border-box;
font-family: Inter;
}
:global(h1, h2, h3, h4, h5, h6, p) {
margin: 0;
padding: 0;
}

View File

@@ -3,57 +3,19 @@
import defaultImg from "$lib/assets/study_space.png";
import { invalidate } from "$app/navigation";
import type { Table } from "$lib";
import crossUrl from "$lib/assets/cross.svg";
import Navbar from "$lib/components/Navbar.svelte";
const { data } = $props();
const { studySpaces, supabase } = $derived(data);
const blankStudySpace = {
description: "",
building_address: "",
location: ""
};
let studySpaceData = $state<Omit<Table<"study_spaces">, "id" | "created_at" | "updated_at">>({
...blankStudySpace
});
let fileInput = $state<HTMLInputElement>();
async function uploadStudySpace() {
const imageFile = fileInput?.files?.[0];
if (!imageFile) return alert("Please select an image file.");
const { data: studySpaceInsert, error: studySpaceError } = await supabase
.from("study_spaces")
.insert(studySpaceData)
.select()
.single();
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
});
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();
if (imageInsertError) return alert(`Error creating image: ${imageInsertError.message}`);
if (fileInput) fileInput.value = ""; // Clear the file input
studySpaceData = { ...blankStudySpace }; // Reset the form data
alert("Thank you for your contribution!");
invalidate("db:study_spaces"); // Invalidate page data so that it refreshes
}
</script>
<Navbar>
<a href="/space">
<img src={crossUrl} alt="new" class="new-space" />
</a>
</Navbar>
<main>
{#each studySpaces as studySpace (studySpace.id)}
{@const imgUrl =
@@ -62,7 +24,11 @@
.from("files_bucket")
.getPublicUrl(studySpace.study_space_images[0].image_path).data.publicUrl
: defaultImg}
<SpaceCard alt="Photo of {studySpace.description}" imgSrc={imgUrl}>
<SpaceCard
alt="Photo of {studySpace.description}"
href="/space/{studySpace.id}"
imgSrc={imgUrl}
>
{#snippet description()}
<p>{studySpace.description}</p>
{/snippet}
@@ -70,37 +36,6 @@
{/each}
</main>
<form
onsubmit={(e) => {
e.preventDefault();
uploadStudySpace();
}}
>
<input
type="text"
name="description"
bind:value={studySpaceData.description}
placeholder="Study Space Description"
required
/>
<input
type="text"
name="location"
bind:value={studySpaceData.location}
placeholder="Study Space Location within building"
required
/>
<input
type="text"
name="building_address"
bind:value={studySpaceData.building_address}
placeholder="Building Address"
required
/>
<input type="file" name="image" accept=".png, image/png" required bind:this={fileInput} />
<button type="submit">Upload some Study Space!</button>
</form>
<style>
main {
display: grid;
@@ -109,10 +44,8 @@
padding: 1rem;
width: min(600px, 100vw);
}
form {
max-width: 600px;
display: flex;
flex-direction: column;
gap: 0.5rem;
.new-space {
transform: rotate(45deg);
}
</style>

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import Text from "$lib/components/inputs/Text.svelte";
import Textarea from "$lib/components/inputs/Textarea.svelte";
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 type { Table } from "$lib";
import { goto, invalidate } from "$app/navigation";
const { data } = $props();
const { supabase } = $derived(data);
let spaceImg = $state<FileList>();
let studySpaceData = $state<Omit<Table<"study_spaces">, "id" | "created_at" | "updated_at">>({
description: "",
building_address: "",
location: ""
});
async function uploadStudySpace() {
const imageFile = spaceImg?.[0];
if (!imageFile) return alert("Please select an image file.");
const { data: studySpaceInsert, error: studySpaceError } = await supabase
.from("study_spaces")
.insert(studySpaceData)
.select()
.single();
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
});
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();
if (imageInsertError) return alert(`Error creating image: ${imageInsertError.message}`);
alert("Thank you for your contribution!");
// Redirect to the new study space page
await goto(`/space/${studySpaceInsert.id}`, {
invalidate: ["db:study_spaces"]
});
}
</script>
<Navbar>
<a href="/">
<img src={crossUrl} alt="close" />
</a>
</Navbar>
<form
onsubmit={async (event) => {
event.preventDefault();
await uploadStudySpace();
}}
>
<Image name="study-space-image" minHeight="16rem" bind:files={spaceImg} />
<label for="location">Enter the name:</label>
<Text name="location" bind:value={studySpaceData.location} placeholder="Room 123, Floor 1" />
<label for="description">Add a description:</label>
<Textarea
name="description"
bind:value={studySpaceData.description}
placeholder="A quiet, but small study space..."
rows={5}
/>
<label for="address">Add an address:</label>
<Text
name="address"
bind:value={studySpaceData.building_address}
placeholder="180 Queen's Gate, London, SW7 5HF"
/>
<div class="submit">
<Button type="submit">Share this study space!</Button>
</div>
</form>
<style>
form {
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 0.5rem;
max-width: 32rem;
margin: 0 auto;
}
label {
color: #ffffff;
margin-top: 0.5rem;
}
.submit {
position: sticky;
display: flex;
flex-direction: column;
margin-top: 0.5rem;
bottom: 0;
margin-left: -0.5rem;
width: calc(100% + 1rem);
}
</style>

View File

@@ -0,0 +1,13 @@
import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params, locals: { supabase } }) => {
const { data: space, error: err } = await supabase
.from("study_spaces")
.select("*, study_space_images(*)")
.eq("id", params.id)
.single();
if (err) error(500, "Failed to load study space");
return { space };
};

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import Navbar from "$lib/components/Navbar.svelte";
import crossUrl from "$lib/assets/cross.svg";
import placeholder from "$lib/assets/study_space.png";
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
);
</script>
<Navbar>
<a href="/">
<img src={crossUrl} alt="close" />
</a>
</Navbar>
<main>
<img src={imgUrl} alt="the study space" />
<div class="nameContainer">
{space.location}
</div>
<p class="descContainer">
{space.description}
</p>
<hr />
<div class="whereSubtitle">Where it is:</div>
<p class="addrContainer">
{space.building_address}
</p>
</main>
<style>
main {
display: flex;
flex-direction: column;
padding: 0 0 1rem 0;
max-width: 32rem;
margin: 0 auto;
}
img {
display: block;
width: 100%;
aspect-ratio: 1/0.8;
object-fit: cover;
object-position: center;
}
hr {
height: 2px;
background-color: #2e3c42;
width: 70%;
border: none;
margin: 0 auto;
}
.nameContainer {
display: block;
width: 100%;
padding: 0.6rem;
margin-top: -0.5rem;
object-position: center;
background-color: #49bd85;
border-radius: 8px;
font-size: 2.8rem;
font-weight: bold;
color: #ffffff;
}
.descContainer {
display: block;
width: 100%;
padding: 1.4rem;
margin-top: -0.5rem;
object-position: center;
border-radius: 8px;
font-size: 1.2rem;
}
.whereSubtitle {
font-size: 1.2rem;
font-weight: bold;
color: #ffffff;
padding: 1.4rem;
}
.addrContainer {
font-size: 1.2rem;
padding: 0rem 1.4rem;
}
</style>