feat: allowing user to get a list of their images

feat: UI to show and organise user images
This commit is contained in:
2025-05-05 15:38:23 +01:00
parent 9c325c7799
commit 07b83aa728
8 changed files with 199 additions and 80 deletions

View File

@ -70,6 +70,42 @@ func main() {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
// Temporarily not in protect route because we aren't using cookies.
// Therefore they don't get automatically attached to the request.
// So <img src=""> cannot send the tokensend the token
r.Get("/image/{id}", func(w http.ResponseWriter, r *http.Request) {
stringImageId := r.PathValue("id")
// userId := r.Context().Value(USER_ID).(uuid.UUID)
imageId, err := uuid.Parse(stringImageId)
if err != nil {
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, "You cannot read this")
return
}
// if authorized := imageModel.IsUserAuthorized(r.Context(), imageId, userId); !authorized {
// w.WriteHeader(http.StatusForbidden)
// fmt.Fprintf(w, "You cannot read this")
// return
// }
image, err := imageModel.Get(r.Context(), imageId)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Could not get image")
return
}
// TODO: this could be part of the db table
extension := filepath.Ext(image.ImageName)
extension = extension[1:]
w.Header().Add("Content-Type", "image/"+extension)
w.Write(image.Image)
})
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(ProtectedRoute) r.Use(ProtectedRoute)
@ -81,7 +117,7 @@ func main() {
return return
} }
images, err := userModel.ListWithProperties(r.Context(), userId) imageProperties, err := userModel.ListWithProperties(r.Context(), userId)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
@ -89,7 +125,25 @@ func main() {
return return
} }
jsonImages, err := json.Marshal(models.GetTypedImageProperties(images)) images, err := userModel.GetUserImages(r.Context(), userId)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Something went wrong")
return
}
type ImagesReturn struct {
UserImages []models.UserImageWithImage
ImageProperties []models.TypedProperties
}
imagesReturn := ImagesReturn{
UserImages: images,
ImageProperties: models.GetTypedImageProperties(imageProperties),
}
jsonImages, err := json.Marshal(imagesReturn)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@ -130,40 +184,6 @@ func main() {
w.Write(jsonImages) w.Write(jsonImages)
}) })
r.Get("/image/{id}", func(w http.ResponseWriter, r *http.Request) {
stringImageId := r.PathValue("id")
userId := r.Context().Value(USER_ID).(uuid.UUID)
imageId, err := uuid.Parse(stringImageId)
if err != nil {
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, "You cannot read this")
return
}
if authorized := imageModel.IsUserAuthorized(r.Context(), imageId, userId); !authorized {
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, "You cannot read this")
return
}
// TODO: really need authorization here!
image, err := imageModel.Get(r.Context(), imageId)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Could not get image")
return
}
// TODO: this could be part of the db table
extension := filepath.Ext(image.Image.ImageName)
extension = extension[1:]
w.Header().Add("Content-Type", "image/"+extension)
w.Write(image.Image.Image)
})
r.Post("/image/{name}", func(w http.ResponseWriter, r *http.Request) { r.Post("/image/{name}", func(w http.ResponseWriter, r *http.Request) {
imageName := r.PathValue("name") imageName := r.PathValue("name")
userId := r.Context().Value(USER_ID).(uuid.UUID) userId := r.Context().Value(USER_ID).(uuid.UUID)

View File

@ -154,21 +154,14 @@ func (m ImageModel) StartProcessing(ctx context.Context, processingImageId uuid.
return err return err
} }
func (m ImageModel) Get(ctx context.Context, imageId uuid.UUID) (ImageData, error) { func (m ImageModel) Get(ctx context.Context, imageId uuid.UUID) (model.Image, error) {
getImageStmt := SELECT(UserImages.AllColumns, Image.AllColumns). getImageStmt := Image.SELECT(Image.AllColumns).
FROM( WHERE(Image.ID.EQ(UUID(imageId)))
UserImages.INNER_JOIN(Image, Image.ID.EQ(UserImages.ImageID)),
).
WHERE(UserImages.ID.EQ(UUID(imageId)))
images := []ImageData{} image := model.Image{}
err := getImageStmt.QueryContext(ctx, m.dbPool, &images) err := getImageStmt.QueryContext(ctx, m.dbPool, &image)
if len(images) != 1 { return image, err
return ImageData{}, errors.New(fmt.Sprintf("Expected 1, got %d\n", len(images)))
}
return images[0], err
} }
func (m ImageModel) IsUserAuthorized(ctx context.Context, imageId uuid.UUID, userId uuid.UUID) bool { func (m ImageModel) IsUserAuthorized(ctx context.Context, imageId uuid.UUID, userId uuid.UUID) bool {

View File

@ -295,6 +295,27 @@ func (m UserModel) Save(ctx context.Context, user model.Users) (model.Users, err
return insertedUser, err return insertedUser, err
} }
type UserImageWithImage struct {
model.UserImages
Image model.Image
}
func (m UserModel) GetUserImages(ctx context.Context, userId uuid.UUID) ([]UserImageWithImage, error) {
getUserImagesStmt := SELECT(
UserImages.AllColumns,
Image.ID,
Image.ImageName,
).
FROM(UserImages.INNER_JOIN(Image, Image.ID.EQ(UserImages.ImageID))).
WHERE(UserImages.UserID.EQ(UUID(userId)))
userImages := []UserImageWithImage{}
err := getUserImagesStmt.QueryContext(ctx, m.dbPool, &userImages)
return userImages, err
}
func NewUserModel(db *sql.DB) UserModel { func NewUserModel(db *sql.DB) UserModel {
return UserModel{dbPool: db} return UserModel{dbPool: db}
} }

View File

@ -3,7 +3,13 @@ import type { PluginListener } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { readFile } from "@tauri-apps/plugin-fs"; import { readFile } from "@tauri-apps/plugin-fs";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import { createEffect, createSignal, onCleanup } from "solid-js"; import {
type Component,
type ParentProps,
createEffect,
createSignal,
onCleanup,
} from "solid-js";
import { import {
type ShareEvent, type ShareEvent,
listenForShareEvents, listenForShareEvents,
@ -20,6 +26,10 @@ import { type sendImage, sendImageFile } from "./network";
const currentPlatform = platform(); const currentPlatform = platform();
console.log("Current Platform: ", currentPlatform); console.log("Current Platform: ", currentPlatform);
const AppWrapper: Component<ParentProps> = ({ children }) => {
return <div class="flex w-full justify-center h-screen">{children}</div>;
};
export const App = () => { export const App = () => {
createEffect(() => { createEffect(() => {
// TODO: Don't use window.location.href // TODO: Don't use window.location.href
@ -79,12 +89,14 @@ export const App = () => {
onSetProcessingImage={setProcessingImage} onSetProcessingImage={setProcessingImage}
/> />
<Router> <Router>
<Route path="/" component={AppWrapper}>
<Route path="/login" component={Login} /> <Route path="/login" component={Login} />
<Route path="/" component={ProtectedRoute}> <Route path="/" component={ProtectedRoute}>
<Route path="/" component={Search} /> <Route path="/" component={Search} />
<Route path="/settings" component={Settings} /> <Route path="/settings" component={Settings} />
</Route> </Route>
</Route>
</Router> </Router>
</SearchImageContextProvider> </SearchImageContextProvider>
); );

View File

@ -14,7 +14,7 @@ import { SearchCard } from "./components/search-card/SearchCard";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { ItemModal } from "./components/item-modal/ItemModal"; import { ItemModal } from "./components/item-modal/ItemModal";
import type { Shortcut } from "./components/shortcuts/hooks/useShortcutEditor"; import type { Shortcut } from "./components/shortcuts/hooks/useShortcutEditor";
import type { UserImage } from "./network"; import { base, type UserImage } from "./network";
import { useSearchImageContext } from "./contexts/SearchImageContext"; import { useSearchImageContext } from "./contexts/SearchImageContext";
export const Search = () => { export const Search = () => {
@ -24,9 +24,10 @@ export const Search = () => {
null, null,
); );
const { images, onRefetchImages } = useSearchImageContext(); const { images, imagesWithProperties, onRefetchImages } =
useSearchImageContext();
let fuze = new Fuse<UserImage>(images() ?? [], { let fuze = new Fuse<UserImage>(images(), {
keys: [ keys: [
{ name: "rawData", weight: 1 }, { name: "rawData", weight: 1 },
{ name: "title", weight: 1 }, { name: "title", weight: 1 },
@ -35,11 +36,9 @@ export const Search = () => {
}); });
createEffect(() => { createEffect(() => {
console.log("DBG: ", images()); setSearchResults(images());
setSearchResults(images() ?? []);
console.log(images());
fuze = new Fuse<UserImage>(images() ?? [], { fuze = new Fuse<UserImage>(images(), {
keys: [ keys: [
{ name: "data.Name", weight: 2 }, { name: "data.Name", weight: 2 },
{ name: "rawData", weight: 1 }, { name: "rawData", weight: 1 },
@ -50,10 +49,9 @@ export const Search = () => {
const onInputChange = (event: InputEvent) => { const onInputChange = (event: InputEvent) => {
const query = (event.target as HTMLInputElement).value; const query = (event.target as HTMLInputElement).value;
console.log(query);
if (query.length === 0) { if (query.length === 0) {
setSearchResults(images() ?? []); setSearchResults(images());
} else { } else {
setSearchQuery(query); setSearchQuery(query);
setSearchResults(fuze.search(query).map((s) => s.item)); setSearchResults(fuze.search(query).map((s) => s.item));
@ -134,7 +132,7 @@ export const Search = () => {
</div> </div>
<div class="px-4 mt-4 bg-white rounded-t-2xl"> <div class="px-4 mt-4 bg-white rounded-t-2xl">
<div class="h-[254px] mt-4 overflow-scroll scrollbar-hide"> <div class="mt-4 overflow-scroll scrollbar-hide">
<Show <Show
when={searchResults().length > 0} when={searchResults().length > 0}
fallback={ fallback={
@ -169,6 +167,19 @@ export const Search = () => {
</div> </div>
</div> </div>
<div class="w-full grid grid-cols-9 gap-2 grid-flow-row-dense py-4">
<For each={Object.keys(imagesWithProperties())}>
{(imageId) => (
<div>
<img
alt="One of the users images"
src={`${base}/image/${imageId}`}
/>
</div>
)}
</For>
</div>
<div class="w-full border-t h-10 bg-white px-4 flex items-center border-neutral-100"> <div class="w-full border-t h-10 bg-white px-4 flex items-center border-neutral-100">
<p class="text-sm text-neutral-700"> <p class="text-sm text-neutral-700">
Use{" "} Use{" "}

View File

@ -1,19 +1,25 @@
import { import {
createContext, type Accessor,
type Resource,
type Component, type Component,
type ParentProps, type ParentProps,
createContext,
createMemo,
createResource, createResource,
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { getUserImages } from "../network"; import { getUserImages } from "../network";
import { groupPropertiesWithImage } from "../utils/groupPropertiesWithImage";
type ImageWithRawData = Awaited<ReturnType<typeof getUserImages>>[number] & { export type ImageWithRawData = Awaited<
ReturnType<typeof getUserImages>
>["ImageProperties"][number] & {
rawData: string[]; rawData: string[];
}; };
type SearchImageStore = { type SearchImageStore = {
images: Resource<ImageWithRawData[]>; images: Accessor<ImageWithRawData[]>;
imagesWithProperties: Accessor<ReturnType<typeof groupPropertiesWithImage>>;
onRefetchImages: () => void; onRefetchImages: () => void;
}; };
@ -50,19 +56,36 @@ const getAllValues = (object: object): Array<string> => {
const SearchImageContext = createContext<SearchImageStore>(); const SearchImageContext = createContext<SearchImageStore>();
export const SearchImageContextProvider: Component<ParentProps> = (props) => { export const SearchImageContextProvider: Component<ParentProps> = (props) => {
const [images, { refetch }] = createResource(() => const [data, { refetch }] = createResource(getUserImages);
getUserImages().then((data) => {
return data.map((d) => ({ const imageData = createMemo<ImageWithRawData[]>(() => {
const d = data();
if (d == null) {
return [];
}
return d.ImageProperties.map((d) => ({
...d, ...d,
rawData: getAllValues(d), rawData: getAllValues(d),
})); }));
}), });
);
const imagesWithProperties = createMemo<
ReturnType<typeof groupPropertiesWithImage>
>(() => {
const d = data();
if (d == null) {
return {};
}
return groupPropertiesWithImage(d);
});
return ( return (
<SearchImageContext.Provider <SearchImageContext.Provider
value={{ value={{
images, images: imageData,
imagesWithProperties: imagesWithProperties,
onRefetchImages: refetch, onRefetchImages: refetch,
}} }}
> >

View File

@ -4,6 +4,7 @@ import {
type InferOutput, type InferOutput,
array, array,
literal, literal,
null_,
nullable, nullable,
parse, parse,
pipe, pipe,
@ -84,6 +85,7 @@ export const sendImage = async (
const locationValidator = strictObject({ const locationValidator = strictObject({
ID: pipe(string(), uuid()), ID: pipe(string(), uuid()),
CreatedAt: pipe(string()),
Name: string(), Name: string(),
Address: nullable(string()), Address: nullable(string()),
Description: nullable(string()), Description: nullable(string()),
@ -92,6 +94,7 @@ const locationValidator = strictObject({
const contactValidator = strictObject({ const contactValidator = strictObject({
ID: pipe(string(), uuid()), ID: pipe(string(), uuid()),
CreatedAt: pipe(string()),
Name: string(), Name: string(),
Description: nullable(string()), Description: nullable(string()),
PhoneNumber: nullable(string()), PhoneNumber: nullable(string()),
@ -101,6 +104,7 @@ const contactValidator = strictObject({
const eventValidator = strictObject({ const eventValidator = strictObject({
ID: pipe(string(), uuid()), ID: pipe(string(), uuid()),
CreatedAt: pipe(string()),
Name: string(), Name: string(),
StartDateTime: nullable(pipe(string())), StartDateTime: nullable(pipe(string())),
EndDateTime: nullable(pipe(string())), EndDateTime: nullable(pipe(string())),
@ -114,6 +118,7 @@ const eventValidator = strictObject({
const noteValidator = strictObject({ const noteValidator = strictObject({
ID: pipe(string(), uuid()), ID: pipe(string(), uuid()),
CreatedAt: pipe(string()),
Name: string(), Name: string(),
Description: nullable(string()), Description: nullable(string()),
Content: string(), Content: string(),
@ -146,18 +151,36 @@ const dataTypeValidator = variant("type", [
noteDataType, noteDataType,
contactDataType, contactDataType,
]); ]);
const getUserImagesResponseValidator = array(dataTypeValidator);
const userImageValidator = strictObject({
ID: pipe(string(), uuid()),
CreatedAt: pipe(string()),
ImageID: pipe(string(), uuid()),
UserID: pipe(string(), uuid()),
Image: strictObject({
ID: pipe(string(), uuid()),
ImageName: string(),
Image: null_(),
}),
});
export type UserImage = InferOutput<typeof dataTypeValidator>; export type UserImage = InferOutput<typeof dataTypeValidator>;
export const getUserImages = async (): Promise<UserImage[]> => { const imageRequestValidator = strictObject({
UserImages: array(userImageValidator),
ImageProperties: array(dataTypeValidator),
});
export const getUserImages = async (): Promise<
InferOutput<typeof imageRequestValidator>
> => {
const request = getBaseAuthorizedRequest({ path: "image" }); const request = getBaseAuthorizedRequest({ path: "image" });
const res = await fetch(request).then((res) => res.json()); const res = await fetch(request).then((res) => res.json());
console.log("BACKEND RESPONSE: ", res); console.log("BACKEND RESPONSE: ", res);
return parse(getUserImagesResponseValidator, res); return parse(imageRequestValidator, res);
}; };
export const getImage = async (imageId: string): Promise<UserImage> => { export const getImage = async (imageId: string): Promise<UserImage> => {

View File

@ -0,0 +1,16 @@
import type { getUserImages } from "../network";
export const groupPropertiesWithImage = ({
UserImages,
ImageProperties,
}: Awaited<ReturnType<typeof getUserImages>>) => {
const imageToProperties: Record<string, typeof ImageProperties> = {};
for (const image of UserImages) {
imageToProperties[image.ImageID] = ImageProperties.filter((i) =>
i.data.Images.includes(image.ImageID),
);
}
return imageToProperties;
};