mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[www] move upload control into img
This commit is contained in:
+43
-48
@@ -1709,15 +1709,13 @@ function RoomDetail({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="card room-hero">
|
<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
|
||||||
{/* 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. */}
|
||||||
the img worker allows, so each is a cached variant. */}
|
<RoomImageUpload
|
||||||
<img className="room-hero-img" src={`${imgHost}/${room.ImageName}?width=512`} alt="" />
|
roomId={room.RoomId}
|
||||||
<RoomImageUpload
|
src={`${imgHost}/${room.ImageName}?width=512`}
|
||||||
roomId={room.RoomId}
|
onImageChange={(imageName) => onRoomChange({ ...room, ImageName: imageName })}
|
||||||
onImageChange={(imageName) => onRoomChange({ ...room, ImageName: imageName })}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="room-hero-body">
|
<div className="room-hero-body">
|
||||||
<div className="room-head">
|
<div className="room-head">
|
||||||
<h1 className="room-hero-name">^{room.Name}</h1>
|
<h1 className="room-hero-name">^{room.Name}</h1>
|
||||||
@@ -2005,71 +2003,68 @@ function BlobUpload({
|
|||||||
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png'])
|
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
|
* 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
|
* `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
|
* `…/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.
|
* 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
|
* There is no submit step because there is no publish step: the image is live the moment
|
||||||
* route answers, for everyone browsing rooms. The hero above redraws from the new
|
* 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
|
* `ImageName` straight away — `img` caches by key and the key is new, so nothing stale
|
||||||
* can be served.
|
* can be served.
|
||||||
*/
|
*/
|
||||||
function RoomImageUpload({
|
function RoomImageUpload({
|
||||||
roomId,
|
roomId,
|
||||||
|
src,
|
||||||
onImageChange,
|
onImageChange,
|
||||||
}: {
|
}: {
|
||||||
roomId: number
|
roomId: number
|
||||||
|
src: string
|
||||||
onImageChange: (imageName: string) => void
|
onImageChange: (imageName: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [file, setFile] = useState<File | null>(null)
|
|
||||||
const input = useRef<HTMLInputElement>(null)
|
|
||||||
const { pending, error, done, run } = useAction()
|
const { pending, error, done, run } = useAction()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<div className="room-hero-media">
|
||||||
className="blob-upload room-image-upload"
|
{/* A label, so the whole picture is the file input's click target. The input is
|
||||||
onSubmit={(e) => {
|
hidden visually rather than with `display: none`, which keeps it in the tab
|
||||||
e.preventDefault()
|
order — the focus ring is drawn on the label around it. */}
|
||||||
if (!file) return
|
<label className="room-image-upload" aria-busy={pending}>
|
||||||
void run(async () => {
|
<img className="room-hero-img" src={src} alt="" />
|
||||||
if (!IMAGE_TYPES.has(file.type)) {
|
<span className="room-image-upload-hint">
|
||||||
throw new Error('Choose a JPEG or PNG image.')
|
{pending ? 'Uploading…' : 'Click to upload image'}
|
||||||
}
|
</span>
|
||||||
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.'
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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
|
<input
|
||||||
ref={input}
|
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png"
|
accept="image/jpeg,image/png"
|
||||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
disabled={pending}
|
||||||
required
|
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)
|
||||||
|
return 'Image replaced — it shows in game now.'
|
||||||
|
} finally {
|
||||||
|
// Cleared either way, so picking the same file again still fires a change.
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
{done && <p className="ok">{done}</p>}
|
{done && <p className="ok">{done}</p>}
|
||||||
<button type="submit" disabled={pending || file === null}>
|
</div>
|
||||||
{pending ? 'Uploading…' : 'Upload image'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -769,18 +769,67 @@ h2 {
|
|||||||
background: var(--surface-hi);
|
background: var(--surface-hi);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The picture and the form that replaces it share the narrow column, so the control sits
|
/* The picture is its own upload control: a label around the image and a visually hidden
|
||||||
under the thing it changes. The form drops the blob-upload's top margin for the
|
file input, so a click anywhere on it opens the picker. The hint is always drawn, not
|
||||||
picture's own gap and keeps its border, which is what marks it as a control on a page
|
only on hover — a touch screen has no hover, and nothing else says the picture can be
|
||||||
whose other room fields are read-only. */
|
changed. */
|
||||||
.room-hero-media {
|
.room-hero-media {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.room-hero-media .error,
|
||||||
|
.room-hero-media .ok {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.room-image-upload {
|
.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 {
|
.room-hero-name {
|
||||||
|
|||||||
Reference in New Issue
Block a user