Feature/ implement basic search function #40

Merged
kasun merged 30 commits from feature/Implement-basic-search-function into main 2026-06-25 17:58:40 +02:00
9 changed files with 175 additions and 53 deletions
Showing only changes of commit b7bc8f5525 - Show all commits
@@ -15,11 +15,17 @@ import com.vaessl.app.connection.HomeboxEntity;
import com.vaessl.app.exception.ConnectionNotFoundException; import com.vaessl.app.exception.ConnectionNotFoundException;
import com.vaessl.app.exception.RemoteApiException; import com.vaessl.app.exception.RemoteApiException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static com.vaessl.app.connection.Endpoint.*; import static com.vaessl.app.connection.Endpoint.*;
@Component @Component
public class HomeboxSearchProvider implements SearchProvider { public class HomeboxSearchProvider implements SearchProvider {
// TODO: Remove logger before merge
private static final Logger log = LoggerFactory.getLogger(HomeboxSearchProvider.class);
private final RestClient.Builder restClientBuilder; private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository; private final ConnectionRepository cRepository;
@@ -64,6 +70,8 @@ public class HomeboxSearchProvider implements SearchProvider {
return new SearchResponse(title, description, extraSearchResponseData); return new SearchResponse(title, description, extraSearchResponseData);
}).toList(); }).toList();
log.info("Search results for query '{}': {}", request.query(), items);
return new PageImpl<>(items, pageable, hbResponse.total()); return new PageImpl<>(items, pageable, hbResponse.total());
} }
+9
View File
@@ -0,0 +1,9 @@
import { apiFetch } from "./client";
import type { AuthResponse } from "../types/connection";
import type { SearchRequest } from "../types/search";
export const search = (req: SearchRequest) =>
apiFetch<AuthResponse>("/search", {
method: "POST",
body: JSON.stringify(req),
});
+35
View File
@@ -0,0 +1,35 @@
.action-btn {
font-size: 14px;
font-weight: 500;
padding: 7px 16px;
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
transition: box-shadow 0.2s, opacity 0.2s;
&:hover {
opacity: 0.85;
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
&--connect,
&--search {
background: var(--accent);
color: #fff;
}
&--disconnect {
background: transparent;
color: var(--text);
border-color: var(--border);
&:hover {
border-color: #ef4444;
color: #ef4444;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import './ActionButton.scss'
type Variant = 'connect' | 'disconnect' | 'search'
interface Props {
variant: Variant
onClick: () => void
children: React.ReactNode
}
export function ActionButton({ variant, onClick, children }: Readonly<Props>) {
return (
<button className={`action-btn action-btn--${variant}`} onClick={onClick}>
{children}
</button>
)
}
+24 -8
View File
@@ -4,6 +4,7 @@ import type { ConnectionStatus } from "../types/connection"
import { ServiceCard } from "./ServiceCard" import { ServiceCard } from "./ServiceCard"
import { ConnectModal } from "./ConnectModal" import { ConnectModal } from "./ConnectModal"
import "./Dashboard.scss" import "./Dashboard.scss"
import { SearchModal } from "./SearchModal"
const SERVICES = [ const SERVICES = [
{ serviceType: 'HOMEBOX', label: 'Homebox', icon: '📦' }, { serviceType: 'HOMEBOX', label: 'Homebox', icon: '📦' },
@@ -11,7 +12,8 @@ const SERVICES = [
export function Dashboard() { export function Dashboard() {
const [statuses, setStatuses] = useState<ConnectionStatus[]>([]) const [statuses, setStatuses] = useState<ConnectionStatus[]>([])
const [openModal, setOpenModal] = useState<string | null>(null) const [openConnectionModal, setOpenConnectionModal] = useState<string | null>(null)
const [openSearchModal, setOpenSearchModal] = useState<string | null>(null)
const refresh = () => { const refresh = () => {
getStatuses().then(setStatuses).catch(() => { }) getStatuses().then(setStatuses).catch(() => { })
@@ -23,7 +25,9 @@ export function Dashboard() {
logout(serviceType).then(refresh).catch(() => { }) logout(serviceType).then(refresh).catch(() => { })
} }
const activeModal = SERVICES.find(s => s.serviceType === openModal) const activeConnectionModal = SERVICES.find(s => s.serviceType === openConnectionModal)
const activeSearchModal = SERVICES.find(s => s.serviceType === openSearchModal)
const activeSearchStatus = statuses.find(s => s.serviceType === openSearchModal)
return ( return (
<div className="dashboard"> <div className="dashboard">
@@ -40,18 +44,30 @@ export function Dashboard() {
label={label} label={label}
icon={icon} icon={icon}
status={statuses.find(s => s.serviceType === serviceType) ?? null} status={statuses.find(s => s.serviceType === serviceType) ?? null}
onConnect={() => setOpenModal(serviceType)} onConnect={() => setOpenConnectionModal(serviceType)}
onDisconnect={() => handleDisconnect(serviceType)} onDisconnect={() => handleDisconnect(serviceType)}
onSearch={() => setOpenSearchModal(serviceType)}
/> />
))} ))}
</div> </div>
{activeModal && ( {activeConnectionModal && (
<ConnectModal <ConnectModal
serviceType={activeModal.serviceType} serviceType={activeConnectionModal.serviceType}
label={activeModal.label} label={activeConnectionModal.label}
onClose={() => setOpenModal(null)} onClose={() => setOpenSearchModal(null)}
onSuccess={() => { setOpenModal(null); refresh() }} onSuccess={() => { setOpenSearchModal(null); refresh() }}
/>
)}
{activeSearchModal && (
<SearchModal
serviceType={activeSearchModal.serviceType}
label={activeSearchModal.label}
appUrl={activeSearchStatus?.appUrl ?? ''}
username={activeSearchStatus?.username ?? ''}
onClose={() => setOpenSearchModal(null) }
/> />
)} )}
</div> </div>
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
import './ConnectModal.scss'
import type { SearchRequest } from '../types/search'
import { search } from '../api/searches'
interface Props {
serviceType: string
label: string
appUrl: string
username: string
onClose: () => void
}
export function SearchModal({ serviceType, label, appUrl, username, onClose }: Readonly<Props>) {
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const firstInputRef = useRef<HTMLInputElement>(null)
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
dialog.showModal()
firstInputRef.current?.focus()
const handleCancel = (e: Event) => {
e.preventDefault()
onClose()
}
dialog.addEventListener('cancel', handleCancel)
return () => dialog.removeEventListener('cancel', handleCancel)
}, [onClose])
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
const req: SearchRequest = { appUrl, serviceType, username, query}
await search(req)
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed')
} finally {
setLoading(false)
}
}
return (
<dialog className="modal" ref={dialogRef}>
<div className="modal__header">
<h2 className="modal__title" id="modal-title">Search in {label}</h2>
<button className="modal__close" onClick={onClose} aria-label="Close">×</button>
</div>
<form className="modal__form" onSubmit={handleSubmit}>
<div className="modal__field">
<input id="search" className="modal__input"
value={query} onChange={e => setQuery(e.target.value)} />
</div>
{error && <p className="modal__error">{error}</p>}
<button className="modal__submit" type="submit" disabled={loading}>
'Search'
</button>
</form>
</dialog>
)
}
-35
View File
@@ -81,39 +81,4 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
&__btn {
font-size: 14px;
font-weight: 500;
padding: 7px 16px;
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
transition: box-shadow 0.2s, opacity 0.2s;
&:hover {
opacity: 0.85;
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
&--connect {
background: var(--accent);
color: #fff;
}
&--disconnect {
background: transparent;
color: var(--text);
border-color: var(--border);
&:hover {
border-color: #ef4444;
color: #ef4444;
}
}
}
} }
+7 -10
View File
@@ -1,4 +1,5 @@
import type { ConnectionStatus } from '../types/connection' import type { ConnectionStatus } from '../types/connection'
import { ActionButton } from './ActionButton'
import './ServiceCard.scss' import './ServiceCard.scss'
interface Props { interface Props {
@@ -8,9 +9,10 @@ interface Props {
status: ConnectionStatus | null status: ConnectionStatus | null
onConnect: () => void onConnect: () => void
onDisconnect: () => void onDisconnect: () => void
onSearch: () => void
} }
export function ServiceCard({ serviceType: _serviceType, label, icon, status, onConnect, onDisconnect }: Readonly<Props>) { export function ServiceCard({ serviceType: _serviceType, label, icon, status, onConnect, onDisconnect, onSearch }: Readonly<Props>) {
const connected = status?.connected ?? false const connected = status?.connected ?? false
const formatExpiry = (iso: string | null) => { const formatExpiry = (iso: string | null) => {
@@ -37,15 +39,10 @@ export function ServiceCard({ serviceType: _serviceType, label, icon, status, on
</div> </div>
</div> </div>
<div className="service-card__actions"> <div className="service-card__actions">
{connected ? ( <ActionButton variant={connected ? 'disconnect' : 'connect'} onClick={connected ? onDisconnect : onConnect}>
<button className="service-card__btn service-card__btn--disconnect" onClick={onDisconnect}> {connected ? 'Disconnect' : 'Connect'}
Disconnect </ActionButton>
</button> {connected ? (<ActionButton variant='search' onClick={onSearch}>Search</ActionButton>) : null}
) : (
<button className="service-card__btn service-card__btn--connect" onClick={onConnect}>
Connect
</button>
)}
</div> </div>
</div> </div>
) )
+6
View File
@@ -0,0 +1,6 @@
export interface SearchRequest {
appUrl: string;
serviceType: string;
username: string;
query: string;
}