feat: walking skeleton - study space uploads
This commit is contained in:
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -3,6 +3,7 @@
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"svelte.svelte-vscode",
|
||||
"redhat.vscode-yaml"
|
||||
"redhat.vscode-yaml",
|
||||
"naumovs.color-highlight"
|
||||
]
|
||||
}
|
||||
|
||||
BIN
src/lib/assets/study_space.png
Normal file
BIN
src/lib/assets/study_space.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
35
src/lib/components/SpaceCard.svelte
Normal file
35
src/lib/components/SpaceCard.svelte
Normal 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
215
src/lib/database.d.ts
vendored
Normal 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
12
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
const { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
18
src/routes/+page.server.ts
Normal file
18
src/routes/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -1,2 +1,87 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
||||
36
src/routes/api/study_spaces/+server.ts
Normal file
36
src/routes/api/study_spaces/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
56
supabase/migrations/20250530085401_study_spaces_skeleton.sql
Normal file
56
supabase/migrations/20250530085401_study_spaces_skeleton.sql
Normal 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);
|
||||
10
supabase/schemas/0000_common.sql
Normal file
10
supabase/schemas/0000_common.sql
Normal 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;
|
||||
$$;
|
||||
45
supabase/schemas/0001_study_spaces.sql
Normal file
45
supabase/schemas/0001_study_spaces.sql
Normal 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);
|
||||
Reference in New Issue
Block a user