210 lines
6.7 KiB
TypeScript
210 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { CATEGORIES } from "@/lib/ui-config";
|
|
import type { CategoryId, Place } from "@/lib/types";
|
|
import type { Dispatch } from "@/lib/app-state";
|
|
import { FieldLabel } from "@/components/ui-primitives";
|
|
import {
|
|
CoverPicker,
|
|
commitCover,
|
|
coverStateFromUrl,
|
|
disposeCover,
|
|
type CoverState,
|
|
} from "@/components/cover-picker";
|
|
import { Icons } from "@/components/icons";
|
|
import { editPlace } from "@/lib/db/actions";
|
|
|
|
export function EditPlaceSheet({
|
|
place,
|
|
onClose,
|
|
dispatch,
|
|
}: {
|
|
place: Place;
|
|
onClose: () => void;
|
|
dispatch: Dispatch;
|
|
}) {
|
|
const [name, setName] = useState(place.name);
|
|
const [address, setAddress] = useState(place.address);
|
|
const [category, setCategory] = useState<CategoryId>(place.category);
|
|
const [tags, setTags] = useState<string[]>(place.tags);
|
|
const [tagInput, setTagInput] = useState("");
|
|
const [cover, setCover] = useState<CoverState>(() => coverStateFromUrl(place.cover_url));
|
|
const [saving, setSaving] = useState(false);
|
|
const [, startTransition] = useTransition();
|
|
|
|
const isValid = name.trim() && address.trim();
|
|
|
|
const addTag = (raw: string) => {
|
|
const t = raw.trim().replace(/,$/, "");
|
|
if (t && !tags.includes(t) && tags.length < 10) setTags([...tags, t]);
|
|
setTagInput("");
|
|
};
|
|
|
|
const submit = () => {
|
|
if (!isValid) return;
|
|
setSaving(true);
|
|
const trimmedAddress = address.trim();
|
|
startTransition(async () => {
|
|
try {
|
|
// Upload to R2 only now — local blob (if any) is converted to a real
|
|
// URL. An existing URL is returned unchanged.
|
|
const coverUrl = await commitCover(cover);
|
|
const patch = {
|
|
name: name.trim(),
|
|
address: trimmedAddress,
|
|
short_address: trimmedAddress.split(",").slice(0, 2).join(" · "),
|
|
city: trimmedAddress.split(",").pop()?.trim() || "",
|
|
category,
|
|
tags,
|
|
cover_url: coverUrl,
|
|
};
|
|
await editPlace(place.id, patch);
|
|
disposeCover(cover);
|
|
dispatch({ type: "PATCH_PLACE", placeId: place.id, patch });
|
|
onClose();
|
|
dispatch({ type: "TOAST", value: "Đã lưu thay đổi" });
|
|
} catch (e) {
|
|
setSaving(false);
|
|
dispatch({
|
|
type: "TOAST",
|
|
value: (e as Error).message || "Lưu thất bại",
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="overlay" onClick={onClose} />
|
|
<div className="sheet" style={{ height: "85%" }}>
|
|
<div className="sheet-handle" />
|
|
<div
|
|
style={{
|
|
padding: "6px 12px 8px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
}}
|
|
>
|
|
<button
|
|
onClick={onClose}
|
|
style={{
|
|
background: "transparent",
|
|
border: 0,
|
|
color: "var(--muted-foreground)",
|
|
fontSize: 15,
|
|
fontWeight: 500,
|
|
padding: "8px 12px",
|
|
}}
|
|
>
|
|
Hủy
|
|
</button>
|
|
<div style={{ fontSize: 16, fontWeight: 600 }}>Sửa địa điểm</div>
|
|
<div style={{ width: 70 }} />
|
|
</div>
|
|
|
|
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px" }}>
|
|
<div style={{ marginTop: 8 }}>
|
|
<CoverPicker
|
|
value={cover}
|
|
onChange={setCover}
|
|
onError={(msg) => dispatch({ type: "TOAST", value: msg })}
|
|
/>
|
|
</div>
|
|
|
|
<FieldLabel required>Tên địa điểm</FieldLabel>
|
|
<div className="input">
|
|
<input value={name} onChange={(e) => setName(e.target.value)} />
|
|
</div>
|
|
|
|
<FieldLabel required>Địa chỉ</FieldLabel>
|
|
<div className="input">
|
|
<Icons.MapPin size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
|
|
<input value={address} onChange={(e) => setAddress(e.target.value)} />
|
|
</div>
|
|
|
|
<FieldLabel required>Danh mục</FieldLabel>
|
|
<div className="toggle-group">
|
|
{(Object.entries(CATEGORIES) as [CategoryId, typeof CATEGORIES[CategoryId]][]).map(
|
|
([k, c]) => {
|
|
const I = Icons[c.icon as keyof typeof Icons];
|
|
return (
|
|
<button
|
|
key={k}
|
|
data-active={category === k}
|
|
onClick={() => setCategory(k)}
|
|
>
|
|
<I size={20} stroke={1.75} />
|
|
<span>{c.label}</span>
|
|
</button>
|
|
);
|
|
},
|
|
)}
|
|
</div>
|
|
|
|
<FieldLabel>
|
|
Thẻ <span style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}>· {tags.length}/10</span>
|
|
</FieldLabel>
|
|
<div
|
|
className="input"
|
|
style={{ flexWrap: "wrap", height: "auto", minHeight: 48, padding: "6px 10px", gap: 6 }}
|
|
>
|
|
{tags.map((t) => (
|
|
<span
|
|
key={t}
|
|
className="badge"
|
|
style={{ height: 26, background: "var(--primary-soft)", color: "var(--primary)" }}
|
|
>
|
|
{t}
|
|
<button
|
|
onClick={() => setTags(tags.filter((x) => x !== t))}
|
|
style={{ background: "transparent", border: 0, color: "inherit", padding: 2, marginLeft: 2 }}
|
|
>
|
|
<Icons.X size={12} stroke={2.5} />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<input
|
|
value={tagInput}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
if (v.endsWith(",")) addTag(v);
|
|
else setTagInput(v);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
addTag(tagInput);
|
|
} else if (e.key === "Backspace" && !tagInput && tags.length) {
|
|
setTags(tags.slice(0, -1));
|
|
}
|
|
}}
|
|
placeholder={tags.length ? "" : "Enter để thêm thẻ"}
|
|
style={{ flex: 1, minWidth: 80, height: 32 }}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ height: 8 }} />
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
padding: "12px 16px 4px",
|
|
borderTop: "0.5px solid var(--border)",
|
|
background: "color-mix(in oklch, var(--card) 90%, transparent)",
|
|
}}
|
|
>
|
|
<button
|
|
className="btn btn--block btn--lg"
|
|
disabled={!isValid || saving}
|
|
onClick={submit}
|
|
>
|
|
{saving ? "Đang lưu..." : "Lưu thay đổi"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|