add trips permissions

This commit is contained in:
Logan
2026-06-21 20:00:48 -04:00
parent 981f03ac06
commit 6ae4d398f8
9 changed files with 425 additions and 10 deletions
+69 -1
View File
@@ -908,6 +908,7 @@ export default function TripDetailPage() {
const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
const [tagInput, setTagInput] = useState("");
const [inviteInput, setInviteInput] = useState("");
const load = useCallback(async () => {
try {
@@ -1024,6 +1025,28 @@ export default function TripDetailPage() {
setTrip((prev) => prev ? { ...prev, available_tags: available, overlap_tags: overlap } : prev);
}
async function handleToggleVisibility() {
if (!trip) return;
const next = trip.visibility === "private" ? "public" : "private";
await c2api.setTripVisibility(trip.trip_id, next);
setTrip((prev) => prev ? { ...prev, visibility: next } : prev);
}
async function handleInvite() {
const discordId = inviteInput.trim();
if (!trip || !discordId) return;
if ((trip.invited_discord_ids ?? []).includes(discordId)) { setInviteInput(""); return; }
await c2api.inviteToTrip(trip.trip_id, discordId);
setTrip((prev) => prev ? { ...prev, invited_discord_ids: [...(prev.invited_discord_ids ?? []), discordId] } : prev);
setInviteInput("");
}
async function handleRevokeInvite(discordId: string) {
if (!trip) return;
await c2api.revokeInvite(trip.trip_id, discordId);
setTrip((prev) => prev ? { ...prev, invited_discord_ids: (prev.invited_discord_ids ?? []).filter((id) => id !== discordId) } : prev);
}
async function handleToggleOverlap(tag: string) {
if (!trip) return;
const current = trip.overlap_tags ?? [];
@@ -1074,7 +1097,13 @@ export default function TripDetailPage() {
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
{/* Visibility badge */}
{trip.visibility === "private" && (
<span className="text-xs font-mono text-amber-500 border border-amber-800/50 rounded-full px-2 py-0.5">
🔒 private
</span>
)}
{isAdmin && (
<>
<button
@@ -1083,6 +1112,12 @@ export default function TripDetailPage() {
>
+ Add Event
</button>
<button
onClick={handleToggleVisibility}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors border border-gray-700 rounded-lg px-3 py-2"
>
{trip.visibility === "private" ? "Make public" : "Make private"}
</button>
<button
onClick={handleDeleteTrip}
className="text-xs text-red-500 hover:text-red-400 transition-colors"
@@ -1131,6 +1166,39 @@ export default function TripDetailPage() {
)}
</div>
)}
{/* Invite management — admin only, only when private */}
{isAdmin && trip.visibility === "private" && (
<div className="space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">Invited</p>
<div className="flex flex-wrap items-center gap-2">
{(trip.invited_discord_ids ?? []).length === 0 && (
<span className="text-xs text-gray-600">No invites yet</span>
)}
{(trip.invited_discord_ids ?? []).map((discordId) => (
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-gray-800 border border-gray-700 rounded-full px-2.5 py-0.5 text-gray-300">
<span className="font-mono">{discordId}</span>
<button
onClick={() => handleRevokeInvite(discordId)}
className="opacity-60 hover:opacity-100 hover:text-red-400 transition-colors leading-none"
>
×
</button>
</span>
))}
<div className="flex items-center gap-1">
<input
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
placeholder="Discord user ID…"
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-36"
/>
<button onClick={handleInvite} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
</div>
</div>
</div>
)}
</div>
{/* Two-column layout */}
+12
View File
@@ -144,6 +144,18 @@ export const c2api = {
request(`/trips/${id}`, { method: "DELETE" }),
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
setTripVisibility: (id: string, visibility: "public" | "private") =>
request<{ visibility: string }>(`/trips/${id}/visibility`, { method: "PUT", body: JSON.stringify({ visibility }) }),
inviteToTrip: (id: string, discord_user_id: string) =>
request(`/trips/${id}/invite/${discord_user_id}`, { method: "POST" }),
revokeInvite: (id: string, discord_user_id: string) =>
request(`/trips/${id}/invite/${discord_user_id}`, { method: "DELETE" }),
generateLinkCode: () =>
request<{ code?: string; expires_minutes?: number; already_linked?: boolean; discord_user_id?: string }>("/auth/link/generate", { method: "POST" }),
getLinkStatus: () =>
request<{ linked: boolean; discord_user_id?: string; discord_username?: string; linked_at?: string }>("/auth/link/status"),
unlinkDiscord: () =>
request("/auth/link", { method: "DELETE" }),
createTripEvent: (tripId: string, body: object) =>
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
updateTripEvent: (tripId: string, eventId: string, body: object) =>
+2
View File
@@ -135,6 +135,8 @@ export interface TripRecord {
attendees: Record<string, string>;
available_tags: string[];
overlap_tags: string[];
visibility: "public" | "private";
invited_discord_ids: string[];
created_at: string;
events?: TripEvent[];
}