fix: searchable timezone, flag dropdown for language
- Bring back the dropdown UX for the language picker (the v1.99 button
grid was nicer at first but lost the at-a-glance affordance the user
preferred). New SearchableDropdown component renders a real dropdown
trigger + list, with each option carrying its inline-SVG flag.
- Timezone picker is now searchable. Match against IANA name, code
(CET, ET…), city/country label, and offset in multiple notations
("UTC+1", "+01:00", "GMT+01:00"). Used both for the user's primary
timezone and the additional-timezones add picker.
v1.101.1
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d3a40e32a0
commit
8c0f7acfd8
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.101.0",
|
"version": "1.101.1",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
201
src/components/SearchableDropdown.tsx
Normal file
201
src/components/SearchableDropdown.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { ChevronDown, Search, X } from "lucide-react";
|
||||||
|
|
||||||
|
// Generic select-like dropdown that supports arbitrary node rendering for
|
||||||
|
// each option (so we can show flags, offsets, etc.) and an optional search
|
||||||
|
// box that filters the list against `searchHaystack`.
|
||||||
|
|
||||||
|
export interface DropdownOption<T = string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
searchHaystack?: string; // extra text to match search against (city, country, abbreviation…)
|
||||||
|
leading?: React.ReactNode;
|
||||||
|
secondary?: string; // dimmer text after the label
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchableDropdownProps<T = string> {
|
||||||
|
value: T;
|
||||||
|
options: DropdownOption<T>[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchable?: boolean;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
width?: string | number;
|
||||||
|
darkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchableDropdown<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
searchable = false,
|
||||||
|
searchPlaceholder = "Search…",
|
||||||
|
emptyText = "No matches",
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
width = "100%",
|
||||||
|
darkMode = false,
|
||||||
|
}: SearchableDropdownProps<T>) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && searchable) setTimeout(() => searchRef.current?.focus(), 30);
|
||||||
|
}, [open, searchable]);
|
||||||
|
|
||||||
|
const selected = options.find((o) => o.value === value);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query.trim()) return options;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return options.filter((o) => {
|
||||||
|
const hay = (o.searchHaystack || `${o.label} ${o.secondary || ""}`).toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}, [options, query]);
|
||||||
|
|
||||||
|
const bg = darkMode ? "#1f2937" : "#ffffff";
|
||||||
|
const border = darkMode ? "#374151" : "#d1d5db";
|
||||||
|
const text = darkMode ? "#e5e7eb" : "#111827";
|
||||||
|
const muted = darkMode ? "#9ca3af" : "#6b7280";
|
||||||
|
const hoverBg = darkMode ? "#374151" : "#f3f4f6";
|
||||||
|
const activeBg = darkMode ? "rgba(13,148,136,0.18)" : "rgba(13,148,136,0.08)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className={className} style={{ position: "relative", width, ...style }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
background: bg,
|
||||||
|
color: text,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected?.leading && <span style={{ display: "inline-flex" }}>{selected.leading}</span>}
|
||||||
|
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{selected ? selected.label : (placeholder || "Select…")}
|
||||||
|
</span>
|
||||||
|
{selected?.secondary && (
|
||||||
|
<span style={{ color: muted, fontSize: "0.8rem", whiteSpace: "nowrap" }}>{selected.secondary}</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown size={14} style={{ color: muted, flexShrink: 0, transform: open ? "rotate(180deg)" : "none", transition: "transform 0.15s" }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "calc(100% + 4px)",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
background: bg,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
maxHeight: 320,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchable && (
|
||||||
|
<div style={{ padding: 8, borderBottom: `1px solid ${border}`, display: "flex", alignItems: "center", gap: 6, background: bg }}>
|
||||||
|
<Search size={14} style={{ color: muted, flexShrink: 0 }} />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: text,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
style={{ background: "none", border: "none", cursor: "pointer", color: muted, padding: 2 }}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div style={{ padding: 16, color: muted, fontSize: "0.85rem", textAlign: "center" }}>{emptyText}</div>
|
||||||
|
) : filtered.map((opt) => {
|
||||||
|
const active = opt.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={String(opt.value)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(opt.value);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 10px",
|
||||||
|
border: "none",
|
||||||
|
background: active ? activeBg : "transparent",
|
||||||
|
color: text,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = hoverBg; }}
|
||||||
|
onMouseLeave={(e) => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
{opt.leading && <span style={{ display: "inline-flex", flexShrink: 0 }}>{opt.leading}</span>}
|
||||||
|
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{opt.label}</span>
|
||||||
|
{opt.secondary && <span style={{ color: muted, fontSize: "0.78rem", whiteSpace: "nowrap" }}>{opt.secondary}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -36,7 +36,8 @@ import IconPicker from "./IconPicker";
|
|||||||
import { allIcons } from "./iconRegistry";
|
import { allIcons } from "./iconRegistry";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import FlagIcon from "./FlagIcon";
|
import FlagIcon from "./FlagIcon";
|
||||||
import { TIMEZONE_OPTIONS, formatTimezone } from "../lib/timezones";
|
import SearchableDropdown from "./SearchableDropdown";
|
||||||
|
import { TIMEZONE_OPTIONS, formatTimezone, getOffsetMinutes, formatOffset } from "../lib/timezones";
|
||||||
|
|
||||||
// Punkt 6 — manage a user's extra timezones (for cross-team scheduling).
|
// Punkt 6 — manage a user's extra timezones (for cross-team scheduling).
|
||||||
// Stored in user.viewSettings.extraTimezones to avoid an extra migration.
|
// Stored in user.viewSettings.extraTimezones to avoid an extra migration.
|
||||||
@ -104,17 +105,33 @@ function ExtraTimezonesEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: "flex", gap: "6px" }}>
|
<div style={{ display: "flex", gap: "6px" }}>
|
||||||
<select
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<SearchableDropdown
|
||||||
value={picker}
|
value={picker}
|
||||||
onChange={(e) => setPicker(e.target.value)}
|
onChange={(v) => setPicker(v)}
|
||||||
className="weekly-input"
|
searchable
|
||||||
style={{ flex: 1, padding: "6px 8px", border: "1px solid var(--weekly-border, #e5e7eb)", borderRadius: "6px", fontSize: "0.85rem" }}
|
placeholder={de ? "Zeitzone auswählen…" : "Select a timezone…"}
|
||||||
>
|
searchPlaceholder={de ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
|
||||||
<option value="">{de ? "Zeitzone auswählen…" : "Select a timezone…"}</option>
|
emptyText={de ? "Keine Treffer" : "No matches"}
|
||||||
{TIMEZONE_OPTIONS.filter((o) => !extras.includes(o.zone) && o.zone !== profile.timezone).map((opt) => (
|
options={TIMEZONE_OPTIONS
|
||||||
<option key={opt.zone} value={opt.zone}>{formatTimezone(opt)}</option>
|
.filter((o) => !extras.includes(o.zone) && o.zone !== profile.timezone)
|
||||||
))}
|
.sort((a, b) => {
|
||||||
</select>
|
const oa = getOffsetMinutes(a.zone);
|
||||||
|
const ob = getOffsetMinutes(b.zone);
|
||||||
|
if (oa !== ob) return oa - ob;
|
||||||
|
return a.code.localeCompare(b.code);
|
||||||
|
})
|
||||||
|
.map((opt) => {
|
||||||
|
const off = formatOffset(getOffsetMinutes(opt.zone));
|
||||||
|
return {
|
||||||
|
value: opt.zone,
|
||||||
|
label: `${opt.code} — ${opt.label}`,
|
||||||
|
secondary: `UTC${off}`,
|
||||||
|
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => add(picker)}
|
onClick={() => add(picker)}
|
||||||
disabled={!picker}
|
disabled={!picker}
|
||||||
@ -1690,51 +1707,17 @@ function SettingsSidebar({
|
|||||||
>
|
>
|
||||||
{t.language}
|
{t.language}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<SearchableDropdown
|
||||||
role="radiogroup"
|
value={profile.language || "de"}
|
||||||
style={{
|
onChange={(v) => saveField("language", v)}
|
||||||
display: "grid",
|
options={[
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
|
{ value: "en", label: "English", leading: <FlagIcon code="en" width={20} height={14} /> },
|
||||||
gap: "6px",
|
{ value: "de", label: "Deutsch", leading: <FlagIcon code="de" width={20} height={14} /> },
|
||||||
}}
|
{ value: "fr", label: "Français", leading: <FlagIcon code="fr" width={20} height={14} /> },
|
||||||
>
|
{ value: "es", label: "Español", leading: <FlagIcon code="es" width={20} height={14} /> },
|
||||||
{[
|
{ value: "it", label: "Italiano", leading: <FlagIcon code="it" width={20} height={14} /> },
|
||||||
{ code: "en", label: "English" },
|
]}
|
||||||
{ code: "de", label: "Deutsch" },
|
/>
|
||||||
{ code: "fr", label: "Français" },
|
|
||||||
{ code: "es", label: "Español" },
|
|
||||||
{ code: "it", label: "Italiano" },
|
|
||||||
].map((lang) => {
|
|
||||||
const active = (profile.language || "de") === lang.code;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={lang.code}
|
|
||||||
role="radio"
|
|
||||||
aria-checked={active}
|
|
||||||
onClick={() => saveField("language", lang.code)}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
padding: "8px 10px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: active
|
|
||||||
? "2px solid var(--weekly-teal, #009a9a)"
|
|
||||||
: "1px solid var(--weekly-border, #e5e7eb)",
|
|
||||||
background: active ? "rgba(13,148,136,0.08)" : "var(--weekly-bg, #fff)",
|
|
||||||
color: "var(--weekly-text, #333)",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
fontWeight: active ? 600 : 400,
|
|
||||||
transition: "all 0.15s",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FlagIcon code={lang.code} width={20} height={14} />
|
|
||||||
<span>{lang.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -1748,33 +1731,31 @@ function SettingsSidebar({
|
|||||||
>
|
>
|
||||||
{t.timezone}
|
{t.timezone}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<SearchableDropdown
|
||||||
value={profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}
|
value={profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||||
onChange={(e) => saveField("timezone", e.target.value)}
|
onChange={(v) => saveField("timezone", v)}
|
||||||
className="weekly-input"
|
searchable
|
||||||
style={{
|
searchPlaceholder={profile.language === "de" ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
|
||||||
width: "100%",
|
emptyText={profile.language === "de" ? "Keine Treffer" : "No matches"}
|
||||||
padding: "8px",
|
options={[...TIMEZONE_OPTIONS]
|
||||||
border: "1px solid var(--weekly-settings-input-border, #ddd)",
|
.sort((a, b) => {
|
||||||
borderRadius: "4px",
|
const oa = getOffsetMinutes(a.zone);
|
||||||
background: "var(--weekly-settings-input-bg, #fff)",
|
const ob = getOffsetMinutes(b.zone);
|
||||||
color: "var(--weekly-settings-text, #333)",
|
if (oa !== ob) return oa - ob;
|
||||||
}}
|
return a.code.localeCompare(b.code);
|
||||||
>
|
})
|
||||||
{(() => {
|
.map((opt) => {
|
||||||
const sorted = [...TIMEZONE_OPTIONS].sort((a, b) => {
|
const off = formatOffset(getOffsetMinutes(opt.zone));
|
||||||
const oa = new Date().toLocaleString("en-US", { timeZone: a.zone, timeZoneName: "shortOffset" });
|
return {
|
||||||
const ob = new Date().toLocaleString("en-US", { timeZone: b.zone, timeZoneName: "shortOffset" });
|
value: opt.zone,
|
||||||
return oa.localeCompare(ob);
|
label: `${opt.code} — ${opt.label}`,
|
||||||
});
|
secondary: `UTC${off}`,
|
||||||
// Use formatTimezone for label so each option shows offset + code + label
|
// Match against IANA name (zone), code, label,
|
||||||
return sorted.map((opt) => (
|
// and the offset in several styles.
|
||||||
<option key={opt.zone} value={opt.zone}>
|
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
|
||||||
{formatTimezone(opt)}
|
};
|
||||||
</option>
|
})}
|
||||||
));
|
/>
|
||||||
})()}
|
|
||||||
</select>
|
|
||||||
{/* Multi-timezone (Punkt 6) */}
|
{/* Multi-timezone (Punkt 6) */}
|
||||||
<ExtraTimezonesEditor
|
<ExtraTimezonesEditor
|
||||||
profile={profile}
|
profile={profile}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user