feat: walking skeleton - study space uploads

This commit is contained in:
2025-05-30 11:13:35 +01:00
parent 00944faf1e
commit d7db89e13c
12 changed files with 516 additions and 14 deletions

View File

@@ -3,6 +3,7 @@
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"svelte.svelte-vscode", "svelte.svelte-vscode",
"redhat.vscode-yaml" "redhat.vscode-yaml",
"naumovs.color-highlight"
] ]
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
alt: string;
imgSrc: string;
description?: Snippet;
}
const { alt, imgSrc, description }: Props = $props();
</script>
<div class="card">
<img src={imgSrc} {alt} />
<div class="description">
{@render description?.()}
</div>
</div>
<style>
.card {
display: flex;
flex-direction: column;
background-color: #38353f;
}
.description {
padding: 0.5rem;
color: #edebe9;
font-size: 0.875rem;
}
img {
width: 16rem;
aspect-ratio: 1 / 1;
}
</style>

215
src/lib/database.d.ts vendored Normal file
View File

@@ -0,0 +1,215 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
operationName?: string
query?: string
variables?: Json
extensions?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
study_space_images: {
Row: {
created_at: string | null
image_path: string
study_space_id: string
updated_at: string | null
}
Insert: {
created_at?: string | null
image_path: string
study_space_id: string
updated_at?: string | null
}
Update: {
created_at?: string | null
image_path?: string
study_space_id?: string
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "study_space_images_study_space_id_fkey"
columns: ["study_space_id"]
isOneToOne: false
referencedRelation: "study_spaces"
referencedColumns: ["id"]
},
]
}
study_spaces: {
Row: {
created_at: string | null
id: string
title: string
updated_at: string | null
}
Insert: {
created_at?: string | null
id?: string
title: string
updated_at?: string | null
}
Update: {
created_at?: string | null
id?: string
title?: string
updated_at?: string | null
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DefaultSchema = Database[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof Database },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof Database },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database
}
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {},
},
} as const

12
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,12 @@
<script lang="ts">
const { children } = $props();
</script>
{@render children?.()}
<style>
:global(body) {
margin: 0;
padding: 0;
}
</style>

View File

@@ -0,0 +1,18 @@
import { createClient } from "@supabase/supabase-js";
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from "$env/static/public";
import type { PageServerLoad } from "./$types";
import type { Database } from "$lib/database";
import { error } from "@sveltejs/kit";
export const load: PageServerLoad = async ({ depends }) => {
depends("db:study_spaces");
const supabase = createClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
const { data: studySpaces, error: err } = await supabase
.from("study_spaces")
.select("*, study_space_images(*)");
if (err) error(500, "Failed to load study spaces");
return {
studySpaces
};
};

View File

@@ -1,2 +1,87 @@
<h1>Welcome to SvelteKit</h1> <script lang="ts">
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> import SpaceCard from "$lib/components/SpaceCard.svelte";
import defaultImg from "$lib/assets/study_space.png";
import { createClient } from "@supabase/supabase-js";
import type { Database } from "$lib/database";
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from "$env/static/public";
import { invalidate } from "$app/navigation";
const supabase = createClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
const { data } = $props();
const { studySpaces } = $derived(data);
let title = $state("");
let fileInput = $state<HTMLInputElement | null>(null);
async function uploadStudySpace() {
const imageFile = fileInput?.files?.[0];
const imageB64 = imageFile
? await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(imageFile);
})
: null;
if (!imageB64 || !imageFile) {
alert("Please select an image file.");
return;
}
const res = await fetch("/api/study_spaces", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
title,
img: imageB64,
imgTitle: imageFile.name
})
});
if (res.ok) {
alert("Study space uploaded successfully!");
await invalidate("db:study_spaces");
} else {
alert("Failed to upload study space.");
}
}
</script>
<main>
{#each studySpaces as studySpace (studySpace.id)}
{@const imgUrl =
studySpace.study_space_images.length > 0
? supabase.storage
.from("files_bucket")
.getPublicUrl(studySpace.study_space_images[0].image_path).data.publicUrl
: defaultImg}
<SpaceCard alt="Photo of {studySpace.title}" imgSrc={imgUrl}>
{#snippet description()}
<p>{studySpace.title}</p>
{/snippet}
</SpaceCard>
{/each}
</main>
<form
onsubmit={(e) => {
e.preventDefault();
uploadStudySpace();
}}
>
<input type="text" name="title" bind:value={title} placeholder="Study Space Title" required />
<input type="file" name="image" accept=".png, image/png" required bind:this={fileInput} />
<button type="submit">Upload Study Space</button>
</form>
<style>
main {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 1rem;
width: min(600px, 100vw);
}
form {
max-width: 600px;
}
</style>

View File

@@ -0,0 +1,36 @@
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from "$env/static/public";
import type { Database } from "$lib/database";
import { createClient } from "@supabase/supabase-js";
import { error, type RequestHandler } from "@sveltejs/kit";
export const POST: RequestHandler = async ({ request }) => {
const supabase = createClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
const body = await request.json();
const title = body.title;
const imgB64 = body.img;
const imgTitle = body.imgTitle;
if (!title || !imgB64 || !imgTitle) error(400, "Missing required fields: title, img, imgTitle");
const img = await fetch(imgB64).then((res) => res.blob());
const { data: imageData, error: imageError } = await supabase.storage
.from("files_bucket")
.upload(`public/${imgTitle}`, img, {
contentType: img.type,
upsert: false
});
if (imageError) error(500, `Failed to upload image: ${imageError.message}`);
const { data: studySpaceData, error: studySpaceError } = await supabase
.from("study_spaces")
.insert({ title })
.select()
.single();
if (studySpaceError) error(500, "Failed to create study space");
const { error: imageLinkError } = await supabase
.from("study_space_images")
.insert({ study_space_id: studySpaceData.id, image_path: imageData.path });
if (imageLinkError) error(500, "Failed to link image to study space");
return new Response(JSON.stringify({ id: studySpaceData.id }), { status: 200 });
};

View File

@@ -1,11 +0,0 @@
import { describe, test, expect } from "vitest";
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/svelte";
import Page from "./+page.svelte";
describe("/+page.svelte", () => {
test("should render h1", () => {
render(Page);
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,56 @@
CREATE FUNCTION handle_updated_at()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = ''
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
INSERT INTO storage.buckets (id, name, public)
VALUES ('files_bucket', 'files_bucket', true);
CREATE POLICY "Whack"
ON storage.objects
FOR ALL
USING (bucket_id = 'files_bucket');
CREATE TABLE study_spaces (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
CREATE TABLE study_space_images (
study_space_id uuid REFERENCES study_spaces(id) ON DELETE CASCADE,
image_path text NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
PRIMARY KEY (study_space_id, image_path)
);
-- Triggers
CREATE TRIGGER study_spaces_updated_at
AFTER UPDATE ON study_spaces
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
CREATE TRIGGER study_space_images_updated_at
AFTER UPDATE ON study_space_images
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
-- Security
-- ALTER TABLE study_spaces ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE study_space_images ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY "Allow all users to view study spaces"
-- ON study_spaces
-- FOR SELECT
-- USING (true);
-- CREATE POLICY "Allow all users to view study space images"
-- ON study_space_images
-- FOR SELECT
-- USING (true);

View File

@@ -0,0 +1,10 @@
CREATE FUNCTION handle_updated_at()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = ''
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;

View File

@@ -0,0 +1,45 @@
INSERT INTO storage.buckets (id, name, public)
VALUES ('files_bucket', 'files_bucket', true);
CREATE POLICY "Whack"
ON storage.objects
FOR ALL
USING (bucket_id = 'files_bucket');
CREATE TABLE study_spaces (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
CREATE TABLE study_space_images (
study_space_id uuid REFERENCES study_spaces(id) ON DELETE CASCADE,
image_path text NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
PRIMARY KEY (study_space_id, image_path)
);
-- Triggers
CREATE TRIGGER study_spaces_updated_at
AFTER UPDATE ON study_spaces
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
CREATE TRIGGER study_space_images_updated_at
AFTER UPDATE ON study_space_images
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
-- Security
-- ALTER TABLE study_spaces ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE study_space_images ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY "Allow all users to view study spaces"
-- ON study_spaces
-- FOR SELECT
-- USING (true);
-- CREATE POLICY "Allow all users to view study space images"
-- ON study_space_images
-- FOR SELECT
-- USING (true);