feat(app): refactor App component and add Settings page

- Refactored the App component to utilize a new SearchCard component for rendering search results.
- Introduced a Settings page with FolderPicker and Shortcuts components for user configuration.
- Removed the ImagePage component as it was no longer needed.
- Updated routing to include the new Settings page and adjusted imports accordingly.
- Added a settings button to the main interface for easy access to the new settings functionality.
This commit is contained in:
Dmytro Kondakov
2025-04-13 22:48:26 +02:00
parent 43404aaf18
commit ca2e98e4b4
17 changed files with 451 additions and 149 deletions

View File

@ -1,5 +1,6 @@
import { Button } from "@kobalte/core/button";
import { A } from "@solidjs/router";
import { IconSearch } from "@tabler/icons-solidjs";
import { IconSearch, IconSettings } from "@tabler/icons-solidjs";
import { listen } from "@tauri-apps/api/event";
import clsx from "clsx";
import Fuse from "fuse.js";
@ -11,6 +12,7 @@ import {
onCleanup,
} from "solid-js";
import { ImageViewer } from "./components/ImageViewer";
import { SearchCard } from "./components/search-card/SearchCard";
import { SearchCardContact } from "./components/search-card/SearchCardContact";
import { SearchCardEvent } from "./components/search-card/SearchCardEvent";
import { SearchCardLocation } from "./components/search-card/SearchCardLocation";
@ -18,27 +20,6 @@ import { SearchCardNote } from "./components/search-card/SearchCardNote";
import { type UserImage, getUserImages } from "./network";
import { getCardSize } from "./utils/getCardSize";
const getCardComponent = (item: UserImage) => {
switch (item.type) {
case "location":
return <SearchCardLocation item={item} />;
case "event":
return <SearchCardEvent item={item} />;
case "note":
return <SearchCardNote item={item} />;
case "contact":
return <SearchCardContact item={item} />;
// case "Website":
// return <SearchCardWebsite item={item} />;
// case "Note":
// return <SearchCardNote item={item} />;
// case "Receipt":
// return <SearchCardReceipt item={item} />;
default:
return null;
}
};
// How wonderfully functional
const getAllValues = (object: object): Array<string> => {
const loop = (acc: Array<string>, next: object): Array<string> => {
@ -66,7 +47,7 @@ const getAllValues = (object: object): Array<string> => {
return loop([], object);
};
function App() {
export const App = () => {
const [searchResults, setSearchResults] = createSignal<UserImage[]>([]);
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedItem, setSelectedItem] = createSignal<UserImage | null>(
@ -125,11 +106,9 @@ function App() {
return (
<>
<main class="container pt-2">
<A href="login">login</A>
<ImageViewer />
<div class="px-4">
<div class="px-4 flex items-center">
<div class="inline-flex justify-between w-full rounded-xl text-base leading-none outline-none bg-white border border-neutral-200 text-neutral-900">
<div class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-l-md px-2.5 text-gray-900">
<div class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-md px-2.5 text-gray-900">
<IconSearch
size={20}
class="m-auto size-5 text-neutral-600"
@ -145,6 +124,13 @@ function App() {
class="appearance-none inline-flex w-full min-h-[40px] text-base bg-transparent rounded-l-md outline-none placeholder:text-gray-600"
/>
</div>
<Button
as="a"
href="/settings"
class="ml-2 p-2.5 bg-neutral-200 rounded-lg"
>
<IconSettings size={20} />
</Button>
</div>
<div class="px-4 mt-4 bg-white rounded-t-2xl">
@ -162,33 +148,21 @@ function App() {
setSelectedItem(item);
}
}}
class={clsx(
"h-[144px] border relative border-neutral-200 cursor-pointer overflow-hidden rounded-xl",
{
"col-span-3":
getCardSize(
item.type,
) === "1/1",
"col-span-6":
getCardSize(
item.type,
) === "2/1",
},
)}
class="h-[144px] border relative col-span-3 border-neutral-200 cursor-pointer overflow-hidden rounded-xl"
>
<span class="sr-only">
{item.data.Name}
</span>
{getCardComponent(item)}
<SearchCard item={item} />
</div>
)}
</For>
</div>
) : searchQuery() !== "" ? (
) : (
<div class="text-center text-lg m-auto text-neutral-700">
No results found
</div>
) : null}
)}
</div>
</div>
@ -198,6 +172,4 @@ function App() {
</main>
</>
);
}
export default App;
};

View File

@ -1,67 +0,0 @@
import { A, useParams } from "@solidjs/router";
import { createEffect, createResource, For, Suspense } from "solid-js";
import { getUserImages } from "./network";
export function ImagePage() {
const { imageId } = useParams<{ imageId: string }>();
const [image] = createResource(async () => {
const userImages = await getUserImages();
const currentImage = userImages.find((image) => image.ID === imageId);
if (currentImage == null) {
// TODO: this error handling.
throw new Error("must be valid");
}
return currentImage;
});
createEffect(() => {
console.log(image());
});
return (
<Suspense fallback={<>Loading...</>}>
<A href="/">Back</A>
<h1 class="text-2xl font-bold">{image()?.Image.ImageName}</h1>
<img
src={`http://localhost:3040/image/${image()?.ID}`}
alt="link"
/>
<div class="flex flex-col">
<h2 class="text-xl font-bold">Tags</h2>
<For each={image()?.Tags ?? []}>
{(tag) => <div>{tag.Tag.Tag}</div>}
</For>
<h2 class="text-xl font-bold">Locations</h2>
<For each={image()?.Locations ?? []}>
{(location) => (
<ul>
<li>{location.Name}</li>
{location.Address && <li>{location.Address}</li>}
{location.Coordinates && (
<li>{location.Coordinates}</li>
)}
{location.Description && (
<li>{location.Description}</li>
)}
</ul>
)}
</For>
<h2 class="text-xl font-bold">Events</h2>
<For each={image()?.Events ?? []}>
{(event) => (
<ul>
<li>{event.Name}</li>
{event.Location && <li>{event.Location.Name}</li>}
{event.Description && <li>{event.Description}</li>}
</ul>
)}
</For>
</div>
</Suspense>
);
}

22
frontend/src/Settings.tsx Normal file
View File

@ -0,0 +1,22 @@
import { Button } from "@kobalte/core/button";
import { FolderPicker } from "./components/FolderPicker";
import { Shortcuts } from "./components/shortcuts/Shortcuts";
export const Settings = () => {
return (
<>
<main class="container pt-2">
<div class="flex px-4 gap-2 items-center">
<Button as="a" href="/">
Back to home
</Button>
<h1 class="text-2xl font-bold">Settings</h1>
</div>
<div class="flex flex-col px-4 gap-2">
<FolderPicker />
<Shortcuts />
</div>
</main>
</>
);
};

View File

@ -1,10 +1,9 @@
import { createSignal } from "solid-js";
import { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { createSignal } from "solid-js";
export function FolderPicker() {
const [selectedPath, setSelectedPath] = createSignal<string>("");
const [status, setStatus] = createSignal<string>("");
const handleFolderSelect = async () => {
try {
@ -19,10 +18,11 @@ export function FolderPicker() {
const response = await invoke("handle_selected_folder", {
path: selected,
});
setStatus(`Folder processed: ${response}`);
console.log("DBG: ", response);
}
} catch (error) {
setStatus(`Error: ${error}`);
console.error("DBG: ", error);
}
};
@ -42,8 +42,6 @@ export function FolderPicker() {
<p class="text-sm break-all">{selectedPath()}</p>
</div>
)}
{status() && <p class="text-sm text-gray-600">{status()}</p>}
</div>
);
}

View File

@ -0,0 +1,22 @@
import type { UserImage } from "../../network";
import { SearchCardContact } from "./SearchCardContact";
import { SearchCardEvent } from "./SearchCardEvent";
import { SearchCardLocation } from "./SearchCardLocation";
import { SearchCardNote } from "./SearchCardNote";
export const SearchCard = (props: { item: UserImage }) => {
const { item } = props;
switch (item.type) {
case "location":
return <SearchCardLocation item={item} />;
case "event":
return <SearchCardEvent item={item} />;
case "note":
return <SearchCardNote item={item} />;
case "contact":
return <SearchCardContact item={item} />;
default:
return null;
}
};

View File

@ -19,10 +19,6 @@ export const SearchCardContact = ({ item }: Props) => {
<p class="text-xs text-neutral-500">{data.PhoneNumber}</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500">{data.Email}</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Description}
</p>
</div>
);
};

View File

@ -1,5 +1,3 @@
import { Separator } from "@kobalte/core/separator";
import { IconCalendar } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network";
@ -18,15 +16,13 @@ export const SearchCardEvent = ({ item }: Props) => {
</div>
<p class="text-xs text-neutral-500">
Organized by {data.Organizer?.Name ?? "unknown"} on{" "}
{new Date(data.StartDateTime).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Description}
{data.StartDateTime
? new Date(data.StartDateTime).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
: "unknown date"}
</p>
</div>
);

View File

@ -1,5 +1,3 @@
import { Separator } from "@kobalte/core/separator";
import { IconMapPin } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network";
@ -17,10 +15,6 @@ export const SearchCardLocation = ({ item }: Props) => {
<IconMapPin size={20} class="text-neutral-500 mt-1" />
</div>
<p class="text-xs text-neutral-500">{data.Address}</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Description}
</p>
</div>
);
};

View File

@ -16,11 +16,7 @@ export const SearchCardNote = ({ item }: Props) => {
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
<IconNote size={20} class="text-neutral-500 mt-1" />
</div>
<p class="text-xs text-neutral-500">Keywords TODO</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Content}
</p>
<p class="text-xs text-neutral-500">Note</p>
</div>
);
};

View File

@ -0,0 +1,78 @@
import { IconX } from "@tabler/icons-solidjs";
import { type Component, For } from "solid-js";
import { formatKey } from "./utils/formatKey";
import { sortKeys } from "./utils/sortKeys";
interface ShortcutItemProps {
shortcut: string[];
isEditing: boolean;
currentKeys: string[];
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
}
export const ShortcutItem: Component<ShortcutItemProps> = (props) => {
const renderKeys = (keys: string[]) => {
const sortedKeys = sortKeys(keys);
return (
<For each={sortedKeys}>
{(key) => (
<kbd class="px-2 py-1 text-sm font-semibold rounded shadow-sm bg-neutral-100 border-neutral-300 text-neutral-900 ">
{formatKey(key)}
</kbd>
)}
</For>
);
};
return (
<div class="flex items-center justify-between p-4 rounded-lg bg-neutral-5">
<div class="flex items-center gap-4">
{props.isEditing ? (
<>
<div class="flex gap-1 min-w-[120px] justify-end">
{props.currentKeys.length > 0 ? (
renderKeys(props.currentKeys)
) : (
<span class="italic text-neutral-500">
Press keys...
</span>
)}
</div>
<div class="flex gap-2">
<button
type="button"
onClick={props.onSave}
disabled={props.currentKeys.length < 2}
class="px-3 py-1 text-sm rounded bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save
</button>
<button
type="button"
onClick={props.onCancel}
class="p-1 rounded text-neutral-500 hover:text-neutral-700 hover:bg-neutral-200 "
>
<IconX class="w-4 h-4" />
</button>
</div>
</>
) : (
<>
<div class="flex gap-1">
{renderKeys(props.shortcut)}
</div>
<button
type="button"
onClick={props.onEdit}
class="px-3 py-1 text-sm rounded bg-neutral-200 text-neutral-700 hover:bg-neutral-300 "
>
Edit
</button>
</>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,70 @@
import { invoke } from "@tauri-apps/api/core";
import { createSignal, onMount } from "solid-js";
import { ShortcutItem } from "./ShortcutItem";
import { type Shortcut, useShortcutEditor } from "./hooks/useShortcutEditor";
export const Shortcuts = () => {
const [shortcut, setShortcut] = createSignal<Shortcut>([]);
async function getCurrentShortcut() {
try {
const res: string = await invoke("get_current_shortcut");
console.log("DBG: ", res);
setShortcut(res?.split("+"));
} catch (err) {
console.error("Failed to fetch shortcut:", err);
}
}
onMount(() => {
getCurrentShortcut();
});
const changeShortcut = (key: Shortcut) => {
setShortcut(key);
if (key.length === 0) return;
invoke("change_shortcut", { key: key?.join("+") }).catch((err) => {
console.error("Failed to save hotkey:", err);
});
};
const {
isEditing,
currentKeys,
startEditing,
saveShortcut,
cancelEditing,
} = useShortcutEditor(shortcut(), changeShortcut);
const onEditShortcut = async () => {
startEditing();
invoke("unregister_shortcut").catch((err) => {
console.error("Failed to save hotkey:", err);
});
};
const onCancelShortcut = async () => {
cancelEditing();
invoke("change_shortcut", { key: shortcut()?.join("+") }).catch(
(err) => {
console.error("Failed to save hotkey:", err);
},
);
};
const onSaveShortcut = async () => {
saveShortcut();
};
return (
<ShortcutItem
shortcut={shortcut()}
isEditing={isEditing()}
currentKeys={currentKeys()}
onEdit={onEditShortcut}
onSave={onSaveShortcut}
onCancel={onCancelShortcut}
/>
);
};

View File

@ -0,0 +1,134 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
import { isModifierKey } from "../utils/isModifierKey";
import { normalizeKey } from "../utils/normalizeKey";
import { sortKeys } from "../utils/sortKeys";
export type Shortcut = string[];
const RESERVED_SHORTCUTS = [
["Command", "C"],
["Command", "V"],
["Command", "X"],
["Command", "A"],
["Command", "Z"],
["Command", "Q"],
// Windows/Linux
["Control", "C"],
["Control", "V"],
["Control", "X"],
["Control", "A"],
["Control", "Z"],
];
export function useShortcutEditor(
shortcut: Shortcut,
onChange: (shortcut: Shortcut) => void,
) {
console.log("DBG: ", shortcut);
const [isEditing, setIsEditing] = createSignal(false);
const [currentKeys, setCurrentKeys] = createSignal<string[]>([]);
const pressedKeys = new Set<string>();
const startEditing = () => {
setIsEditing(true);
setCurrentKeys([]);
};
const saveShortcut = async () => {
if (!isEditing() || currentKeys().length < 2) return;
const hasModifier = currentKeys().some(isModifierKey);
const hasNonModifier = currentKeys().some((key) => !isModifierKey(key));
if (!hasModifier || !hasNonModifier) return;
const isReserved = RESERVED_SHORTCUTS.some(
(reserved) =>
reserved.length === currentKeys().length &&
reserved.every(
(key, index) =>
key.toLowerCase() ===
currentKeys()[index].toLowerCase(),
),
);
if (isReserved) {
console.error("This is a system reserved shortcut");
return;
}
// Sort keys to ensure consistent order (modifiers first)
const sortedKeys = sortKeys(currentKeys());
onChange(sortedKeys);
setIsEditing(false);
setCurrentKeys([]);
};
const cancelEditing = () => {
setIsEditing(false);
setCurrentKeys([]);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isEditing()) return;
e.preventDefault();
e.stopPropagation();
const key = normalizeKey(e.code);
// Update pressed keys
pressedKeys.add(key);
setCurrentKeys(() => {
const keys = Array.from(pressedKeys);
let modifiers = keys.filter(isModifierKey);
let nonModifiers = keys.filter((k) => !isModifierKey(k));
if (modifiers.length > 2) {
modifiers = modifiers.slice(0, 2);
}
if (nonModifiers.length > 2) {
nonModifiers = nonModifiers.slice(0, 2);
}
// Combine modifiers and non-modifiers
return [...modifiers, ...nonModifiers];
});
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!isEditing()) return;
const key = normalizeKey(e.code);
pressedKeys.delete(key);
};
// Use createEffect to handle event listeners based on editing state
createEffect(() => {
if (isEditing()) {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
} else {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
pressedKeys.clear();
}
});
// Clean up event listeners on unmount
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
});
return {
isEditing,
currentKeys,
startEditing,
saveShortcut,
cancelEditing,
};
}

View File

@ -0,0 +1,38 @@
export const formatKey = (key: string): string => {
// Convert to uppercase for consistency
const upperKey = key.toUpperCase();
// Handle special keys
switch (upperKey) {
case "CONTROL":
return "Ctrl";
case "META":
return "⌘";
case "ALT":
return "⌥";
case "SHIFT":
return "⇧";
case "ARROWUP":
return "↑";
case "ARROWDOWN":
return "↓";
case "ARROWLEFT":
return "←";
case "ARROWRIGHT":
return "→";
case "ESCAPE":
return "Esc";
case "ENTER":
return "Enter";
case "BACKSPACE":
return "⌫";
case "DELETE":
return "⌦";
case "TAB":
return "⇥";
case "CAPSLOCK":
return "⇪";
default:
return upperKey;
}
};

View File

@ -0,0 +1,4 @@
export const isModifierKey = (key: string): boolean => {
const modifiers = ["Control", "Shift", "Alt", "Meta", "Command"];
return modifiers.includes(key);
};

View File

@ -0,0 +1,33 @@
export const normalizeKey = (code: string): string => {
// Remove 'Key' prefix from letter keys
if (code.startsWith("Key")) {
return code.slice(3);
}
// Remove 'Digit' prefix from number keys
if (code.startsWith("Digit")) {
return code.slice(5);
}
// Handle special keys
const specialKeys: Record<string, string> = {
ControlLeft: "Control",
ControlRight: "Control",
ShiftLeft: "Shift",
ShiftRight: "Shift",
AltLeft: "Alt",
AltRight: "Alt",
MetaLeft: "Command",
MetaRight: "Command",
ArrowLeft: "ArrowLeft",
ArrowRight: "ArrowRight",
ArrowUp: "ArrowUp",
ArrowDown: "ArrowDown",
Enter: "Enter",
Space: "Space",
Escape: "Escape",
Backspace: "Backspace",
Delete: "Delete",
Tab: "Tab",
CapsLock: "CapsLock",
};
return specialKeys[code] || code;
};

View File

@ -0,0 +1,14 @@
export const sortKeys = (keys: string[]): string[] => {
const priority: { [key: string]: number } = {
Control: 1,
Meta: 2,
Alt: 3,
Shift: 4,
};
return [...keys].sort((a, b) => {
const aPriority = priority[a] || 999;
const bPriority = priority[b] || 999;
return aPriority - bPriority;
});
};

View File

@ -1,11 +1,13 @@
/* @refresh reload */
import { render } from "solid-js/web";
import App from "./App";
import "./index.css";
import { Route, Router } from "@solidjs/router";
import { ImagePage } from "./ImagePage";
import { App } from "./App";
import { Login } from "./Login";
import { ProtectedRoute } from "./ProtectedRoute";
import { Settings } from "./Settings";
render(
() => (
@ -14,7 +16,7 @@ render(
<Route path="/" component={ProtectedRoute}>
<Route path="/" component={App} />
<Route path="/image/:imageId" component={ImagePage} />
<Route path="/settings" component={Settings} />
</Route>
</Router>
),