Autosave city form, heart-style likes, social link handles, map polish
- Admin: allow editing a user's display name - Map: zoom to ~500km around the most recently added city on load; marker popups show the photo and links, and fix the unreadable black-on-black "View details" button - City form: autosave on every change (debounced for text, immediate for status/heart/participants) instead of an explicit Save button; a new city is created as soon as name, country, and location are set - Replace the "Liked" checkbox with a heart toggle (outline vs. filled) - Links: add a platform selector (Instagram/X/Facebook/TikTok/YouTube/ LinkedIn) so a handle auto-generates the profile URL, alongside plain website links; links autosave too, no separate Save button Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
3debb1ce1e
commit
ce16de8504
8 changed files with 496 additions and 218 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import type { CityLink } from '@citytracker/shared';
|
import type { CityLink } from '@citytracker/shared';
|
||||||
|
|
||||||
interface LinkListEditorProps {
|
interface LinkListEditorProps {
|
||||||
|
|
@ -7,75 +7,148 @@ interface LinkListEditorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DraftLink {
|
interface DraftLink {
|
||||||
url: string;
|
type: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LinkListEditor({ links, onSave }: LinkListEditorProps) {
|
interface Platform {
|
||||||
const [draft, setDraft] = useState<DraftLink[]>(
|
key: string;
|
||||||
links.map((l) => ({ url: l.url, label: l.label })),
|
label: string;
|
||||||
);
|
prefix: string | null;
|
||||||
const [saving, setSaving] = useState(false);
|
placeholder: string;
|
||||||
const [dirty, setDirty] = useState(false);
|
}
|
||||||
|
|
||||||
function updateLink(index: number, field: keyof DraftLink, value: string) {
|
const PLATFORMS: Platform[] = [
|
||||||
setDraft((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)));
|
{ key: 'website', label: 'Website', prefix: null, placeholder: 'https://…' },
|
||||||
setDirty(true);
|
{ key: 'instagram', label: 'Instagram', prefix: 'https://instagram.com/', placeholder: 'username' },
|
||||||
|
{ key: 'twitter', label: 'X (Twitter)', prefix: 'https://x.com/', placeholder: 'username' },
|
||||||
|
{ key: 'facebook', label: 'Facebook', prefix: 'https://facebook.com/', placeholder: 'username' },
|
||||||
|
{ key: 'tiktok', label: 'TikTok', prefix: 'https://tiktok.com/@', placeholder: 'username' },
|
||||||
|
{ key: 'youtube', label: 'YouTube', prefix: 'https://youtube.com/@', placeholder: 'handle' },
|
||||||
|
{ key: 'linkedin', label: 'LinkedIn', prefix: 'https://linkedin.com/in/', placeholder: 'username' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function platformFor(key: string): Platform {
|
||||||
|
return PLATFORMS.find((p) => p.key === key) ?? PLATFORMS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLinkForEditing(link: CityLink): DraftLink {
|
||||||
|
for (const platform of PLATFORMS) {
|
||||||
|
if (platform.prefix && link.url.startsWith(platform.prefix)) {
|
||||||
|
return { type: platform.key, label: link.label, value: link.url.slice(platform.prefix.length) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: 'website', label: link.label, value: link.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(draft: DraftLink): string | null {
|
||||||
|
const platform = platformFor(draft.type);
|
||||||
|
const value = draft.value.trim();
|
||||||
|
if (!value) return null;
|
||||||
|
if (!platform.prefix) return value;
|
||||||
|
return platform.prefix + value.replace(/^@+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLabel(draft: DraftLink): string {
|
||||||
|
const trimmed = draft.label.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
const platform = platformFor(draft.type);
|
||||||
|
return platform.key === 'website' ? '' : platform.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(draft: DraftLink[]): { url: string; label: string }[] {
|
||||||
|
return draft
|
||||||
|
.map((d) => {
|
||||||
|
const url = buildUrl(d);
|
||||||
|
return url ? { url, label: buildLabel(d) } : null;
|
||||||
|
})
|
||||||
|
.filter((l): l is { url: string; label: string } => l !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTOSAVE_DELAY_MS = 800;
|
||||||
|
|
||||||
|
export function LinkListEditor({ links, onSave }: LinkListEditorProps) {
|
||||||
|
const [draft, setDraft] = useState<DraftLink[]>(() => links.map(parseLinkForEditing));
|
||||||
|
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const lastSavedRef = useRef<string>(JSON.stringify(toPayload(links.map(parseLinkForEditing))));
|
||||||
|
|
||||||
|
function persist(nextDraft: DraftLink[], immediate = false) {
|
||||||
|
setDraft(nextDraft);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const payload = toPayload(nextDraft);
|
||||||
|
const serialized = JSON.stringify(payload);
|
||||||
|
if (serialized === lastSavedRef.current) return;
|
||||||
|
setStatus('saving');
|
||||||
|
try {
|
||||||
|
await onSave(payload);
|
||||||
|
lastSavedRef.current = serialized;
|
||||||
|
setStatus('saved');
|
||||||
|
} catch {
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
run();
|
||||||
|
} else {
|
||||||
|
debounceRef.current = setTimeout(run, AUTOSAVE_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRow(index: number, patch: Partial<DraftLink>) {
|
||||||
|
persist(draft.map((d, i) => (i === index ? { ...d, ...patch } : d)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLink() {
|
function addLink() {
|
||||||
setDraft((prev) => [...prev, { url: '', label: '' }]);
|
persist([...draft, { type: 'website', label: '', value: '' }], true);
|
||||||
setDirty(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeLink(index: number) {
|
function removeLink(index: number) {
|
||||||
setDraft((prev) => prev.filter((_, i) => i !== index));
|
persist(draft.filter((_, i) => i !== index), true);
|
||||||
setDirty(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await onSave(draft.filter((l) => l.url.trim()));
|
|
||||||
setDirty(false);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="link-editor">
|
<div className="link-editor">
|
||||||
{draft.map((link, index) => (
|
{draft.map((row, index) => {
|
||||||
|
const platform = platformFor(row.type);
|
||||||
|
return (
|
||||||
<div className="link-row" key={index}>
|
<div className="link-row" key={index}>
|
||||||
|
<select value={row.type} onChange={(e) => updateRow(index, { type: e.target.value })}>
|
||||||
|
{PLATFORMS.map((p) => (
|
||||||
|
<option key={p.key} value={p.key}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<input
|
<input
|
||||||
placeholder="Label (optional)"
|
placeholder="Label (optional)"
|
||||||
value={link.label}
|
value={row.label}
|
||||||
onChange={(e) => updateLink(index, 'label', e.target.value)}
|
onChange={(e) => updateRow(index, { label: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
placeholder="https://…"
|
placeholder={platform.placeholder}
|
||||||
value={link.url}
|
value={row.value}
|
||||||
onChange={(e) => updateLink(index, 'url', e.target.value)}
|
onChange={(e) => updateRow(index, { value: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => removeLink(index)}>
|
<button type="button" className="btn btn-ghost btn-small" onClick={() => removeLink(index)}>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<div className="link-actions">
|
<div className="link-actions">
|
||||||
<button type="button" className="btn btn-small" onClick={addLink}>
|
<button type="button" className="btn btn-small" onClick={addLink}>
|
||||||
+ Add link
|
+ Add link
|
||||||
</button>
|
</button>
|
||||||
{dirty && (
|
<span className="save-status">
|
||||||
<button
|
{status === 'saving' && 'Saving…'}
|
||||||
type="button"
|
{status === 'saved' && 'Saved'}
|
||||||
className="btn btn-primary btn-small"
|
{status === 'error' && 'Could not save'}
|
||||||
onClick={handleSave}
|
</span>
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? 'Saving…' : 'Save links'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents } from 'react-leaflet';
|
import { useRef } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents } from 'react-leaflet';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { City } from '@citytracker/shared';
|
import type { City } from '@citytracker/shared';
|
||||||
import { cityMarkerIcon } from './CityMarker';
|
import { cityMarkerIcon } from './CityMarker';
|
||||||
|
|
||||||
interface CityMapProps {
|
interface CityMapProps {
|
||||||
cities: City[];
|
cities: City[];
|
||||||
|
focusCity?: City;
|
||||||
onMapClick?: (lat: number, lng: number) => void;
|
onMapClick?: (lat: number, lng: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,7 +20,29 @@ function ClickHandler({ onMapClick }: { onMapClick?: (lat: number, lng: number)
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CityMap({ cities, onMapClick }: CityMapProps) {
|
/** Roughly bounds a `radiusKm` circle around a point, for an initial "zoomed to this area" view. */
|
||||||
|
function boundsAroundKm(lat: number, lng: number, radiusKm: number): [[number, number], [number, number]] {
|
||||||
|
const latDelta = radiusKm / 111;
|
||||||
|
const lngDelta = radiusKm / (111 * Math.cos((lat * Math.PI) / 180) || 1);
|
||||||
|
return [
|
||||||
|
[lat - latDelta, lng - lngDelta],
|
||||||
|
[lat + latDelta, lng + lngDelta],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function InitialFocus({ focusCity }: { focusCity?: City }) {
|
||||||
|
const map = useMap();
|
||||||
|
const appliedRef = useRef(false);
|
||||||
|
|
||||||
|
if (focusCity && !appliedRef.current) {
|
||||||
|
appliedRef.current = true;
|
||||||
|
map.fitBounds(boundsAroundKm(focusCity.lat, focusCity.lng, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CityMap({ cities, focusCity, onMapClick }: CityMapProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -28,13 +52,36 @@ export function CityMap({ cities, onMapClick }: CityMapProps) {
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
<ClickHandler onMapClick={onMapClick} />
|
<ClickHandler onMapClick={onMapClick} />
|
||||||
|
<InitialFocus focusCity={focusCity} />
|
||||||
{cities.map((city) => (
|
{cities.map((city) => (
|
||||||
<Marker key={city.id} position={[city.lat, city.lng]} icon={cityMarkerIcon(city)}>
|
<Marker key={city.id} position={[city.lat, city.lng]} icon={cityMarkerIcon(city)}>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="map-popup">
|
<div className="map-popup">
|
||||||
<strong>{city.name}</strong>
|
<strong>{city.name}</strong>
|
||||||
<div>{city.country}</div>
|
<div>{city.country}</div>
|
||||||
<button type="button" className="btn btn-small" onClick={() => navigate(`/cities/${city.id}`)}>
|
{city.photoFilename && (
|
||||||
|
<img
|
||||||
|
className="map-popup-photo"
|
||||||
|
src={`/uploads/${city.photoFilename}`}
|
||||||
|
alt={city.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{city.links.length > 0 && (
|
||||||
|
<ul className="map-popup-links">
|
||||||
|
{city.links.map((link) => (
|
||||||
|
<li key={link.id}>
|
||||||
|
<a href={link.url} target="_blank" rel="noreferrer">
|
||||||
|
{link.label || link.url}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-small"
|
||||||
|
onClick={() => navigate(`/cities/${city.id}`)}
|
||||||
|
>
|
||||||
View details
|
View details
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
19
client/src/components/ui/HeartToggle.tsx
Normal file
19
client/src/components/ui/HeartToggle.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
interface HeartToggleProps {
|
||||||
|
liked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
size?: 'small' | 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeartToggle({ liked, onToggle, size = 'large' }: HeartToggleProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`heart-toggle heart-toggle-${size} ${liked ? 'heart-toggle-liked' : ''}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-pressed={liked}
|
||||||
|
aria-label={liked ? 'Liked' : 'Not liked'}
|
||||||
|
>
|
||||||
|
{liked ? '♥' : '♡'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import type {
|
|
||||||
CreateCityInput,
|
|
||||||
UpdateCityInput,
|
|
||||||
SetParticipantsInput,
|
|
||||||
SetLinksInput,
|
|
||||||
} from '@citytracker/shared';
|
|
||||||
import * as citiesApi from '../api/cities';
|
import * as citiesApi from '../api/cities';
|
||||||
import type { CityListFilters } from '../api/cities';
|
import type { CityListFilters } from '../api/cities';
|
||||||
|
|
||||||
|
|
@ -35,22 +29,6 @@ function useInvalidateCities() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateCity() {
|
|
||||||
const invalidate = useInvalidateCities();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (input: CreateCityInput) => citiesApi.createCity(input),
|
|
||||||
onSuccess: () => invalidate(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateCity(id: number) {
|
|
||||||
const invalidate = useInvalidateCities();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (input: UpdateCityInput) => citiesApi.updateCity(id, input),
|
|
||||||
onSuccess: () => invalidate(id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteCity() {
|
export function useDeleteCity() {
|
||||||
const invalidate = useInvalidateCities();
|
const invalidate = useInvalidateCities();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|
@ -59,22 +37,6 @@ export function useDeleteCity() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSetParticipants(id: number) {
|
|
||||||
const invalidate = useInvalidateCities();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (input: SetParticipantsInput) => citiesApi.setParticipants(id, input),
|
|
||||||
onSuccess: () => invalidate(id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSetLinks(id: number) {
|
|
||||||
const invalidate = useInvalidateCities();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (input: SetLinksInput) => citiesApi.setLinks(id, input),
|
|
||||||
onSuccess: () => invalidate(id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUploadPhoto(id: number) {
|
export function useUploadPhoto(id: number) {
|
||||||
const invalidate = useInvalidateCities();
|
const invalidate = useInvalidateCities();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,16 @@ export function AdminUsersPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEditDisplayName(id: number, current: string | null) {
|
||||||
|
const next = window.prompt('Display name:', current ?? '');
|
||||||
|
if (next === null) return;
|
||||||
|
try {
|
||||||
|
await updateUser.mutateAsync({ id, input: { displayName: next.trim() || null } });
|
||||||
|
} catch {
|
||||||
|
window.alert('Could not update display name. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleResetPassword(id: number) {
|
async function handleResetPassword(id: number) {
|
||||||
const newPassword = window.prompt('Enter a new password (min 8 characters):');
|
const newPassword = window.prompt('Enter a new password (min 8 characters):');
|
||||||
if (!newPassword) return;
|
if (!newPassword) return;
|
||||||
|
|
@ -119,6 +129,13 @@ export function AdminUsersPage() {
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small"
|
||||||
|
onClick={() => handleEditDisplayName(u.id, u.displayName)}
|
||||||
|
>
|
||||||
|
Edit name
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-small"
|
className="btn btn-small"
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,15 @@
|
||||||
import { useEffect, useState, type FormEvent } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import type { CityStatus, GeocodeResult } from '@citytracker/shared';
|
import type { CityStatus, GeocodeResult } from '@citytracker/shared';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import {
|
import { useCity, useDeleteCity, useUploadPhoto, useDeletePhoto } from '../hooks/useCities';
|
||||||
useCity,
|
import * as citiesApi from '../api/cities';
|
||||||
useCreateCity,
|
|
||||||
useUpdateCity,
|
|
||||||
useDeleteCity,
|
|
||||||
useSetParticipants,
|
|
||||||
useSetLinks,
|
|
||||||
useUploadPhoto,
|
|
||||||
useDeletePhoto,
|
|
||||||
} from '../hooks/useCities';
|
|
||||||
import { CitySearchInput } from '../components/city/CitySearchInput';
|
import { CitySearchInput } from '../components/city/CitySearchInput';
|
||||||
import { LinkListEditor } from '../components/city/LinkListEditor';
|
import { LinkListEditor } from '../components/city/LinkListEditor';
|
||||||
import { ParticipantPicker } from '../components/city/ParticipantPicker';
|
import { ParticipantPicker } from '../components/city/ParticipantPicker';
|
||||||
import { PhotoUploader } from '../components/city/PhotoUploader';
|
import { PhotoUploader } from '../components/city/PhotoUploader';
|
||||||
|
import { HeartToggle } from '../components/ui/HeartToggle';
|
||||||
|
|
||||||
interface CityDetailPageProps {
|
interface CityDetailPageProps {
|
||||||
mode: 'new' | 'edit';
|
mode: 'new' | 'edit';
|
||||||
|
|
@ -26,126 +20,211 @@ interface NavState {
|
||||||
lng?: number;
|
lng?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
countryCode: string | null;
|
||||||
|
lat: number | null;
|
||||||
|
lng: number | null;
|
||||||
|
status: CityStatus;
|
||||||
|
visitDateStart: string;
|
||||||
|
visitDateEnd: string;
|
||||||
|
notes: string;
|
||||||
|
liked: boolean;
|
||||||
|
participantIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveStatus = 'idle' | 'unsaved' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
const AUTOSAVE_DELAY_MS = 800;
|
||||||
|
|
||||||
|
function isReadyToSave(f: FormState): boolean {
|
||||||
|
return f.lat !== null && f.lng !== null && f.name.trim() !== '' && f.country.trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
export function CityDetailPage({ mode }: CityDetailPageProps) {
|
export function CityDetailPage({ mode }: CityDetailPageProps) {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const cityId = mode === 'edit' ? Number(id) : undefined;
|
const initialCityId = mode === 'edit' ? Number(id) : undefined;
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const { data: city, isLoading } = useCity(cityId);
|
const { data: city } = useCity(initialCityId);
|
||||||
const createCity = useCreateCity();
|
|
||||||
const updateCity = useUpdateCity(cityId ?? -1);
|
|
||||||
const deleteCity = useDeleteCity();
|
const deleteCity = useDeleteCity();
|
||||||
const setParticipants = useSetParticipants(cityId ?? -1);
|
|
||||||
const setLinks = useSetLinks(cityId ?? -1);
|
const cityIdRef = useRef<number | undefined>(initialCityId);
|
||||||
|
const [cityId, setCityId] = useState<number | undefined>(initialCityId);
|
||||||
const uploadPhoto = useUploadPhoto(cityId ?? -1);
|
const uploadPhoto = useUploadPhoto(cityId ?? -1);
|
||||||
const deletePhotoMutation = useDeletePhoto(cityId ?? -1);
|
const deletePhotoMutation = useDeletePhoto(cityId ?? -1);
|
||||||
|
|
||||||
const navState = location.state as NavState | null;
|
const navState = location.state as NavState | null;
|
||||||
|
|
||||||
const [name, setName] = useState('');
|
const [form, setForm] = useState<FormState>({
|
||||||
const [country, setCountry] = useState('');
|
name: '',
|
||||||
const [countryCode, setCountryCode] = useState<string | null>(null);
|
country: '',
|
||||||
const [lat, setLat] = useState<number | null>(navState?.lat ?? null);
|
countryCode: null,
|
||||||
const [lng, setLng] = useState<number | null>(navState?.lng ?? null);
|
lat: navState?.lat ?? null,
|
||||||
const [status, setStatus] = useState<CityStatus>('visited');
|
lng: navState?.lng ?? null,
|
||||||
const [visitDateStart, setVisitDateStart] = useState('');
|
status: 'visited',
|
||||||
const [visitDateEnd, setVisitDateEnd] = useState('');
|
visitDateStart: '',
|
||||||
const [notes, setNotes] = useState('');
|
visitDateEnd: '',
|
||||||
const [liked, setLiked] = useState(false);
|
notes: '',
|
||||||
const [participantIds, setParticipantIds] = useState<number[]>(user ? [user.id] : []);
|
liked: false,
|
||||||
const [error, setError] = useState<string | null>(null);
|
participantIds: user ? [user.id] : [],
|
||||||
const [submitting, setSubmitting] = useState(false);
|
});
|
||||||
|
|
||||||
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const lastPersistedRef = useRef<string | null>(null);
|
||||||
|
const hydratedForIdRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
// Hydrate local form state from loaded city data exactly once per city id, so a background
|
||||||
|
// refetch triggered by our own autosave doesn't clobber whatever the user has typed since.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (city) {
|
if (city && hydratedForIdRef.current !== city.id) {
|
||||||
setName(city.name);
|
hydratedForIdRef.current = city.id;
|
||||||
setCountry(city.country);
|
const hydrated: FormState = {
|
||||||
setCountryCode(city.countryCode);
|
name: city.name,
|
||||||
setLat(city.lat);
|
country: city.country,
|
||||||
setLng(city.lng);
|
countryCode: city.countryCode,
|
||||||
setStatus(city.status);
|
lat: city.lat,
|
||||||
setVisitDateStart(city.visitDateStart ?? '');
|
lng: city.lng,
|
||||||
setVisitDateEnd(city.visitDateEnd ?? '');
|
status: city.status,
|
||||||
setNotes(city.notes);
|
visitDateStart: city.visitDateStart ?? '',
|
||||||
setLiked(city.liked);
|
visitDateEnd: city.visitDateEnd ?? '',
|
||||||
setParticipantIds(city.participants.map((p) => p.id));
|
notes: city.notes,
|
||||||
|
liked: city.liked,
|
||||||
|
participantIds: city.participants.map((p) => p.id),
|
||||||
|
};
|
||||||
|
setForm(hydrated);
|
||||||
|
lastPersistedRef.current = JSON.stringify(hydrated);
|
||||||
}
|
}
|
||||||
}, [city]);
|
}, [city]);
|
||||||
|
|
||||||
function handleGeocodeSelect(result: GeocodeResult) {
|
async function doSave(next: FormState) {
|
||||||
setName(result.displayName.split(',')[0]);
|
if (!isReadyToSave(next)) return;
|
||||||
setCountry(result.country ?? '');
|
|
||||||
setCountryCode(result.countryCode);
|
|
||||||
setLat(result.lat);
|
|
||||||
setLng(result.lng);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
const snapshot = JSON.stringify(next);
|
||||||
e.preventDefault();
|
if (snapshot === lastPersistedRef.current) return;
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (lat === null || lng === null) {
|
setSaveStatus('saving');
|
||||||
setError('Please select a location by searching or clicking the map.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
try {
|
||||||
if (mode === 'new') {
|
if (cityIdRef.current === undefined) {
|
||||||
const created = await createCity.mutateAsync({
|
const created = await citiesApi.createCity({
|
||||||
name,
|
name: next.name,
|
||||||
country,
|
country: next.country,
|
||||||
countryCode: countryCode ?? null,
|
countryCode: next.countryCode,
|
||||||
lat,
|
lat: next.lat!,
|
||||||
lng,
|
lng: next.lng!,
|
||||||
status,
|
status: next.status,
|
||||||
visitDateStart: visitDateStart || undefined,
|
visitDateStart: next.visitDateStart || undefined,
|
||||||
visitDateEnd: visitDateEnd || undefined,
|
visitDateEnd: next.visitDateEnd || undefined,
|
||||||
notes,
|
notes: next.notes,
|
||||||
liked,
|
liked: next.liked,
|
||||||
participantIds,
|
participantIds: next.participantIds,
|
||||||
});
|
});
|
||||||
|
cityIdRef.current = created.id;
|
||||||
|
hydratedForIdRef.current = created.id;
|
||||||
|
lastPersistedRef.current = snapshot;
|
||||||
|
setCityId(created.id);
|
||||||
|
setSaveStatus('saved');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cities'] });
|
||||||
navigate(`/cities/${created.id}`, { replace: true });
|
navigate(`/cities/${created.id}`, { replace: true });
|
||||||
} else if (cityId) {
|
} else {
|
||||||
await updateCity.mutateAsync({
|
await citiesApi.updateCity(cityIdRef.current, {
|
||||||
name,
|
name: next.name,
|
||||||
country,
|
country: next.country,
|
||||||
countryCode,
|
countryCode: next.countryCode,
|
||||||
lat,
|
lat: next.lat!,
|
||||||
lng,
|
lng: next.lng!,
|
||||||
status,
|
status: next.status,
|
||||||
visitDateStart: visitDateStart || null,
|
visitDateStart: next.visitDateStart || null,
|
||||||
visitDateEnd: visitDateEnd || null,
|
visitDateEnd: next.visitDateEnd || null,
|
||||||
notes,
|
notes: next.notes,
|
||||||
liked,
|
liked: next.liked,
|
||||||
});
|
});
|
||||||
await setParticipants.mutateAsync({ userIds: participantIds });
|
lastPersistedRef.current = snapshot;
|
||||||
|
setSaveStatus('saved');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cities'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['city', cityIdRef.current] });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Could not save the city. Please check the form and try again.');
|
setSaveStatus('error');
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleSave(next: FormState, immediate = false) {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
if (!isReadyToSave(next)) {
|
||||||
|
setSaveStatus('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveStatus('unsaved');
|
||||||
|
if (immediate) {
|
||||||
|
doSave(next);
|
||||||
|
} else {
|
||||||
|
debounceRef.current = setTimeout(() => doSave(next), AUTOSAVE_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField<K extends keyof FormState>(key: K, value: FormState[K], immediate = false) {
|
||||||
|
setForm((prev) => {
|
||||||
|
const next = { ...prev, [key]: value };
|
||||||
|
scheduleSave(next, immediate);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleParticipantsChange(ids: number[]) {
|
||||||
|
setForm((prev) => ({ ...prev, participantIds: ids }));
|
||||||
|
if (cityIdRef.current !== undefined) {
|
||||||
|
try {
|
||||||
|
await citiesApi.setParticipants(cityIdRef.current, { userIds: ids });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['city', cityIdRef.current] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cities'] });
|
||||||
|
} catch {
|
||||||
|
window.alert('Could not update participants — at least one participant is required.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGeocodeSelect(result: GeocodeResult) {
|
||||||
|
setForm((prev) => {
|
||||||
|
const next: FormState = {
|
||||||
|
...prev,
|
||||||
|
name: result.displayName.split(',')[0],
|
||||||
|
country: result.country ?? '',
|
||||||
|
countryCode: result.countryCode,
|
||||||
|
lat: result.lat,
|
||||||
|
lng: result.lng,
|
||||||
|
};
|
||||||
|
scheduleSave(next, true);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!cityId) return;
|
if (!cityIdRef.current) return;
|
||||||
if (!window.confirm(`Delete ${city?.name}? This cannot be undone.`)) return;
|
if (!window.confirm(`Delete ${form.name}? This cannot be undone.`)) return;
|
||||||
await deleteCity.mutateAsync(cityId);
|
await deleteCity.mutateAsync(cityIdRef.current);
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'edit' && isLoading) {
|
async function handleLinksSave(links: { url: string; label: string }[]) {
|
||||||
return <div className="page-loading">Loading…</div>;
|
if (!cityId) return;
|
||||||
|
const updated = await citiesApi.setLinks(cityId, { links });
|
||||||
|
queryClient.setQueryData(['city', cityId], updated);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cities'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isEditing = cityId !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="city-detail-page">
|
<div className="city-detail-page">
|
||||||
<h1>{mode === 'new' ? 'Add a city' : city?.name}</h1>
|
<h1>{isEditing ? form.name : 'Add a city'}</h1>
|
||||||
<form className="city-form" onSubmit={handleSubmit}>
|
<div className="city-form">
|
||||||
{mode === 'new' && (
|
{!isEditing && (
|
||||||
<label>
|
<label>
|
||||||
Search for a city
|
Search for a city
|
||||||
<CitySearchInput onSelect={handleGeocodeSelect} />
|
<CitySearchInput onSelect={handleGeocodeSelect} />
|
||||||
|
|
@ -154,46 +233,57 @@ export function CityDetailPage({ mode }: CityDetailPageProps) {
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label>
|
<label>
|
||||||
Name
|
Name
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} required />
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => updateField('name', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Country
|
Country
|
||||||
<input value={country} onChange={(e) => setCountry(e.target.value)} required />
|
<input
|
||||||
|
value={form.country}
|
||||||
|
onChange={(e) => updateField('country', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{lat !== null && lng !== null && (
|
{form.lat !== null && form.lng !== null && (
|
||||||
<p className="coords-hint">
|
<p className="coords-hint">
|
||||||
Location: {lat.toFixed(4)}, {lng.toFixed(4)}
|
Location: {form.lat.toFixed(4)}, {form.lng.toFixed(4)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label>
|
<label>
|
||||||
Status
|
Status
|
||||||
<select value={status} onChange={(e) => setStatus(e.target.value as CityStatus)}>
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => updateField('status', e.target.value as CityStatus, true)}
|
||||||
|
>
|
||||||
<option value="visited">Visited</option>
|
<option value="visited">Visited</option>
|
||||||
<option value="planned">Planned</option>
|
<option value="planned">Planned</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="like-toggle">
|
<div className="heart-field">
|
||||||
<input type="checkbox" checked={liked} onChange={(e) => setLiked(e.target.checked)} />
|
<span>Liked</span>
|
||||||
♥ Liked
|
<HeartToggle liked={form.liked} onToggle={() => updateField('liked', !form.liked, true)} />
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label>
|
<label>
|
||||||
Visit start date
|
Visit start date
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={visitDateStart}
|
value={form.visitDateStart}
|
||||||
onChange={(e) => setVisitDateStart(e.target.value)}
|
onChange={(e) => updateField('visitDateStart', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Visit end date
|
Visit end date
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={visitDateEnd}
|
value={form.visitDateEnd}
|
||||||
onChange={(e) => setVisitDateEnd(e.target.value)}
|
onChange={(e) => updateField('visitDateEnd', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -201,32 +291,36 @@ export function CityDetailPage({ mode }: CityDetailPageProps) {
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
rows={5}
|
rows={5}
|
||||||
value={notes}
|
value={form.notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => updateField('notes', e.target.value)}
|
||||||
placeholder="What did you like? Tips, restaurants, ..."
|
placeholder="What did you like? Tips, restaurants, ..."
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<ParticipantPicker selectedIds={participantIds} onChange={setParticipantIds} />
|
<ParticipantPicker selectedIds={form.participantIds} onChange={handleParticipantsChange} />
|
||||||
|
|
||||||
{error && <p className="form-error">{error}</p>}
|
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
<span className="save-status">
|
||||||
{submitting ? 'Saving…' : mode === 'new' ? 'Add city' : 'Save changes'}
|
{saveStatus === 'saving' && 'Saving…'}
|
||||||
|
{saveStatus === 'unsaved' && 'Saving…'}
|
||||||
|
{saveStatus === 'saved' && 'All changes saved'}
|
||||||
|
{saveStatus === 'error' && 'Could not save — check your connection'}
|
||||||
|
{saveStatus === 'idle' &&
|
||||||
|
!isEditing &&
|
||||||
|
'Search for a city or click the map, then add a name and country to save'}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => navigate('/')}>
|
||||||
|
Back to map
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => navigate(-1)}>
|
{isEditing && (
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
{mode === 'edit' && (
|
|
||||||
<button type="button" className="btn btn-danger" onClick={handleDelete}>
|
<button type="button" className="btn btn-danger" onClick={handleDelete}>
|
||||||
Delete city
|
Delete city
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
{mode === 'edit' && cityId && city && (
|
{isEditing && cityId && city && (
|
||||||
<>
|
<>
|
||||||
<section className="city-section">
|
<section className="city-section">
|
||||||
<h2>Photo</h2>
|
<h2>Photo</h2>
|
||||||
|
|
@ -238,7 +332,7 @@ export function CityDetailPage({ mode }: CityDetailPageProps) {
|
||||||
</section>
|
</section>
|
||||||
<section className="city-section">
|
<section className="city-section">
|
||||||
<h2>Links</h2>
|
<h2>Links</h2>
|
||||||
<LinkListEditor links={city.links} onSave={(links) => setLinks.mutateAsync({ links })} />
|
<LinkListEditor key={cityId} links={city.links} onSave={handleLinksSave} />
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ export function MapDashboardPage() {
|
||||||
[allCities],
|
[allCities],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Cities are returned newest-first, so the first entry is the most recently added one.
|
||||||
|
const latestCity = allCities?.[0];
|
||||||
|
|
||||||
function handleMapClick(lat: number, lng: number) {
|
function handleMapClick(lat: number, lng: number) {
|
||||||
navigate('/cities/new', { state: { lat, lng } });
|
navigate('/cities/new', { state: { lat, lng } });
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +44,7 @@ export function MapDashboardPage() {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="page-loading">Loading map…</div>
|
<div className="page-loading">Loading map…</div>
|
||||||
) : (
|
) : (
|
||||||
<CityMap cities={cities ?? []} onMapClick={handleMapClick} />
|
<CityMap cities={cities ?? []} focusCity={latestCity} onMapClick={handleMapClick} />
|
||||||
)}
|
)}
|
||||||
<button type="button" className="btn btn-primary fab" onClick={() => navigate('/cities/new')}>
|
<button type="button" className="btn btn-primary fab" onClick={() => navigate('/cities/new')}>
|
||||||
+ Add city
|
+ Add city
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,19 @@ a {
|
||||||
gap: var(--spacing-1);
|
gap: var(--spacing-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-popup-photo {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-popup-links {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* City form */
|
/* City form */
|
||||||
|
|
||||||
.city-detail-page {
|
.city-detail-page {
|
||||||
|
|
@ -372,6 +385,27 @@ a {
|
||||||
gap: var(--spacing-2) !important;
|
gap: var(--spacing-2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heart-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-toggle-small {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-toggle-large {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-toggle-liked {
|
||||||
|
color: #e64980;
|
||||||
|
}
|
||||||
|
|
||||||
.coords-hint {
|
.coords-hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|
@ -381,6 +415,7 @@ a {
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
gap: var(--spacing-2);
|
gap: var(--spacing-2);
|
||||||
margin-top: var(--spacing-2);
|
margin-top: var(--spacing-2);
|
||||||
}
|
}
|
||||||
|
|
@ -389,6 +424,19 @@ a {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.city-section {
|
.city-section {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|
@ -460,11 +508,21 @@ a {
|
||||||
|
|
||||||
.link-row {
|
.link-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: var(--spacing-2);
|
gap: var(--spacing-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-row select {
|
||||||
|
flex: 0 0 140px;
|
||||||
|
padding: var(--spacing-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.link-row input {
|
.link-row input {
|
||||||
flex: 1;
|
flex: 1 1 140px;
|
||||||
|
min-width: 0;
|
||||||
padding: var(--spacing-2);
|
padding: var(--spacing-2);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|
@ -473,7 +531,8 @@ a {
|
||||||
|
|
||||||
.link-actions {
|
.link-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-2);
|
align-items: center;
|
||||||
|
gap: var(--spacing-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Participant picker */
|
/* Participant picker */
|
||||||
|
|
@ -541,6 +600,10 @@ a {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.users-table td .btn + .btn {
|
||||||
|
margin-left: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
.row-inactive {
|
.row-inactive {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue