|
| 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