add tags
Trip-level tags: admins configure available tags in the trip header (inline add/remove pills). The AI can also create new tags via the add_tag tool. Event tags: selectable in the Add Event modal, shown as colored pills on event cards in the timeline, and on AI suggestion cards. AI integration: sees available tags in its system prompt, applies them when proposing events, can create new ones with add_tag. Discord: tags shown as inline code blocks under each event in /trip view. Colors: auto-assigned from an 8-color palette by tag index, consistent everywhere.
This commit is contained in:
@@ -146,6 +146,7 @@ class TripCreate(BaseModel):
|
|||||||
maps_link: Optional[str] = None
|
maps_link: Optional[str] = None
|
||||||
start_date: str # YYYY-MM-DD
|
start_date: str # YYYY-MM-DD
|
||||||
end_date: str # YYYY-MM-DD
|
end_date: str # YYYY-MM-DD
|
||||||
|
available_tags: List[str] = [] # tag labels configured for this trip
|
||||||
|
|
||||||
|
|
||||||
class TripEventCreate(BaseModel):
|
class TripEventCreate(BaseModel):
|
||||||
@@ -157,6 +158,7 @@ class TripEventCreate(BaseModel):
|
|||||||
maps_link: Optional[str] = None
|
maps_link: Optional[str] = None
|
||||||
place_id: Optional[str] = None # Google Place ID
|
place_id: Optional[str] = None # Google Place ID
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
tags: List[str] = [] # tag labels applied to this event
|
||||||
|
|
||||||
|
|
||||||
class AttendeeAction(BaseModel):
|
class AttendeeAction(BaseModel):
|
||||||
|
|||||||
@@ -47,6 +47,26 @@ _TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "add_tag",
|
||||||
|
"description": (
|
||||||
|
"Add a new tag to the trip's available tag list so it can be used on events. "
|
||||||
|
"Use this when you want to apply a tag that doesn't exist yet."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tag": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Short tag label, e.g. 'must-do', 'nightlife', 'food'",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["tag"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -66,6 +86,7 @@ _TOOLS = [
|
|||||||
"location": {"type": "string", "description": "Full address or place name"},
|
"location": {"type": "string", "description": "Full address or place name"},
|
||||||
"maps_link": {"type": "string", "description": "Google Maps URL"},
|
"maps_link": {"type": "string", "description": "Google Maps URL"},
|
||||||
"notes": {"type": "string", "description": "Brief tips or reasoning"},
|
"notes": {"type": "string", "description": "Brief tips or reasoning"},
|
||||||
|
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply — must be from the trip's available tags list"},
|
||||||
},
|
},
|
||||||
"required": ["title"],
|
"required": ["title"],
|
||||||
},
|
},
|
||||||
@@ -133,13 +154,15 @@ def _build_system_prompt(trip: dict, events: list[dict]) -> str:
|
|||||||
|
|
||||||
itinerary = "".join(lines) if lines else "\n (no events yet)"
|
itinerary = "".join(lines) if lines else "\n (no events yet)"
|
||||||
attendees = ", ".join(trip.get("attendees", {}).values()) or "not specified"
|
attendees = ", ".join(trip.get("attendees", {}).values()) or "not specified"
|
||||||
|
available_tags = trip.get("available_tags") or []
|
||||||
|
tags_section = f"\nAvailable tags: {', '.join(available_tags)}" if available_tags else ""
|
||||||
|
|
||||||
return f"""You are a trip planning assistant for the following trip.
|
return f"""You are a trip planning assistant for the following trip.
|
||||||
|
|
||||||
Trip: {trip["name"]}
|
Trip: {trip["name"]}
|
||||||
Destination: {trip["location"]}
|
Destination: {trip["location"]}
|
||||||
Dates: {trip["start_date"]} to {trip["end_date"]}
|
Dates: {trip["start_date"]} to {trip["end_date"]}
|
||||||
Attendees: {attendees}
|
Attendees: {attendees}{tags_section}
|
||||||
|
|
||||||
Current itinerary:{itinerary}
|
Current itinerary:{itinerary}
|
||||||
|
|
||||||
@@ -148,6 +171,7 @@ Guidelines:
|
|||||||
- Format all responses using Markdown: use **bold** for place names and key details, bullet lists for options, and [links](url) for Maps links.
|
- Format all responses using Markdown: use **bold** for place names and key details, bullet lists for options, and [links](url) for Maps links.
|
||||||
- When the user mentions places, activities, or asks for suggestions, search for them with search_places before proposing.
|
- When the user mentions places, activities, or asks for suggestions, search for them with search_places before proposing.
|
||||||
- Use propose_event for each concrete suggestion — one call per event. The user will approve or skip each one.
|
- Use propose_event for each concrete suggestion — one call per event. The user will approve or skip each one.
|
||||||
|
- When proposing events, apply relevant tags from the available tags list if any are defined.
|
||||||
- Be mindful of the existing schedule when assigning times. Avoid obvious conflicts.
|
- Be mindful of the existing schedule when assigning times. Avoid obvious conflicts.
|
||||||
- All proposed dates must fall between {trip["start_date"]} and {trip["end_date"]}.
|
- All proposed dates must fall between {trip["start_date"]} and {trip["end_date"]}.
|
||||||
- If the user says something like "everyone should be there by 6", factor that into your time proposals.
|
- If the user says something like "everyone should be there by 6", factor that into your time proposals.
|
||||||
@@ -183,6 +207,7 @@ async def create_trip(body: TripCreate):
|
|||||||
"start_date": body.start_date,
|
"start_date": body.start_date,
|
||||||
"end_date": body.end_date,
|
"end_date": body.end_date,
|
||||||
"attendees": {}, # {discord_user_id: discord_username}
|
"attendees": {}, # {discord_user_id: discord_username}
|
||||||
|
"available_tags": body.available_tags,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
}
|
}
|
||||||
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
||||||
@@ -199,6 +224,17 @@ async def get_trip(trip_id: str):
|
|||||||
return {**trip, "events": events}
|
return {**trip, "events": events}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{trip_id}/tags")
|
||||||
|
async def update_trip_tags(trip_id: str, body: dict):
|
||||||
|
"""Replace the trip's available tag list."""
|
||||||
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
|
if not trip:
|
||||||
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
|
tags = [str(t) for t in body.get("available_tags", []) if t]
|
||||||
|
await fstore.doc_update("trips", trip_id, {"available_tags": tags})
|
||||||
|
return {"available_tags": tags}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{trip_id}")
|
@router.delete("/{trip_id}")
|
||||||
async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)):
|
async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)):
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
@@ -275,6 +311,7 @@ async def create_event(trip_id: str, body: TripEventCreate):
|
|||||||
"maps_link": body.maps_link,
|
"maps_link": body.maps_link,
|
||||||
"place_id": body.place_id,
|
"place_id": body.place_id,
|
||||||
"notes": body.notes,
|
"notes": body.notes,
|
||||||
|
"tags": body.tags,
|
||||||
"attendees": {},
|
"attendees": {},
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
}
|
}
|
||||||
@@ -403,7 +440,19 @@ async def trip_chat(
|
|||||||
for tc in msg.tool_calls:
|
for tc in msg.tool_calls:
|
||||||
args = json.loads(tc.function.arguments)
|
args = json.loads(tc.function.arguments)
|
||||||
|
|
||||||
if tc.function.name == "search_places":
|
if tc.function.name == "add_tag":
|
||||||
|
new_tag = str(args.get("tag", "")).strip()[:50]
|
||||||
|
if new_tag and new_tag not in trip.get("available_tags", []):
|
||||||
|
updated_tags = list(trip.get("available_tags") or []) + [new_tag]
|
||||||
|
trip["available_tags"] = updated_tags
|
||||||
|
await fstore.doc_update("trips", trip_id, {"available_tags": updated_tags})
|
||||||
|
messages.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc.id,
|
||||||
|
"content": json.dumps({"available_tags": trip.get("available_tags", [])}),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif tc.function.name == "search_places":
|
||||||
# Limit query string lengths before hitting the Maps API
|
# Limit query string lengths before hitting the Maps API
|
||||||
query = str(args.get("query", ""))[:200]
|
query = str(args.get("query", ""))[:200]
|
||||||
near = str(args.get("near", ""))[:200]
|
near = str(args.get("near", ""))[:200]
|
||||||
@@ -416,8 +465,10 @@ async def trip_chat(
|
|||||||
|
|
||||||
elif tc.function.name == "propose_event":
|
elif tc.function.name == "propose_event":
|
||||||
suggestion = {k: args.get(k) for k in (
|
suggestion = {k: args.get(k) for k in (
|
||||||
"title", "date", "start_time", "end_time", "location", "maps_link", "notes"
|
"title", "date", "start_time", "end_time", "location", "maps_link", "notes", "tags"
|
||||||
)}
|
)}
|
||||||
|
if not isinstance(suggestion.get("tags"), list):
|
||||||
|
suggestion["tags"] = []
|
||||||
suggestions.append(suggestion)
|
suggestions.append(suggestion)
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
|||||||
@@ -58,6 +58,34 @@ function dateRange(start: string, end: string): string[] {
|
|||||||
return dates;
|
return dates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tag helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TAG_PALETTE = [
|
||||||
|
"bg-violet-900/60 text-violet-300 border-violet-700/50",
|
||||||
|
"bg-sky-900/60 text-sky-300 border-sky-700/50",
|
||||||
|
"bg-emerald-900/60 text-emerald-300 border-emerald-700/50",
|
||||||
|
"bg-amber-900/60 text-amber-300 border-amber-700/50",
|
||||||
|
"bg-rose-900/60 text-rose-300 border-rose-700/50",
|
||||||
|
"bg-fuchsia-900/60 text-fuchsia-300 border-fuchsia-700/50",
|
||||||
|
"bg-cyan-900/60 text-cyan-300 border-cyan-700/50",
|
||||||
|
"bg-orange-900/60 text-orange-300 border-orange-700/50",
|
||||||
|
];
|
||||||
|
|
||||||
|
function tagColor(tag: string, availableTags: string[]): string {
|
||||||
|
const idx = availableTags.indexOf(tag);
|
||||||
|
return TAG_PALETTE[(idx >= 0 ? idx : 0) % TAG_PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagPill({ tag, availableTags }: { tag: string; availableTags: string[] }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-block text-[10px] font-medium rounded-full px-2 py-0.5 border ${tagColor(tag, availableTags)}`}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function detectConflicts(events: TripEvent[]): Set<string> {
|
function detectConflicts(events: TripEvent[]): Set<string> {
|
||||||
const timed = events.filter((e) => e.start_time);
|
const timed = events.filter((e) => e.start_time);
|
||||||
const conflicts = new Set<string>();
|
const conflicts = new Set<string>();
|
||||||
@@ -158,6 +186,7 @@ function AddEventModal({
|
|||||||
onAdd: (body: object) => Promise<void>;
|
onAdd: (body: object) => Promise<void>;
|
||||||
prefill?: Partial<TripEvent>;
|
prefill?: Partial<TripEvent>;
|
||||||
}) {
|
}) {
|
||||||
|
const availableTags = trip.available_tags ?? [];
|
||||||
const [title, setTitle] = useState(prefill?.title ?? "");
|
const [title, setTitle] = useState(prefill?.title ?? "");
|
||||||
const [date, setDate] = useState(prefill?.date ?? trip.start_date);
|
const [date, setDate] = useState(prefill?.date ?? trip.start_date);
|
||||||
const [startTime, setStartTime] = useState(prefill?.start_time ?? "");
|
const [startTime, setStartTime] = useState(prefill?.start_time ?? "");
|
||||||
@@ -166,6 +195,7 @@ function AddEventModal({
|
|||||||
const [mapsLink, setMapsLink] = useState(prefill?.maps_link ?? "");
|
const [mapsLink, setMapsLink] = useState(prefill?.maps_link ?? "");
|
||||||
const [placeId, setPlaceId] = useState(prefill?.place_id ?? "");
|
const [placeId, setPlaceId] = useState(prefill?.place_id ?? "");
|
||||||
const [notes, setNotes] = useState(prefill?.notes ?? "");
|
const [notes, setNotes] = useState(prefill?.notes ?? "");
|
||||||
|
const [tags, setTags] = useState<string[]>(prefill?.tags ?? []);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -190,6 +220,7 @@ function AddEventModal({
|
|||||||
maps_link: mapsLink || null,
|
maps_link: mapsLink || null,
|
||||||
place_id: placeId || null,
|
place_id: placeId || null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
|
tags,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -277,6 +308,29 @@ function AddEventModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{availableTags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Tags</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{availableTags.map((tag) => {
|
||||||
|
const active = tags.includes(tag);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
|
||||||
|
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
|
||||||
|
active ? tagColor(tag, availableTags) : "bg-gray-800 text-gray-500 border-gray-700 hover:border-gray-600 hover:text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|
||||||
<div className="flex gap-3 justify-end pt-1">
|
<div className="flex gap-3 justify-end pt-1">
|
||||||
@@ -306,11 +360,13 @@ function DayTimeline({
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
onDelete,
|
onDelete,
|
||||||
driveSegments,
|
driveSegments,
|
||||||
|
availableTags,
|
||||||
}: {
|
}: {
|
||||||
events: TripEvent[];
|
events: TripEvent[];
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
driveSegments: { fromId: string; toId: string; text: string }[];
|
driveSegments: { fromId: string; toId: string; text: string }[];
|
||||||
|
availableTags: string[];
|
||||||
}) {
|
}) {
|
||||||
const timed = [...events.filter((e) => e.start_time)].sort(
|
const timed = [...events.filter((e) => e.start_time)].sort(
|
||||||
(a, b) => toMin(a.start_time!) - toMin(b.start_time!)
|
(a, b) => toMin(a.start_time!) - toMin(b.start_time!)
|
||||||
@@ -397,6 +453,11 @@ function DayTimeline({
|
|||||||
{height >= 60 && e.notes && (
|
{height >= 60 && e.notes && (
|
||||||
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p>
|
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p>
|
||||||
)}
|
)}
|
||||||
|
{e.tags?.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{e.maps_link && (
|
{e.maps_link && (
|
||||||
@@ -451,6 +512,11 @@ function DayTimeline({
|
|||||||
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p>
|
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p>
|
||||||
)}
|
)}
|
||||||
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>}
|
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>}
|
||||||
|
{e.tags?.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{e.maps_link && (
|
{e.maps_link && (
|
||||||
@@ -493,6 +559,7 @@ interface SuggestionCard {
|
|||||||
location?: string;
|
location?: string;
|
||||||
maps_link?: string;
|
maps_link?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
dismissed?: boolean;
|
dismissed?: boolean;
|
||||||
added?: boolean;
|
added?: boolean;
|
||||||
}
|
}
|
||||||
@@ -567,6 +634,7 @@ function AssistantPanel({
|
|||||||
location: s.location ?? null,
|
location: s.location ?? null,
|
||||||
maps_link: s.maps_link ?? null,
|
maps_link: s.maps_link ?? null,
|
||||||
notes: s.notes ?? null,
|
notes: s.notes ?? null,
|
||||||
|
tags: s.tags ?? [],
|
||||||
});
|
});
|
||||||
onAddEvent(event);
|
onAddEvent(event);
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
@@ -672,6 +740,11 @@ function AssistantPanel({
|
|||||||
)}
|
)}
|
||||||
{s.location && <p className="truncate">{s.location}</p>}
|
{s.location && <p className="truncate">{s.location}</p>}
|
||||||
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>}
|
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>}
|
||||||
|
{s.tags && s.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||||
|
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{s.maps_link && (
|
{s.maps_link && (
|
||||||
<a href={s.maps_link} target="_blank" rel="noopener noreferrer"
|
<a href={s.maps_link} target="_blank" rel="noopener noreferrer"
|
||||||
@@ -755,6 +828,7 @@ export default function TripDetailPage() {
|
|||||||
const [selectedDay, setSelectedDay] = useState<string>("");
|
const [selectedDay, setSelectedDay] = useState<string>("");
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
|
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -836,6 +910,25 @@ export default function TripDetailPage() {
|
|||||||
);
|
);
|
||||||
return { ...prev, events };
|
return { ...prev, events };
|
||||||
});
|
});
|
||||||
|
// Refresh trip to pick up any new tags the AI may have created
|
||||||
|
c2api.getTrip(id).then((data) => setTrip((prev) => prev ? { ...prev, available_tags: (data as FullTrip).available_tags } : prev)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddTag() {
|
||||||
|
if (!trip || !tagInput.trim()) return;
|
||||||
|
const tag = tagInput.trim();
|
||||||
|
if (trip.available_tags?.includes(tag)) { setTagInput(""); return; }
|
||||||
|
const updated = [...(trip.available_tags ?? []), tag];
|
||||||
|
await c2api.updateTripTags(trip.trip_id, updated);
|
||||||
|
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev);
|
||||||
|
setTagInput("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveTag(tag: string) {
|
||||||
|
if (!trip) return;
|
||||||
|
const updated = (trip.available_tags ?? []).filter((t) => t !== tag);
|
||||||
|
await c2api.updateTripTags(trip.trip_id, updated);
|
||||||
|
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading…</p>;
|
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading…</p>;
|
||||||
@@ -899,6 +992,32 @@ export default function TripDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tag manager */}
|
||||||
|
{(isAdmin || (trip.available_tags ?? []).length > 0) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{(trip.available_tags ?? []).map((tag) => (
|
||||||
|
<span key={tag} className={`inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 border ${tagColor(tag, trip.available_tags ?? [])}`}>
|
||||||
|
{tag}
|
||||||
|
{isAdmin && (
|
||||||
|
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white transition-colors leading-none">×</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
|
||||||
|
placeholder="Add tag…"
|
||||||
|
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24"
|
||||||
|
/>
|
||||||
|
<button onClick={handleAddTag} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two-column layout */}
|
{/* Two-column layout */}
|
||||||
@@ -946,6 +1065,7 @@ export default function TripDetailPage() {
|
|||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
driveSegments={driveTimes[selectedDay] ?? []}
|
driveSegments={driveTimes[selectedDay] ?? []}
|
||||||
|
availableTags={trip.available_tags ?? []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export const c2api = {
|
|||||||
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
|
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
|
||||||
deleteTrip: (id: string) =>
|
deleteTrip: (id: string) =>
|
||||||
request(`/trips/${id}`, { method: "DELETE" }),
|
request(`/trips/${id}`, { method: "DELETE" }),
|
||||||
|
updateTripTags: (id: string, available_tags: string[]) =>
|
||||||
|
request<{ available_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags }) }),
|
||||||
createTripEvent: (tripId: string, body: object) =>
|
createTripEvent: (tripId: string, body: object) =>
|
||||||
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
deleteTripEvent: (tripId: string, eventId: string) =>
|
deleteTripEvent: (tripId: string, eventId: string) =>
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export interface TripEvent {
|
|||||||
maps_link: string | null;
|
maps_link: string | null;
|
||||||
place_id: string | null;
|
place_id: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
tags: string[];
|
||||||
attendees: Record<string, string>;
|
attendees: Record<string, string>;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -132,6 +133,7 @@ export interface TripRecord {
|
|||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
attendees: Record<string, string>;
|
attendees: Record<string, string>;
|
||||||
|
available_tags: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
events?: TripEvent[];
|
events?: TripEvent[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,6 +248,10 @@ class TripCommands(commands.Cog):
|
|||||||
if e.get("notes"):
|
if e.get("notes"):
|
||||||
line += f"\n\u3000\u3000_{e['notes']}_"
|
line += f"\n\u3000\u3000_{e['notes']}_"
|
||||||
|
|
||||||
|
event_tags = e.get("tags") or []
|
||||||
|
if event_tags:
|
||||||
|
line += f"\n\u3000\u3000`{'` `'.join(event_tags)}`"
|
||||||
|
|
||||||
event_att = list(e.get("attendees", {}).values())
|
event_att = list(e.get("attendees", {}).values())
|
||||||
if event_att:
|
if event_att:
|
||||||
line += f"\n\u3000\u3000{', '.join(event_att)}"
|
line += f"\n\u3000\u3000{', '.join(event_att)}"
|
||||||
|
|||||||
Reference in New Issue
Block a user