mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] move upload control into img
This commit is contained in:
+29
-34
@@ -1709,15 +1709,13 @@ function RoomDetail({
|
||||
return (
|
||||
<>
|
||||
<section className="card room-hero">
|
||||
<div className="room-hero-media">
|
||||
{/* 512 rather than the list's 256: this one is displayed large. Both are sizes
|
||||
the img worker allows, so each is a cached variant. */}
|
||||
<img className="room-hero-img" src={`${imgHost}/${room.ImageName}?width=512`} alt="" />
|
||||
<RoomImageUpload
|
||||
roomId={room.RoomId}
|
||||
src={`${imgHost}/${room.ImageName}?width=512`}
|
||||
onImageChange={(imageName) => onRoomChange({ ...room, ImageName: imageName })}
|
||||
/>
|
||||
</div>
|
||||
<div className="room-hero-body">
|
||||
<div className="room-head">
|
||||
<h1 className="room-hero-name">^{room.Name}</h1>
|
||||
@@ -2005,71 +2003,68 @@ function BlobUpload({
|
||||
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png'])
|
||||
|
||||
/**
|
||||
* Replace a room's thumbnail with a picture from disk.
|
||||
* The room's thumbnail, which is also the control that replaces it: clicking the picture
|
||||
* opens the file picker, and the picked file uploads straight away.
|
||||
*
|
||||
* Same two steps as the scene-data upload, with a different `FileType`: the bytes go to
|
||||
* `storage` under the Image type, and the key it hands back is put to the room's
|
||||
* `…/image` route as `imageName`. In game the only way to set this is to take a photo in
|
||||
* the room, so this is how an owner gets a picture they made elsewhere onto the room.
|
||||
*
|
||||
* Unlike the scene-data form there is no publish step: the image is live the moment the
|
||||
* route answers, for everyone browsing rooms. The hero above redraws from the new
|
||||
* There is no submit step because there is no publish step: the image is live the moment
|
||||
* the route answers, for everyone browsing rooms. The picture redraws from the new
|
||||
* `ImageName` straight away — `img` caches by key and the key is new, so nothing stale
|
||||
* can be served.
|
||||
*/
|
||||
function RoomImageUpload({
|
||||
roomId,
|
||||
src,
|
||||
onImageChange,
|
||||
}: {
|
||||
roomId: number
|
||||
src: string
|
||||
onImageChange: (imageName: string) => void
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
const { pending, error, done, run } = useAction()
|
||||
|
||||
return (
|
||||
<form
|
||||
className="blob-upload room-image-upload"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
<div className="room-hero-media">
|
||||
{/* A label, so the whole picture is the file input's click target. The input is
|
||||
hidden visually rather than with `display: none`, which keeps it in the tab
|
||||
order — the focus ring is drawn on the label around it. */}
|
||||
<label className="room-image-upload" aria-busy={pending}>
|
||||
<img className="room-hero-img" src={src} alt="" />
|
||||
<span className="room-image-upload-hint">
|
||||
{pending ? 'Uploading…' : 'Click to upload image'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png"
|
||||
disabled={pending}
|
||||
onChange={(e) => {
|
||||
const input = e.target
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
void run(async () => {
|
||||
try {
|
||||
if (!IMAGE_TYPES.has(file.type)) {
|
||||
throw new Error('Choose a JPEG or PNG image.')
|
||||
}
|
||||
const imageName = await uploadToStorage(file, FILE_TYPE_IMAGE)
|
||||
await setRoomImage(roomId, imageName)
|
||||
onImageChange(imageName)
|
||||
setFile(null)
|
||||
if (input.current) input.current.value = ''
|
||||
return 'Image replaced — it shows in game now.'
|
||||
} finally {
|
||||
// Cleared either way, so picking the same file again still fires a change.
|
||||
input.value = ''
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<p className="blob-upload-head">
|
||||
<span className="blob-upload-title">Replace image</span>
|
||||
</p>
|
||||
<p className="muted blob-upload-caveat">
|
||||
A JPEG or PNG, landscape (3:2) like a photo taken in game. It goes live as soon as it
|
||||
uploads.
|
||||
</p>
|
||||
<label className="blob-upload-file">
|
||||
Image file
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{done && <p className="ok">{done}</p>}
|
||||
<button type="submit" disabled={pending || file === null}>
|
||||
{pending ? 'Uploading…' : 'Upload image'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -769,18 +769,67 @@ h2 {
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
/* The picture and the form that replaces it share the narrow column, so the control sits
|
||||
under the thing it changes. The form drops the blob-upload's top margin for the
|
||||
picture's own gap and keeps its border, which is what marks it as a control on a page
|
||||
whose other room fields are read-only. */
|
||||
/* The picture is its own upload control: a label around the image and a visually hidden
|
||||
file input, so a click anywhere on it opens the picker. The hint is always drawn, not
|
||||
only on hover — a touch screen has no hover, and nothing else says the picture can be
|
||||
changed. */
|
||||
.room-hero-media {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.room-hero-media .error,
|
||||
.room-hero-media .ok {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.room-image-upload {
|
||||
margin-top: 0;
|
||||
position: relative;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.room-image-upload[aria-busy='true'] {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.room-image-upload .room-hero-img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.room-image-upload-hint {
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.8125rem;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
background: rgb(0 0 0 / 0.6);
|
||||
transition: background 120ms;
|
||||
}
|
||||
|
||||
.room-image-upload:hover .room-image-upload-hint,
|
||||
.room-image-upload:focus-within .room-image-upload-hint {
|
||||
background: rgb(0 0 0 / 0.8);
|
||||
}
|
||||
|
||||
/* Clipped to nothing rather than `display: none`, which would take it out of the tab
|
||||
order; the ring is drawn on the label instead. */
|
||||
.room-image-upload input[type='file'] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.room-image-upload:has(input:focus-visible) {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.room-hero-name {
|
||||
|
||||
Reference in New Issue
Block a user