Skip to content

Commit c985f09

Browse files
committed
direct links to image detail
1 parent 63bd441 commit c985f09

File tree

7 files changed

+316
-259
lines changed

7 files changed

+316
-259
lines changed

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,3 @@ I made it to easily find images in the tweets I liked. It automatically scrapes
2222

2323
* Train custom autotaggers for the characters that can't be identified in the pre-trained model _(in progress)_
2424
* Alternative embedding models for image similarity search
25-
* Improve UI to provide more links to entities

frontend/src/main.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { FacesRoute, facesLoader } from './routes/faces'
1313
import { extractRootSearchParams } from './utils/search'
1414
import isEqual from 'lodash/isEqual'
1515
import { RootRoute } from './routes/root'
16+
import { imageDetailLoader, ImageDetailRoute } from './routes/imageDetail'
1617

1718
const router = createBrowserRouter([
1819
{
@@ -32,12 +33,24 @@ const router = createBrowserRouter([
3233
},
3334
children: [
3435
{
35-
index: true,
36+
path: "",
3637
element: <SearchResultRoute />,
3738
loader: searchResultLoader,
39+
children: [
40+
{
41+
path: "images/:imageId",
42+
element: <ImageDetailRoute parentContext="searchResult" />,
43+
loader: imageDetailLoader,
44+
}
45+
]
3846
}
3947
]
4048
},
49+
{
50+
path: "i/:imageId",
51+
element: <ImageDetailRoute />,
52+
loader: imageDetailLoader,
53+
},
4154
{
4255
path: "faces",
4356
element: <FacesRoute />,

frontend/src/routes/faces.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from "react"
2-
import { useLoaderData } from "react-router-dom"
2+
import { Link, useLoaderData } from "react-router-dom"
33

44
export const facesLoader = async () => {
55
return fetch("/api/face-clusters")
@@ -26,7 +26,7 @@ export const FacesRoute = () => {
2626
<div className="col py-3">
2727
<div className="d-flex flex-wrap">
2828
{clusters.map((cluster: any) => {
29-
const face = cluster.faces[0]
29+
const face = cluster.faces[0].face
3030
return (
3131
<div key={cluster.id} className="me-2 mb-2">
3232
{cluster.label ?? cluster.id.substring(0, 8)} ({cluster.face_count})<br />
@@ -50,10 +50,12 @@ export const FacesRoute = () => {
5050
<SetLabelForm faceCluster={cluster} onUpdate={(c: any) => setCluster(c) /* TODO: update list */} />
5151

5252
<div className="d-flex flex-wrap">
53-
{cluster.faces.map((face: any) => {
53+
{cluster.faces.map(({image_id, face}: any) => {
5454
return (
5555
<div className="me-2 mb-2" key={`${face.local_filename}`}>
56-
<img src={`/images/faces/${face.local_filename}`} style={{height: 120}} />
56+
<Link to={`/i/${encodeURIComponent(image_id)}`}>
57+
<img src={`/images/faces/${face.local_filename}`} style={{height: 120}} />
58+
</Link>
5759
</div>
5860
)
5961
})}

frontend/src/routes/imageDetail.tsx

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import * as React from 'react'
2+
import { Link, LoaderFunction, useLoaderData } from 'react-router-dom'
3+
import { useState } from 'react'
4+
import { addTag, extractRootSearchParams, onlyTag } from '../utils/search'
5+
import { RootLink, useExtractedSearchParams } from '../components/SearchLink'
6+
import { useCombobox } from 'downshift'
7+
import { useRecentlyAddedManualTags } from '../utils/manualTags'
8+
9+
export const imageDetailLoader: LoaderFunction = async ({ params }) => {
10+
return {
11+
image: await (await fetch(`/api/images/${encodeURIComponent(params['imageId']!)}`)).json()
12+
}
13+
}
14+
15+
export function ImageDetailRoute({
16+
parentContext
17+
}: {
18+
parentContext?: "searchResult"
19+
}) {
20+
const {image: _image} = useLoaderData() as any
21+
const [image, setImage] = useState(_image)
22+
React.useEffect(() => setImage(_image), [_image.id])
23+
return (
24+
<DetailOverlay
25+
selectedImage={image}
26+
onClose={parentContext === "searchResult" ? () => history.back() : null}
27+
updateImage={setImage}
28+
/>
29+
)
30+
}
31+
32+
function DetailOverlay({
33+
selectedImage,
34+
onClose,
35+
updateImage,
36+
}: any) {
37+
const isModal = !!onClose
38+
return (
39+
<>
40+
<div className={isModal ? "modal fade show d-flex" : "vh-fill"} style={{display: 'flex'}}>
41+
{isModal && (
42+
<div data-bs-theme="dark" className="fixed-top p-2" style={{width: '5%'}}>
43+
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
44+
</div>
45+
)}
46+
<div className="col p-4">
47+
<img src={`/images/${selectedImage.local_filename}`} style={{width: '100%', height: '100%', objectFit: 'scale-down'}} />
48+
</div>
49+
<DetailOverlaySidebar
50+
bsTheme={isModal ? "dark" : ""}
51+
selectedImage={selectedImage}
52+
updateImage={updateImage}
53+
/>
54+
</div>
55+
{onClose && <div className="modal-backdrop" />}
56+
</>
57+
)
58+
}
59+
60+
function DetailOverlaySidebar({
61+
bsTheme,
62+
selectedImage,
63+
updateImage,
64+
}: any) {
65+
const [similarImages, setSimilarImages] = React.useState([])
66+
const [faces, setFaces] = React.useState([])
67+
React.useEffect(() => {
68+
fetch(`/api/images/${encodeURIComponent(selectedImage.id)}/similar`)
69+
.then(r => r.json())
70+
.then(r => setSimilarImages(r))
71+
72+
fetch(`/api/images/${encodeURIComponent(selectedImage.id)}/faces`)
73+
.then(r => r.json())
74+
.then(r => setFaces(r))
75+
}, [selectedImage.id])
76+
77+
const allTags = (selectedImage.manual_tags?.map((it: any) => ({...it, is_manual: true})) ?? []).concat(selectedImage.tags ?? [])
78+
79+
const [recentTags, addRecentTag] = useRecentlyAddedManualTags()
80+
const addCharacterTag = (name: string) => {
81+
const manualTags = selectedImage.manual_tags?.slice() ?? []
82+
manualTags.push({
83+
tag: name,
84+
type: 'CHARACTER',
85+
})
86+
fetch(`/api/images/${selectedImage.id}/manual-tags`, {
87+
method: 'PUT',
88+
body: JSON.stringify({manual_tags: manualTags}),
89+
headers: {'Content-Type': 'application/json'},
90+
}).then(r => {
91+
if (r.ok) return r.json()
92+
else return r.json().then(e => { throw new Error(e.detail) })
93+
}).then(r => {
94+
updateImage(r)
95+
}).catch(e => alert(e.message))
96+
97+
addRecentTag(name)
98+
}
99+
const removeCharacterTag = (name: string) => {
100+
const manualTags = selectedImage.manual_tags?.filter((tag: any) => tag.tag !== name) ?? []
101+
fetch(`/api/images/${selectedImage.id}/manual-tags`, {
102+
method: 'PUT',
103+
body: JSON.stringify({manual_tags: manualTags}),
104+
headers: {'Content-Type': 'application/json'},
105+
}).then(r => {
106+
if (r.ok) return r.json()
107+
else return r.json().then(e => { throw new Error(e.detail) })
108+
}).then(r => {
109+
updateImage(r)
110+
}).catch(e => alert(e.message))
111+
}
112+
113+
return (<>
114+
<div className="col-2 p-2 overflow-y-auto" data-bs-theme={bsTheme} style={{fontSize: '0.9rem'}}>
115+
{['RATING', null].map(tagType => (
116+
<TagList key={tagType ?? ""} tags={allTags.filter((tag: any) => tag.type === tagType)} primaryLinkClassName={bsTheme === "dark" && "link-light"} />
117+
))}
118+
</div>
119+
<div className="col-3 border-start p-2 overflow-y-auto bg-body-tertiary">
120+
{selectedImage.tweet_id && selectedImage.tweet_username && (
121+
<p className="card-text">source: <a href={`https://twitter.com/_/status/${selectedImage.tweet_id}`} target="_blank">@{selectedImage.tweet_username}</a></p>
122+
)}
123+
124+
{(faces?.length ?? 0) > 0 && (<>
125+
<div className="mb-2 fw-bold">faces</div>
126+
{faces.filter((face: any) => face.face_cluster_id).map((face: any) =>
127+
<div className="me-2 mb-2">
128+
<img src={`/images/faces/${face.local_filename}`} style={{height: 120}} />
129+
<span className="ms-2">
130+
{face.face_cluster_label ?? face.face_cluster_id?.substring(0, 8)}
131+
</span>
132+
</div>
133+
)}
134+
<div className="d-flex flex-wrap">
135+
{faces.filter((face: any) => !face.face_cluster_id).map((face: any) =>
136+
<div className="me-2 mb-2" style={{opacity: 0.5}}>
137+
<img src={`/images/faces/${face.local_filename}`} style={{height: 120}} />
138+
</div>
139+
)}
140+
</div>
141+
</>)}
142+
143+
<div className="my-2 fw-bold">characters</div>
144+
<div className="pb-2">
145+
<TagList
146+
tags={allTags.filter((tag: any) => tag.type === 'CHARACTER')}
147+
onRemove={(tag: any) => removeCharacterTag(tag)}
148+
/>
149+
<CharacterSelector onSelect={addCharacterTag} />
150+
recently added: {recentTags.map(tag => (
151+
<button key={tag} type="button" onClick={() => addCharacterTag(tag)}>+ {tag}</button>
152+
))}
153+
</div>
154+
155+
<div className="my-2 fw-bold">similar</div>
156+
<div className="d-flex flex-wrap">
157+
{similarImages.map(({image, score}: any) => (
158+
<div className="me-2 mb-2">
159+
<Link to={`/i/${image.id}`}>
160+
<img src={`/images/${image.local_filename}`} style={{height: 120}} />
161+
<br />{score.toFixed(3)}
162+
</Link>
163+
</div>
164+
))}
165+
</div>
166+
</div>
167+
</>
168+
)
169+
}
170+
171+
function TagList({ tags, onRemove, primaryLinkClassName }: any) {
172+
const search = useExtractedSearchParams(extractRootSearchParams)
173+
return tags.map((tag: any) => (
174+
<div key={tag.tag} className="TagList-item">
175+
{tag.is_manual && onRemove && <a href="#" onClick={e => { e.preventDefault(); onRemove(tag.tag) }} className="link-danger me-1">X</a>}
176+
<RootLink search={addTag(search, tag.tag)} className={primaryLinkClassName}>
177+
{tag.tag}
178+
</RootLink>
179+
<span className="ps-2 text-secondary">{tag.score ? tag.score.toFixed(3) : ''}</span>
180+
<RootLink search={onlyTag(search, tag.tag)} className="ms-2 link-secondary">
181+
only
182+
</RootLink>
183+
<RootLink search={addTag(search, "-" + tag.tag)} className="ms-1 link-secondary">
184+
not
185+
</RootLink>
186+
</div>
187+
))
188+
}
189+
190+
function CharacterSelector({
191+
onSelect
192+
}: any) {
193+
const [items, setItems] = useState<any[]>([])
194+
const {
195+
isOpen,
196+
getMenuProps,
197+
getInputProps,
198+
highlightedIndex,
199+
getItemProps,
200+
selectedItem,
201+
reset,
202+
} = useCombobox({
203+
onInputValueChange({inputValue}) {
204+
if (inputValue) {
205+
fetch('/api/tags/character?' + new URLSearchParams({q: inputValue}))
206+
.then(r => r.json())
207+
.then(r => setItems(r))
208+
} else {
209+
setItems([])
210+
}
211+
},
212+
onSelectedItemChange({selectedItem}) {
213+
if (selectedItem) {
214+
onSelect(selectedItem.name)
215+
}
216+
reset()
217+
},
218+
items,
219+
itemToString(item) {
220+
return item ? item.name : ''
221+
},
222+
})
223+
224+
return (
225+
<div>
226+
<div className="w-72 d-flex flex-column gap-1">
227+
<div className="d-flex">
228+
<input
229+
placeholder="Add tag"
230+
className="w-full p-1.5"
231+
{...getInputProps()}
232+
/>
233+
</div>
234+
</div>
235+
<ul
236+
className={`position-absolute w-72 bg-white mt-1 shadow overflow-scroll p-0 z-10 ${
237+
!(isOpen && items.length) && 'hidden'
238+
}`}
239+
style={{maxHeight: 300}}
240+
{...getMenuProps()}
241+
>
242+
{isOpen &&
243+
items.map((item, index) => (
244+
<li
245+
className={`
246+
${highlightedIndex === index ? 'bg-secondary-subtle' : ''}
247+
${selectedItem === item ? 'font-bold' : ''}
248+
py-2 px-3 d-flex flex-col
249+
`}
250+
key={item.id}
251+
{...getItemProps({item, index})}
252+
>
253+
<span>{item.name}</span>
254+
<span className="ps-3 text-secondary">{item.danbooru_post_count}</span>
255+
</li>
256+
))}
257+
</ul>
258+
</div>
259+
)
260+
}

0 commit comments

Comments
 (0)