feat: implement hide app name feature with toggle in admin settings and update branding context
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled

This commit is contained in:
2026-03-04 10:11:35 +01:00
parent bac4e8ae7c
commit ce2cf499dc
11 changed files with 627 additions and 61 deletions

101
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"better-sqlite3": "^11.0.0",
"concurrently": "^9.0.0",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"dotenv": "^16.4.0",
"express": "^4.21.0",
"express-rate-limit": "^7.5.1",
@@ -24,6 +25,7 @@
"pg": "^8.18.0",
"rate-limit-redis": "^4.3.1",
"react": "^18.3.0",
"react-datepicker": "^9.1.0",
"react-dom": "^18.3.0",
"react-hot-toast": "^2.4.0",
"react-router-dom": "^6.28.0",
@@ -777,6 +779,59 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.19",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz",
"integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.8",
"@floating-ui/utils": "^0.2.11",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
@@ -1840,6 +1895,15 @@
"node": ">=12"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@ -2007,6 +2071,16 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3881,6 +3955,27 @@
"node": ">=0.10.0"
}
},
"node_modules/react-datepicker": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-9.1.0.tgz",
"integrity": "sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.15",
"clsx": "^2.1.1",
"date-fns": "^4.1.0"
},
"peerDependencies": {
"date-fns-tz": "^3.0.0",
"react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"date-fns-tz": {
"optional": true
}
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -4510,6 +4605,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",

View File

@@ -17,6 +17,7 @@
"better-sqlite3": "^11.0.0",
"concurrently": "^9.0.0",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"dotenv": "^16.4.0",
"express": "^4.21.0",
"express-rate-limit": "^7.5.1",
@@ -28,6 +29,7 @@
"pg": "^8.18.0",
"rate-limit-redis": "^4.3.1",
"react": "^18.3.0",
"react-datepicker": "^9.1.0",
"react-dom": "^18.3.0",
"react-hot-toast": "^2.4.0",
"react-router-dom": "^6.28.0",

View File

@@ -108,6 +108,8 @@ router.get('/', async (req, res) => {
}
} catch { /* not configured */ }
const hideAppName = await getSetting('hide_app_name');
res.json({
appName: appName || 'Redlight',
hasLogo: !!logoFile,
@@ -118,6 +120,7 @@ router.get('/', async (req, res) => {
privacyUrl: privacyUrl || null,
oauthEnabled,
oauthDisplayName,
hideAppName: hideAppName === 'true',
});
} catch (err) {
log.branding.error('Get branding error:', err);
@@ -282,4 +285,23 @@ router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) =>
}
});
// PUT /api/branding/hide-app-name - Toggle app name visibility (admin only)
router.put('/hide-app-name', authenticateToken, requireAdmin, async (req, res) => {
try {
const { hideAppName } = req.body;
if (typeof hideAppName !== 'boolean') {
return res.status(400).json({ error: 'hideAppName must be a boolean' });
}
if (hideAppName) {
await setSetting('hide_app_name', 'true');
} else {
await deleteSetting('hide_app_name');
}
res.json({ hideAppName });
} catch (err) {
log.branding.error('Update hide app name error:', err);
res.status(500).json({ error: 'Could not update setting' });
}
});
export default router;

View File

@@ -8,7 +8,7 @@ const sizes = {
};
export default function BrandLogo({ size = 'md', className = '' }) {
const { appName, hasLogo, logoUrl } = useBranding();
const { appName, hasLogo, logoUrl, hideAppName } = useBranding();
const s = sizes[size] || sizes.md;
if (hasLogo && logoUrl) {
@@ -19,7 +19,7 @@ export default function BrandLogo({ size = 'md', className = '' }) {
alt={appName}
className={`${s.box} ${s.rounded} object-contain`}
/>
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
{!hideAppName && <span className={`${s.text} font-bold gradient-text`}>{appName}</span>}
</div>
);
}

View File

@@ -0,0 +1,126 @@
import ReactDatePicker from 'react-datepicker';
import { de } from 'date-fns/locale';
import { Calendar as CalendarIcon, Clock, X } from 'lucide-react';
import { forwardRef } from 'react';
import 'react-datepicker/dist/react-datepicker.css';
/**
* Custom DateTimePicker that reads the app's CSS variables and
* fully matches whatever theme is active.
*
* Props:
* value ISO datetime string (or '')
* onChange (isoString) => void
* label string
* required bool
* minDate Date | null
* icon 'calendar' (default) | 'clock'
*/
export default function DateTimePicker({
value,
onChange,
label,
required = false,
minDate = null,
icon = 'calendar',
}) {
const selected = value ? new Date(value) : null;
const handleChange = (date) => {
if (!date) { onChange(''); return; }
// Produce local datetime string yyyy-MM-ddTHH:mm
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const mi = String(date.getMinutes()).padStart(2, '0');
onChange(`${y}-${mo}-${d}T${h}:${mi}`);
};
const Icon = icon === 'clock' ? Clock : CalendarIcon;
return (
<div className="dtp-wrapper">
{label && (
<label className="block text-sm font-medium text-th-text mb-1.5">
{label}{required && ' *'}
</label>
)}
<div className="dtp-input-wrap">
<Icon size={15} className="dtp-icon" />
<ReactDatePicker
selected={selected}
onChange={handleChange}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="dd.MM.yyyy HH:mm"
locale={de}
minDate={minDate}
required={required}
popperPlacement="bottom-start"
popperModifiers={[
{ name: 'offset', options: { offset: [0, 4] } },
{ name: 'preventOverflow', options: { rootBoundary: 'viewport', tether: false, altAxis: true } },
]}
customInput={<CustomInput />}
calendarClassName="dtp-calendar"
dayClassName={(date) => 'dtp-day'}
timeClassName={() => 'dtp-time-item'}
renderCustomHeader={CalendarHeader}
isClearable
clearButtonClassName="dtp-clear-btn"
wrapperClassName="dtp-dp-wrapper"
/>
</div>
</div>
);
}
// ── Custom Input ─────────────────────────────────────────────────────────────
const CustomInput = forwardRef(({ value, onClick, onClear, ...rest }, ref) => (
<button
ref={ref}
type="button"
onClick={onClick}
className="dtp-custom-input"
{...rest}
>
<span className={value ? 'dtp-value' : 'dtp-placeholder'}>
{value || 'Datum & Uhrzeit wählen …'}
</span>
</button>
));
CustomInput.displayName = 'CustomInput';
// ── Custom Header ─────────────────────────────────────────────────────────────
function CalendarHeader({
date,
decreaseMonth, increaseMonth,
prevMonthButtonDisabled, nextMonthButtonDisabled,
}) {
const label = date.toLocaleString('de', { month: 'long', year: 'numeric' });
return (
<div className="dtp-header">
<button
type="button"
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
className="dtp-nav-btn"
aria-label="Vorheriger Monat"
>
</button>
<span className="dtp-month-label">{label}</span>
<button
type="button"
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
className="dtp-nav-btn"
aria-label="Nächster Monat"
>
</button>
</div>
);
}

View File

@@ -13,6 +13,7 @@ export function BrandingProvider({ children }) {
defaultTheme: null,
imprintUrl: null,
privacyUrl: null,
hideAppName: false,
});
const fetchBranding = useCallback(async () => {

View File

@@ -373,6 +373,9 @@
"appNameLabel": "App-Name",
"appNameUpdated": "App-Name aktualisiert",
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden",
"hideAppNameLabel": "App-Namen ausblenden",
"hideAppNameHint": "Nur das Logo anzeigen, den App-Namen daneben ausblenden.",
"hideAppNameFailed": "Einstellung konnte nicht gespeichert werden",
"defaultThemeLabel": "Standard-Theme",
"defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.",
"defaultThemeSaved": "Standard-Theme gespeichert",

View File

@@ -373,6 +373,9 @@
"appNameLabel": "App name",
"appNameUpdated": "App name updated",
"appNameUpdateFailed": "Could not update app name",
"hideAppNameLabel": "Hide app name",
"hideAppNameHint": "Only show the logo, hide the app name text next to it.",
"hideAppNameFailed": "Could not update setting",
"defaultThemeLabel": "Default Theme",
"defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.",
"defaultThemeSaved": "Default theme saved",

View File

@@ -714,39 +714,6 @@
transition-all duration-200;
}
/* ── Styled date/time picker wrapper ── */
.datetime-picker {
@apply relative;
}
.datetime-picker .datetime-icon {
@apply absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-th-text-s z-10;
}
.datetime-picker input[type="datetime-local"],
.datetime-picker input[type="date"],
.datetime-picker input[type="time"] {
@apply w-full pl-11 pr-3 py-2.5 rounded-lg
bg-th-input text-th-text
border border-th-input-b
focus:outline-none focus:ring-2 focus:ring-th-ring focus:border-transparent
transition-all duration-200;
font-variant-numeric: tabular-nums;
}
.datetime-picker input::-webkit-calendar-picker-indicator {
cursor: pointer;
border-radius: 4px;
padding: 4px;
opacity: 0.6;
transition: opacity 0.15s;
filter: var(--picker-icon-filter, none);
}
.datetime-picker input::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
.card {
@apply bg-th-card rounded-xl border border-th-border
shadow-th transition-all duration-200;
@@ -772,3 +739,317 @@
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
}
}
/* ═══════════════════════════════════════════════════════════════
CUSTOM DATE/TIME PICKER — fully themed via CSS variables
═══════════════════════════════════════════════════════════════ */
/* ── Wrapper & trigger button ─────────────────────────────────── */
.dtp-dp-wrapper {
width: 100%;
}
.dtp-input-wrap {
position: relative;
width: 100%;
}
.dtp-icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
pointer-events: none;
z-index: 1;
}
.dtp-custom-input {
width: 100%;
display: flex;
align-items: center;
padding: 0.625rem 0.875rem 0.625rem 2.5rem;
border-radius: 0.5rem;
background: var(--input-bg);
border: 1px solid var(--input-border);
color: var(--text-primary);
font-size: 0.875rem;
text-align: left;
transition: border-color 0.15s, box-shadow 0.15s;
cursor: pointer;
line-height: 1.5;
}
.dtp-custom-input:focus,
.dtp-custom-input:focus-visible {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px var(--ring);
}
.dtp-custom-input:hover {
border-color: var(--accent);
}
.dtp-placeholder {
color: var(--text-secondary);
}
.dtp-value {
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
/* ── Popper & calendar container ──────────────────────────────── */
.react-datepicker-popper {
z-index: 100 !important;
}
.dtp-calendar.react-datepicker {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 0.75rem;
box-shadow: 0 10px 25px -5px var(--shadow-color), 0 4px 10px -6px var(--shadow-color);
font-family: inherit;
font-size: 0.8125rem;
color: var(--text-primary);
overflow: hidden;
display: flex;
}
/* hide the default triangle/arrow */
.react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle,
.react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle {
display: none;
}
/* ── Header (custom rendered) ─────────────────────────────────── */
.dtp-calendar .react-datepicker__header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 0;
}
.dtp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.75rem 0.5rem;
}
.dtp-month-label {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
letter-spacing: 0.01em;
}
.dtp-nav-btn {
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1.2rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
line-height: 1;
padding-bottom: 1px;
}
.dtp-nav-btn:hover:not(:disabled) {
background: var(--hover-bg);
color: var(--text-primary);
}
.dtp-nav-btn:disabled {
opacity: 0.3;
cursor: default;
}
/* ── Day names row ────────────────────────────────────────────── */
.dtp-calendar .react-datepicker__day-names {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-around;
padding: 0.25rem 0.5rem 0.375rem;
margin: 0;
}
.dtp-calendar .react-datepicker__day-name {
color: var(--text-secondary);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
width: 2rem;
text-align: center;
line-height: 1.5;
}
/* ── Month / days grid ────────────────────────────────────────── */
.dtp-calendar .react-datepicker__month {
background: var(--card-bg);
padding: 0.5rem;
margin: 0;
}
.dtp-calendar .react-datepicker__week {
display: flex;
justify-content: space-around;
}
.dtp-calendar .react-datepicker__day {
width: 2rem;
height: 2rem;
line-height: 2rem;
border-radius: 0.375rem;
color: var(--text-primary);
font-size: 0.8125rem;
transition: background 0.12s, color 0.12s;
margin: 0.1rem;
text-align: center;
}
.dtp-calendar .react-datepicker__day:hover:not(.react-datepicker__day--selected):not(.react-datepicker__day--disabled) {
background: var(--hover-bg);
color: var(--text-primary);
border-radius: 0.375rem;
}
.dtp-calendar .react-datepicker__day--selected,
.dtp-calendar .react-datepicker__day--keyboard-selected {
background: var(--accent) !important;
color: var(--accent-text) !important;
border-radius: 0.375rem;
font-weight: 600;
}
.dtp-calendar .react-datepicker__day--today:not(.react-datepicker__day--selected) {
font-weight: 700;
color: var(--accent);
position: relative;
}
.dtp-calendar .react-datepicker__day--today:not(.react-datepicker__day--selected)::after {
content: '';
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent);
}
.dtp-calendar .react-datepicker__day--outside-month {
color: var(--text-secondary);
opacity: 0.4;
}
.dtp-calendar .react-datepicker__day--disabled {
color: var(--text-secondary);
opacity: 0.3;
cursor: not-allowed;
}
/* ── Time column ──────────────────────────────────────────────── */
.dtp-calendar .react-datepicker__time-container {
border-left: 1px solid var(--border);
background: var(--card-bg);
width: 90px;
}
.dtp-calendar .react-datepicker__time-container .react-datepicker__header {
padding: 0.5rem;
text-align: center;
}
.dtp-calendar .react-datepicker__header--time {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 0.5rem !important;
}
.dtp-calendar .react-datepicker__header--time .react-datepicker-time__header {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dtp-calendar .react-datepicker__time {
background: var(--card-bg);
}
.dtp-calendar .react-datepicker__time-box {
width: 90px !important;
}
.dtp-calendar .react-datepicker__time-list {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.dtp-calendar .react-datepicker__time-list-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0 !important;
height: auto !important;
font-size: 0.8125rem;
color: var(--text-primary);
transition: background 0.1s, color 0.1s;
border-radius: 0;
}
.dtp-calendar .react-datepicker__time-list-item:hover:not(.react-datepicker__time-list-item--selected) {
background: var(--hover-bg) !important;
color: var(--text-primary);
}
.dtp-calendar .react-datepicker__time-list-item--selected {
background: var(--accent) !important;
color: var(--accent-text) !important;
font-weight: 600;
}
.dtp-calendar .react-datepicker__time-list-item--disabled {
color: var(--text-secondary);
opacity: 0.3;
}
/* ── Clear button ─────────────────────────────────────────────── */
.react-datepicker__close-icon {
right: 0.5rem;
padding: 0;
}
.react-datepicker__close-icon::after {
background: var(--bg-tertiary) !important;
color: var(--text-secondary) !important;
font-size: 0.875rem;
width: 1.25rem;
height: 1.25rem;
line-height: 1.25rem;
border-radius: 0.25rem;
transition: background 0.15s, color 0.15s;
}
.react-datepicker__close-icon:hover::after {
background: var(--error) !important;
color: #fff !important;
}
/* ── Month navigation (react-datepicker default, hidden since custom header used) ── */
.dtp-calendar .react-datepicker__navigation {
display: none;
}

View File

@@ -16,7 +16,7 @@ import toast from 'react-hot-toast';
export default function Admin() {
const { user } = useAuth();
const { t, language } = useLanguage();
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, refreshBranding } = useBranding();
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, hideAppName, refreshBranding } = useBranding();
const navigate = useNavigate();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -47,6 +47,7 @@ export default function Admin() {
const [savingImprintUrl, setSavingImprintUrl] = useState(false);
const [editPrivacyUrl, setEditPrivacyUrl] = useState('');
const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false);
const [savingHideAppName, setSavingHideAppName] = useState(false);
// OAuth state
const [oauthConfig, setOauthConfig] = useState(null);
@@ -168,6 +169,18 @@ export default function Admin() {
}
};
const handleHideAppNameToggle = async (value) => {
setSavingHideAppName(true);
try {
await api.put('/branding/hide-app-name', { hideAppName: value });
refreshBranding();
} catch {
toast.error(t('admin.hideAppNameFailed'));
} finally {
setSavingHideAppName(false);
}
};
const handleAppNameSave = async () => {
if (!editAppName.trim()) return;
setSavingName(true);
@@ -447,6 +460,28 @@ export default function Admin() {
{savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
</div>
{hasLogo && (
<div className="flex items-center justify-between mt-3 p-3 rounded-lg bg-th-bg-s border border-th-border">
<div className="min-w-0">
<p className="text-sm font-medium text-th-text">{t('admin.hideAppNameLabel')}</p>
<p className="text-xs text-th-text-s mt-0.5">{t('admin.hideAppNameHint')}</p>
</div>
<button
type="button"
disabled={savingHideAppName}
onClick={() => handleHideAppNameToggle(!hideAppName)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-1 disabled:opacity-50 ml-4 ${
hideAppName ? 'bg-th-accent' : 'bg-th-border'
}`}
aria-checked={hideAppName}
role="switch"
>
<span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
hideAppName ? 'translate-x-4' : 'translate-x-0'
}`} />
</button>
</div>
)}
</div>
</div>

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useMemo } from 'react';
import {
ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, Clock, Video,
ChevronLeft, ChevronRight, Plus, Clock, Video,
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
} from 'lucide-react';
import api from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import Modal from '../components/Modal';
import DateTimePicker from '../components/DateTimePicker';
import toast from 'react-hot-toast';
const COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6'];
@@ -533,30 +534,21 @@ export default function Calendar() {
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.startTime')} *</label>
<div className="datetime-picker">
<CalendarIcon size={16} className="datetime-icon" />
<input
type="datetime-local"
value={form.start_time}
onChange={e => setForm({ ...form, start_time: e.target.value })}
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.endTime')} *</label>
<div className="datetime-picker">
<Clock size={16} className="datetime-icon" />
<input
type="datetime-local"
value={form.end_time}
onChange={e => setForm({ ...form, end_time: e.target.value })}
required
/>
</div>
</div>
<DateTimePicker
label={t('calendar.startTime')}
value={form.start_time}
onChange={v => setForm({ ...form, start_time: v })}
required
icon="calendar"
/>
<DateTimePicker
label={t('calendar.endTime')}
value={form.end_time}
onChange={v => setForm({ ...form, end_time: v })}
required
icon="clock"
minDate={form.start_time ? new Date(form.start_time) : null}
/>
</div>
<div className="flex items-center gap-1.5 -mt-2 text-xs text-th-text-s">
<Globe size={12} className="flex-shrink-0" />