am i finished?

This commit is contained in:
2025-08-25 14:23:57 +01:00
parent 769f3981cd
commit fe0968716d
12 changed files with 282 additions and 105 deletions

View File

@ -1,9 +1,9 @@
package auth
import (
"fmt"
"os"
"github.com/charmbracelet/log"
"github.com/wneessen/go-mail"
)
@ -11,7 +11,9 @@ type MailClient struct {
client *mail.Client
}
type TestMailClient struct{}
type TestMailClient struct {
logger *log.Logger
}
type Mailer interface {
SendCode(to string, code string) error
@ -43,15 +45,17 @@ func (m MailClient) SendCode(to string, code string) error {
}
func (m TestMailClient) SendCode(to string, code string) error {
fmt.Printf("Email: %s | Code %s\n", to, code)
m.logger.Info("Auth Code", "email", to, "code", code)
return nil
}
func CreateMailClient() (Mailer, error) {
func CreateMailClient(log *log.Logger) (Mailer, error) {
mode := os.Getenv("MODE")
if mode == "DEV" {
return TestMailClient{}, nil
return TestMailClient{
log,
}, nil
}
client, err := mail.NewClient(

View File

@ -35,7 +35,6 @@ type codeReturn struct {
}
func (h *AuthHandler) login(body loginBody, w http.ResponseWriter, r *http.Request) {
// TODO: validate email
err := h.auth.CreateCode(body.Email)
if err != nil {
middleware.WriteErrorInternal(h.logger, "could not create a code", w)
@ -92,7 +91,7 @@ func CreateAuthHandler(db *sql.DB) AuthHandler {
userModel := models.NewUserModel(db)
logger := log.New(os.Stdout).WithPrefix("Auth")
mailer, err := CreateMailClient()
mailer, err := CreateMailClient(logger)
if err != nil {
panic(err)
}

View File

@ -145,15 +145,15 @@ func (h *ImageHandler) CreateRoutes(r chi.Router) {
h.logger.Info("Mounting image router")
// Public route for serving images (not protected)
r.Get("/image/{id}", h.serveImage)
r.Get("/{id}", h.serveImage)
// Protected routes
r.Group(func(r chi.Router) {
r.Use(middleware.ProtectedRoute)
r.Use(middleware.SetJson)
r.Get("/image", h.listImages)
r.Post("/image/{name}", h.uploadImage)
r.Get("/", h.listImages)
r.Post("/{name}", h.uploadImage)
})
}

View File

@ -358,7 +358,7 @@ func TestAllRoutes(t *testing.T) {
t.Run("Image Routes", func(t *testing.T) {
t.Run("Get images without authentication", func(t *testing.T) {
resp := tc.makeRequest(t, "GET", "/images/image", "", nil)
resp := tc.makeRequest(t, "GET", "/images/", "", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
@ -367,7 +367,7 @@ func TestAllRoutes(t *testing.T) {
})
t.Run("Get images with authentication", func(t *testing.T) {
resp := tc.makeRequest(t, "GET", "/images/image", imageUser.Token, nil)
resp := tc.makeRequest(t, "GET", "/images/", imageUser.Token, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@ -384,7 +384,7 @@ func TestAllRoutes(t *testing.T) {
// Create a simple valid base64 string for testing
testImageBase64 := "dGVzdCBkYXRh" // "test data" in base64
req, err := http.NewRequest("POST", tc.server.URL+"/images/image/test.png", strings.NewReader(testImageBase64))
req, err := http.NewRequest("POST", tc.server.URL+"/images/test.png", strings.NewReader(testImageBase64))
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
@ -417,7 +417,7 @@ func TestAllRoutes(t *testing.T) {
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
}
req, err := http.NewRequest("POST", tc.server.URL+"/images/image/test2.png", bytes.NewReader(testImageBinary))
req, err := http.NewRequest("POST", tc.server.URL+"/images/test2.png", bytes.NewReader(testImageBinary))
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
@ -440,7 +440,7 @@ func TestAllRoutes(t *testing.T) {
})
t.Run("Upload image without name", func(t *testing.T) {
resp := tc.makeRequest(t, "POST", "/images/image/", imageUser.Token, nil)
resp := tc.makeRequest(t, "POST", "/images/", imageUser.Token, nil)
defer resp.Body.Close()
// Route pattern doesn't match empty names, so returns 404
@ -451,7 +451,7 @@ func TestAllRoutes(t *testing.T) {
t.Run("Serve non-existent image", func(t *testing.T) {
fakeUUID := uuid.New()
resp := tc.makeRequest(t, "GET", "/images/image/"+fakeUUID.String(), "", nil)
resp := tc.makeRequest(t, "GET", "/images/"+fakeUUID.String(), "", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
@ -462,7 +462,7 @@ func TestAllRoutes(t *testing.T) {
t.Run("Complete User Flow", func(t *testing.T) {
// Step 1: Test authentication is working
resp := tc.makeRequest(t, "GET", "/images/image", flowUser.Token, nil)
resp := tc.makeRequest(t, "GET", "/images/", flowUser.Token, nil)
if resp.StatusCode != http.StatusOK {
t.Errorf("Authentication failed, expected 200, got %d", resp.StatusCode)
}
@ -478,7 +478,7 @@ func TestAllRoutes(t *testing.T) {
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
}
req, err := http.NewRequest("POST", tc.server.URL+"/images/image/test_flow.png", bytes.NewReader(testImageBinary))
req, err := http.NewRequest("POST", tc.server.URL+"/images/test_flow.png", bytes.NewReader(testImageBinary))
if err != nil {
t.Fatalf("Failed to create upload request: %v", err)
}
@ -500,7 +500,7 @@ func TestAllRoutes(t *testing.T) {
resp.Body.Close()
// Step 3: Verify image appears in user's image list
resp = tc.makeRequest(t, "GET", "/images/image", flowUser.Token, nil)
resp = tc.makeRequest(t, "GET", "/images/", flowUser.Token, nil)
if resp.StatusCode != http.StatusOK {
t.Errorf("Failed to get user images, expected 200, got %d", resp.StatusCode)
}

View File

@ -7,7 +7,7 @@ export const ImageComponent: Component<{ ID: string }> = (props) => {
<A href={`/image/${props.ID}`} class="w-full flex justify-center h-[300px]">
<img
class="flex w-full object-cover rounded-xl"
src={`${base}/image/${props.ID}`}
src={`${base}/images/${props.ID}`}
/>
</A>
);

View File

@ -43,7 +43,7 @@ export const ProcessingImages: Component = () => {
<img
class="w-16 h-16 aspect-square rounded"
alt="processing"
src={`${base}/image/${id}`}
src={`${base}/images/${id}`}
/>
<div class="flex flex-col gap-1">
<p class="text-slate-100">{image().ImageName}</p>

View File

@ -14,12 +14,12 @@ export type SearchImageStore = {
Array<{ date: Date; images: JustTheImageWhatAreTheseNames }>
>;
lists: Accessor<Awaited<ReturnType<typeof getUserImages>>["Lists"]>;
lists: Accessor<Awaited<ReturnType<typeof getUserImages>>["lists"]>;
userImages: Accessor<JustTheImageWhatAreTheseNames>;
processingImages: Accessor<
Awaited<ReturnType<typeof getUserImages>>["ProcessingImages"] | undefined
Awaited<ReturnType<typeof getUserImages>>["processingImages"] | undefined
>;
onRefetchImages: () => void;
@ -39,7 +39,7 @@ export const SearchImageContextProvider: Component<ParentProps> = (props) => {
// Sorted by day. But we could potentially add more in the future.
const buckets: Record<string, JustTheImageWhatAreTheseNames> = {};
for (const image of d.UserImages) {
for (const image of d.userImages) {
if (image.CreatedAt == null) {
continue;
}
@ -58,14 +58,14 @@ export const SearchImageContextProvider: Component<ParentProps> = (props) => {
},
);
const processingImages = () => data()?.ProcessingImages ?? [];
const processingImages = () => data()?.processingImages ?? [];
return (
<SearchImageContext.Provider
value={{
imagesByDate: sortedImages,
lists: () => data()?.Lists ?? [],
userImages: () => data()?.UserImages ?? [],
lists: () => data()?.lists ?? [],
userImages: () => data()?.userImages ?? [],
processingImages,
onRefetchImages: refetch,
}}

View File

@ -55,7 +55,7 @@ export const sendImageFile = async (
file: File,
): Promise<InferOutput<typeof sendImageResponseValidator>> => {
const request = getBaseAuthorizedRequest({
path: `image/${imageName}`,
path: `images/${imageName}`,
body: file,
method: "POST",
});
@ -72,7 +72,7 @@ export const sendImage = async (
base64Image: string,
): Promise<InferOutput<typeof sendImageResponseValidator>> => {
const request = getBaseAuthorizedRequest({
path: `image/${imageName}`,
path: `images/${imageName}`,
body: base64Image,
method: "POST",
});
@ -161,9 +161,9 @@ const listValidator = strictObject({
export type List = InferOutput<typeof listValidator>;
const imageRequestValidator = strictObject({
UserImages: array(userImageValidator),
ProcessingImages: array(userProcessingImageValidator),
Lists: array(listValidator),
userImages: array(userImageValidator),
processingImages: array(userProcessingImageValidator),
lists: array(listValidator),
});
export type JustTheImageWhatAreTheseNames = InferOutput<
@ -173,18 +173,16 @@ export type JustTheImageWhatAreTheseNames = InferOutput<
export const getUserImages = async (): Promise<
InferOutput<typeof imageRequestValidator>
> => {
const request = getBaseAuthorizedRequest({ path: "image" });
const request = getBaseAuthorizedRequest({ path: "images" });
const res = await fetch(request).then((res) => res.json());
console.log("BACKEND RESPONSE: ", res);
return parse(imageRequestValidator, res);
};
export const postLogin = async (email: string): Promise<void> => {
const request = getBaseRequest({
path: "login",
path: "auth/login",
body: JSON.stringify({ email }),
method: "POST",
});
@ -192,18 +190,6 @@ export const postLogin = async (email: string): Promise<void> => {
await fetch(request);
};
export const postDemoLogin = async (): Promise<
InferOutput<typeof codeValidator>
> => {
const request = getBaseRequest({
path: "demo-login",
});
const res = await fetch(request).then((res) => res.json());
return parse(codeValidator, res);
};
const codeValidator = strictObject({
access: string(),
refresh: string(),
@ -214,7 +200,7 @@ export const postCode = async (
code: string,
): Promise<InferOutput<typeof codeValidator>> => {
const request = getBaseRequest({
path: "code",
path: "auth/code",
body: JSON.stringify({ email, code }),
method: "POST",
});
@ -223,3 +209,18 @@ export const postCode = async (
return parse(codeValidator, res);
};
export const createList = async (
title: string,
description: string,
): Promise<void> => {
const request = getBaseAuthorizedRequest({
path: "stacks",
method: "POST",
body: JSON.stringify({ title, description }),
});
request.headers.set("Content-Type", "application/json");
await fetch(request);
};

View File

@ -1,16 +1,136 @@
import { Component, For } from "solid-js";
import { Component, For, createSignal } from "solid-js";
import { useSearchImageContext } from "@contexts/SearchImageContext";
import { ListCard } from "@components/list-card";
import { Button } from "@kobalte/core/button";
import { Dialog } from "@kobalte/core/dialog";
import { createList } from "../../network";
export const Categories: Component = () => {
const { lists } = useSearchImageContext();
const { lists, onRefetchImages } = useSearchImageContext();
return (
<div class="rounded-xl bg-white p-4 flex flex-col gap-2">
<h2 class="text-xl font-bold">Generated Lists</h2>
<div class="w-full grid grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4">
<For each={lists()}>{(list) => <ListCard list={list} />}</For>
</div>
</div>
);
const [title, setTitle] = createSignal("");
const [description, setDescription] = createSignal("");
const [isCreating, setIsCreating] = createSignal(false);
const [showForm, setShowForm] = createSignal(false);
const handleCreateList = async () => {
if (description().trim().length === 0 || title().trim().length === 0)
return;
setIsCreating(true);
try {
await createList(title().trim(), description().trim());
setTitle("");
setDescription("");
setShowForm(false);
onRefetchImages(); // Refresh the lists
} catch (error) {
console.error("Failed to create list:", error);
} finally {
setIsCreating(false);
}
};
return (
<div class="rounded-xl bg-white p-4 flex flex-col gap-2">
<h2 class="text-xl font-bold">Generated Lists</h2>
<div class="w-full grid grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4">
<For each={lists()}>{(list) => <ListCard list={list} />}</For>
</div>
<div class="mt-4">
<Button
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium shadow-sm hover:shadow-md"
onClick={() => setShowForm(true)}
>
+ Create List
</Button>
</div>
<Dialog open={showForm()} onOpenChange={setShowForm}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black/50 z-50" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div class="p-6">
<Dialog.Title class="text-xl font-bold text-neutral-900 mb-4">
Create New List
</Dialog.Title>
<div class="space-y-4">
<div>
<label
for="list-title"
class="block text-sm font-medium text-neutral-700 mb-2"
>
List Title
</label>
<input
id="list-title"
type="text"
value={title()}
onInput={(e) =>
setTitle(e.target.value)
}
placeholder="Enter a title for your list"
class="w-full p-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors"
disabled={isCreating()}
/>
</div>
<div>
<label
for="list-description"
class="block text-sm font-medium text-neutral-700 mb-2"
>
List Description
</label>
<textarea
id="list-description"
value={description()}
onInput={(e) =>
setDescription(e.target.value)
}
placeholder="Describe what kind of list you want to create (e.g., 'A list of my favorite recipes' or 'Photos from my vacation')"
class="w-full p-3 border border-neutral-300 rounded-lg resize-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors"
rows="4"
disabled={isCreating()}
/>
</div>
</div>
<div class="flex gap-3 mt-6">
<Button
class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 font-medium shadow-sm hover:shadow-md"
onClick={handleCreateList}
disabled={
isCreating() ||
!title().trim() ||
!description().trim()
}
>
{isCreating()
? "Creating..."
: "Create List"}
</Button>
<Button
class="px-4 py-2 bg-neutral-300 text-neutral-700 rounded-lg hover:bg-neutral-400 transition-colors font-medium"
onClick={() => {
setShowForm(false);
setTitle("");
setDescription("");
}}
disabled={isCreating()}
>
Cancel
</Button>
</div>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</div>
);
};

View File

@ -3,7 +3,6 @@ import { useSearchImageContext } from "@contexts/SearchImageContext";
import { useParams } from "@solidjs/router";
import { For, type Component } from "solid-js";
import SolidjsMarkdown from "solidjs-markdown";
import { List } from "../list";
import { ListCard } from "@components/list-card";
export const ImagePage: Component = () => {

View File

@ -1,43 +1,107 @@
import { ImageComponent } from "@components/image";
import { useSearchImageContext } from "@contexts/SearchImageContext";
import { useParams } from "@solidjs/router";
import { Component, For, Show } from "solid-js";
import { base } from "../../network";
export const List: Component = () => {
const { listId } = useParams();
const { listId } = useParams();
const { lists } = useSearchImageContext();
const { lists } = useSearchImageContext();
const list = () => lists().find((l) => l.ID === listId);
const list = () => lists().find((l) => l.ID === listId);
return (
<Show when={list()} fallback="List could not be found">
{(l) => (
<table>
<thead>
<tr>
<th>Image</th>
<For each={l().Schema.SchemaItems}>
{(item) => <th>{item.Item}</th>}
</For>
</tr>
</thead>
<tbody>
<For each={l().Images}>
{(image) => (
<tr>
<td>
<ImageComponent ID={image.ImageID} />
</td>
<For each={image.Items}>
{(item) => <td>{item.Value}</td>}
</For>
</tr>
)}
</For>
</tbody>
</table>
)}
</Show>
);
return (
<Show when={list()} fallback="List could not be found">
{(l) => (
<div class="w-full h-full bg-white rounded-lg shadow-sm border border-neutral-200 overflow-hidden">
<div class="overflow-x-auto overflow-y-auto h-full">
<table class="w-full min-w-full">
<thead class="bg-neutral-50 border-b border-neutral-200 sticky top-0 z-10">
<tr>
<th class="px-6 py-4 text-left text-sm font-semibold text-neutral-900 border-r border-neutral-200 min-w-40">
Image
</th>
<For each={l().Schema.SchemaItems}>
{(item, index) => (
<th
class={`px-6 py-4 text-left text-sm font-semibold text-neutral-900 min-w-32 ${
index() <
l().Schema.SchemaItems
.length -
1
? "border-r border-neutral-200"
: ""
}`}
>
{item.Item}
</th>
)}
</For>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200">
<For each={l().Images}>
{(image, rowIndex) => (
<tr
class={`hover:bg-neutral-50 transition-colors ${
rowIndex() % 2 === 0
? "bg-white"
: "bg-neutral-25"
}`}
>
<td class="px-6 py-4 border-r border-neutral-200">
<div class="w-32 h-24 overflow-hidden rounded-lg">
<a
href={`/image/${image.ImageID}`}
class="w-full h-full flex justify-center"
>
<img
class="w-full h-full object-cover rounded-lg"
src={`${base}/images/${image.ImageID}`}
alt="List item"
/>
</a>
</div>
</td>
<For each={image.Items}>
{(item, colIndex) => (
<td
class={`px-6 py-4 text-sm text-neutral-700 ${
colIndex() <
image.Items.length -
1
? "border-r border-neutral-200"
: ""
}`}
>
<div
class="max-w-xs truncate"
title={item.Value}
>
{item.Value}
</div>
</td>
)}
</For>
</tr>
)}
</For>
</tbody>
</table>
<Show when={l().Images.length === 0}>
<div class="px-6 py-12 text-center text-neutral-500">
<p class="text-lg">
No images in this list yet
</p>
<p class="text-sm mt-1">
Images will appear here once added to the
list
</p>
</div>
</Show>
</div>
</div>
)}
</Show>
);
};

View File

@ -1,7 +1,7 @@
import { isTokenValid } from "@components/protected-route";
import { Button } from "@kobalte/core/button";
import { TextField } from "@kobalte/core/text-field";
import { postCode, postDemoLogin, postLogin } from "@network/index";
import { postCode, postLogin } from "@network/index";
import { Navigate } from "@solidjs/router";
import { type Component, Show, createSignal } from "solid-js";
@ -18,16 +18,6 @@ export const Login: Component = () => {
throw new Error("bruh, no email");
}
if (email.toString() === "demo@email.com") {
const { access, refresh } = await postDemoLogin();
localStorage.setItem("access", access);
localStorage.setItem("refresh", refresh);
window.location.href = "/";
return;
}
if (!submitted()) {
await postLogin(email.toString());
setSubmitted(true);