added first searchmodal draft
This commit is contained in:
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface SearchRequest {
|
||||||
|
appUrl: string;
|
||||||
|
serviceType: string;
|
||||||
|
username: string;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user