diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 38cd1c0..9b8df49 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,7 @@ "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "svelte.svelte-vscode", - "redhat.vscode-yaml" + "redhat.vscode-yaml", + "naumovs.color-highlight" ] } diff --git a/src/lib/assets/study_space.png b/src/lib/assets/study_space.png new file mode 100644 index 0000000..9f8d3f4 Binary files /dev/null and b/src/lib/assets/study_space.png differ diff --git a/src/lib/components/SpaceCard.svelte b/src/lib/components/SpaceCard.svelte new file mode 100644 index 0000000..f599a4a --- /dev/null +++ b/src/lib/components/SpaceCard.svelte @@ -0,0 +1,35 @@ + + +
+ +
+ {@render description?.()} +
+
+ + diff --git a/src/lib/database.d.ts b/src/lib/database.d.ts new file mode 100644 index 0000000..c1b0917 --- /dev/null +++ b/src/lib/database.d.ts @@ -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] + +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 diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..9c2d011 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,12 @@ + + +{@render children?.()} + + diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..10d8757 --- /dev/null +++ b/src/routes/+page.server.ts @@ -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(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 + }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cc88df0..c0591a0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,2 +1,87 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + +
+ {#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} + + {#snippet description()} +

{studySpace.title}

+ {/snippet} +
+ {/each} +
+ +
{ + e.preventDefault(); + uploadStudySpace(); + }} +> + + + +
+ + diff --git a/src/routes/api/study_spaces/+server.ts b/src/routes/api/study_spaces/+server.ts new file mode 100644 index 0000000..da4b54c --- /dev/null +++ b/src/routes/api/study_spaces/+server.ts @@ -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(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 }); +}; diff --git a/src/routes/page.svelte.test.ts b/src/routes/page.svelte.test.ts deleted file mode 100644 index e4d5d75..0000000 --- a/src/routes/page.svelte.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/supabase/migrations/20250530085401_study_spaces_skeleton.sql b/supabase/migrations/20250530085401_study_spaces_skeleton.sql new file mode 100644 index 0000000..b2ebf50 --- /dev/null +++ b/supabase/migrations/20250530085401_study_spaces_skeleton.sql @@ -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); \ No newline at end of file diff --git a/supabase/schemas/0000_common.sql b/supabase/schemas/0000_common.sql new file mode 100644 index 0000000..583f64f --- /dev/null +++ b/supabase/schemas/0000_common.sql @@ -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; +$$; diff --git a/supabase/schemas/0001_study_spaces.sql b/supabase/schemas/0001_study_spaces.sql new file mode 100644 index 0000000..b20d189 --- /dev/null +++ b/supabase/schemas/0001_study_spaces.sql @@ -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); \ No newline at end of file