+
Scheduled
-
+
{{ scheduledContent.length }}
@@ -128,13 +140,15 @@ const canViewGroups = hasAnyPermission(DocType.Group, AclPermission.CmsView);
-
+
Groups
-
+
{{ groups.length }}
diff --git a/cms/src/components/dashboard/LanguageCoverageCard.vue b/cms/src/components/dashboard/LanguageCoverageCard.vue
index 5a82d98746..aa053233cd 100644
--- a/cms/src/components/dashboard/LanguageCoverageCard.vue
+++ b/cms/src/components/dashboard/LanguageCoverageCard.vue
@@ -35,20 +35,24 @@ const maxContentCount = computed(() => {
{{ lang.name }}
({{ lang.languageCode }})
-
+
{{ contentCountPerLanguage[lang._id] ?? 0 }}
-
+
0" fillHeight>
-
-
Needs translation
+
+
+ Needs translation
+
@@ -106,11 +108,11 @@ watch(missingTranslations, () => nextTick(update));
diff --git a/cms/src/components/dashboard/RecentActivityCard.vue b/cms/src/components/dashboard/RecentActivityCard.vue
index e226ae1c7b..c6b8cf279f 100644
--- a/cms/src/components/dashboard/RecentActivityCard.vue
+++ b/cms/src/components/dashboard/RecentActivityCard.vue
@@ -30,17 +30,17 @@ function formatRelativeTime(timestamp: number): string {
-
+
No content found for the selected language.
-
+
{{ doc.title || "Untitled" }}
diff --git a/cms/src/components/editor/RichTextEditor.vue b/cms/src/components/editor/RichTextEditor.vue
index 281c2f32ab..2acfb6d1c4 100644
--- a/cms/src/components/editor/RichTextEditor.vue
+++ b/cms/src/components/editor/RichTextEditor.vue
@@ -92,21 +92,21 @@ const toolbarGrouping: ToolbarItem[][] = [
// applied per-button in the toolbar slot so that a button nested inside a wrapper
// (e.g. the download dropdown trigger) stays square instead of rounding both edges.
const toolbarButtonClass =
- "!rounded-none !border-0 !shadow-none !bg-zinc-100 px-2 py-1.5 text-sm text-zinc-700 hover:!bg-zinc-200 active:!bg-zinc-300";
+ "!rounded-none !border-0 !shadow-none !bg-zinc-100 dark:!bg-slate-800 px-2 py-1.5 text-sm text-zinc-700 dark:text-zinc-300 hover:!bg-zinc-200 dark:hover:!bg-slate-700 active:!bg-zinc-300 dark:active:!bg-slate-600";
const toolbarClasses = {
root: "flex min-h-0 flex-1 flex-col",
header: "flex items-center gap-2",
- icon: "h-6 w-6 text-zinc-600",
- title: "text-sm font-medium text-zinc-700",
+ icon: "h-6 w-6 text-zinc-600 dark:text-zinc-400",
+ title: "text-sm font-medium text-zinc-700 dark:text-zinc-300",
toolbar: "flex flex-nowrap items-center gap-4 overflow-x-auto scrollbar-hide",
toolbarGroup: "flex shrink-0 !gap-0 !overflow-hidden !rounded-md !shadow-none pb-0",
button: `${toolbarButtonClass} first:!rounded-l-md last:!rounded-r-md`,
- buttonActive: "!bg-zinc-300",
+ buttonActive: "!bg-zinc-300 dark:!bg-slate-600",
editor: "flex min-h-0 flex-1 flex-col overflow-hidden",
editorContent:
- "prose prose-zinc lg:prose-sm max-w-none min-h-0 flex-1 border-0 bg-white px-0 py-0 ring-0 focus:outline-none focus:ring-0 rounded-none lg:h-full",
- placeholder: "text-zinc-400",
+ "prose prose-zinc dark:prose-invert lg:prose-sm max-w-none min-h-0 flex-1 border-0 bg-white dark:bg-slate-800 px-0 py-0 ring-0 focus:outline-none focus:ring-0 rounded-none lg:h-full",
+ placeholder: "text-zinc-400 dark:text-zinc-500",
} as const;
function openLinkModal() {
@@ -193,75 +193,88 @@ defineExpose({
/>
-
-
-
-
-
+
+
+
+
+
+
+
+
+
void,
+ )
+ "
>
-
+ {{ format.label }}
-
+
+
void,
)
"
>
- {{ format.label }}
+
+
+
+
+
+
+
+ {{ getLabel(item) }}
-
-
-
void,
- )
- "
- >
-
-
-
-
-
-
-
- {{ getLabel(item) }}
-
-
-
+
+
@@ -334,7 +347,7 @@ defineExpose({
margin-bottom: 0 !important;
border-radius: 0 !important;
padding: 0 !important;
- background-color: #fff;
+ background-color: transparent;
box-shadow: none !important;
}
@@ -405,6 +418,10 @@ defineExpose({
scrollbar-color: #d4d4d8 transparent;
}
+ :root.dark :deep(.rte-editor-content .ProseMirror) {
+ scrollbar-color: #3f3f46 transparent;
+ }
+
/* WebKit/Blink: 8px thumb, pill-shaped, inset 2px so it reads as ~4px. */
:deep(.rte-editor-content .ProseMirror::-webkit-scrollbar) {
width: 8px;
@@ -421,8 +438,16 @@ defineExpose({
background-color: #d4d4d8;
}
+ :root.dark :deep(.rte-editor-content .ProseMirror::-webkit-scrollbar-thumb) {
+ background-color: #3f3f46;
+ }
+
:deep(.rte-editor-content .ProseMirror:hover::-webkit-scrollbar-thumb) {
background-color: #a1a1aa;
}
+
+ :root.dark :deep(.rte-editor-content .ProseMirror:hover::-webkit-scrollbar-thumb) {
+ background-color: #52525b;
+ }
}
diff --git a/cms/src/components/forms/FormLabel.vue b/cms/src/components/forms/FormLabel.vue
index 2d554dea1c..4e7bdc47a8 100644
--- a/cms/src/components/forms/FormLabel.vue
+++ b/cms/src/components/forms/FormLabel.vue
@@ -10,9 +10,12 @@ const props = withDefaults(defineProps(), {
-
+
-
+
* Required
diff --git a/cms/src/components/forms/LCombobox.vue b/cms/src/components/forms/LCombobox.vue
index 1fc901b3b5..d8f5a12efd 100644
--- a/cms/src/components/forms/LCombobox.vue
+++ b/cms/src/components/forms/LCombobox.vue
@@ -203,10 +203,10 @@ const onEscape = (e: KeyboardEvent) => {
- {{ label }}
+ {{ label }}
@@ -246,8 +246,8 @@ const onEscape = (e: KeyboardEvent) => {
{
@@ -320,9 +320,9 @@ const onEscape = (e: KeyboardEvent) => {
{
@@ -343,11 +343,9 @@ const onEscape = (e: KeyboardEvent) => {
{
name="options-open-btn"
>
@@ -393,7 +391,7 @@ const onEscape = (e: KeyboardEvent) => {
{
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
{
- 'bg-white text-black hover:bg-zinc-100': !option.selected,
- 'text-zinc-300 hover:bg-white': option.selected,
- 'bg-zinc-100': highlightedIndex === filtered.indexOf(option),
+ 'bg-white text-black hover:bg-zinc-100 dark:bg-slate-800 dark:text-zinc-300 dark:hover:bg-slate-700 dark:hover:text-white':
+ !option.selected,
+ 'cursor-not-allowed text-zinc-300 dark:text-zinc-600':
+ option.selected,
+ 'bg-zinc-100 dark:bg-slate-700 dark:text-zinc-100':
+ highlightedIndex === filtered.indexOf(option),
},
]"
@click="selectOption(option)"
@@ -449,7 +450,7 @@ const onEscape = (e: KeyboardEvent) => {
No options selected yet.
diff --git a/cms/src/components/forms/LInput.vue b/cms/src/components/forms/LInput.vue
index 7a10dd984a..4be2e6c19a 100644
--- a/cms/src/components/forms/LInput.vue
+++ b/cms/src/components/forms/LInput.vue
@@ -82,17 +82,26 @@ watch(
const computedState = computed(() => props.state);
const states = {
- default:
- "text-zinc-900 ring-zinc-300 placeholder:text-zinc-400 hover:ring-zinc-400 focus:ring-zinc-950",
- error: "text-red-900 bg-red-50 ring-red-300 placeholder:text-red-300 hover:ring-red-400 focus:ring-red-500",
- warning:
- "text-yellow-900 bg-yellow-50 ring-yellow-300 placeholder:text-yellow-500 hover:ring-yellow-400 focus:ring-yellow-500",
+ default: `
+ text-zinc-900 ring-zinc-300 placeholder:text-zinc-400 hover:ring-zinc-400 focus:ring-zinc-950
+ dark:bg-slate-900 dark:text-zinc-100 dark:ring-slate-700 dark:placeholder:text-zinc-500 dark:hover:ring-slate-600 dark:focus:ring-indigo-500
+ `,
+ error: `
+ text-red-900 bg-red-50 ring-red-300 placeholder:text-red-300 hover:ring-red-400 focus:ring-red-500
+ dark:bg-red-950/20 dark:text-red-400 dark:ring-red-900 dark:placeholder:text-red-800
+ `,
+ warning: `
+ text-yellow-900 bg-yellow-50 ring-yellow-300 placeholder:text-yellow-500 hover:ring-yellow-400 focus:ring-yellow-500
+ dark:bg-yellow-950/20 dark:text-yellow-400 dark:ring-yellow-900 dark:placeholder:text-yellow-800
+ `,
};
const addOnStates = {
- default: "border-zinc-300 px-3 text-zinc-500",
- error: "border-red-300 bg-red-50 px-3 text-red-600",
- warning: "border-yellow-300 bg-yellow-50 px-3 text-yellow-600",
+ default:
+ "border-zinc-300 px-3 text-zinc-500 dark:border-slate-700 dark:bg-slate-800 dark:text-zinc-400",
+ error: "border-red-300 bg-red-50 px-3 text-red-600 dark:border-red-900 dark:bg-red-950/30 dark:text-red-400",
+ warning:
+ "border-yellow-300 bg-yellow-50 px-3 text-yellow-600 dark:border-yellow-900 dark:bg-yellow-950/30 dark:text-yellow-400",
};
const sizes = {
@@ -107,7 +116,7 @@ const { attrsWithoutStyles } = useAttrsWithoutStyles();
-
+
{{ label }}
@@ -120,8 +129,8 @@ const { attrsWithoutStyles } = useAttrsWithoutStyles();
:is="icon"
class="h-5 w-5"
:class="{
- 'text-zinc-400': state == 'default' && !disabled,
- 'text-zinc-300': state == 'default' && disabled,
+ 'text-zinc-400 dark:text-zinc-500': state == 'default' && !disabled,
+ 'text-zinc-300 dark:text-zinc-700': state == 'default' && disabled,
'text-red-400': state == 'error',
}"
/>
@@ -152,7 +161,9 @@ const { attrsWithoutStyles } = useAttrsWithoutStyles();
[trailingPaddingClass ?? '']: trailingPaddingClass,
'resize-none': inputType === 'textarea',
},
- 'block w-full border-0 py-2 ring-1 ring-inset focus:ring-2 focus:ring-inset disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:text-zinc-500 disabled:ring-zinc-200 sm:text-sm sm:leading-6',
+ 'block w-full border-0 py-2 ring-1 ring-inset transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6',
+ 'disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:text-zinc-500 disabled:ring-zinc-200',
+ 'dark:disabled:bg-slate-900 dark:disabled:text-zinc-600 dark:disabled:ring-slate-800',
]"
:id="id"
:name="name"
@@ -171,9 +182,9 @@ const { attrsWithoutStyles } = useAttrsWithoutStyles();
:class="[
addOnStates[computedState],
{
- 'cursor-pointer bg-zinc-100 text-zinc-600 hover:bg-zinc-300 hover:text-zinc-900':
+ 'cursor-pointer bg-zinc-100 text-zinc-600 hover:bg-zinc-300 hover:text-zinc-900 dark:bg-slate-800 dark:text-zinc-400 dark:hover:bg-slate-700 dark:hover:text-zinc-100':
!rightAddOnDisabled,
- 'cursor-not-allowed bg-zinc-200 text-zinc-400 opacity-50':
+ 'cursor-not-allowed bg-zinc-200 text-zinc-400 opacity-50 dark:bg-slate-950 dark:text-zinc-700':
rightAddOnDisabled,
},
]"
diff --git a/cms/src/components/forms/LSelect.vue b/cms/src/components/forms/LSelect.vue
index 31f7e625ca..75278aa6b5 100644
--- a/cms/src/components/forms/LSelect.vue
+++ b/cms/src/components/forms/LSelect.vue
@@ -37,9 +37,14 @@ const props = withDefaults(defineProps(), {
const model = defineModel();
const states = {
- default:
- "border-zinc-300 bg-white focus-within:outline-none focus-within:outline focus-within:outline-offset-[-3px] focus-within:outline-zinc-500 focus-within:ring-0",
- error: "border-red-300 bg-red-50 text-red-900 focus-within:outline-none focus-within:outline focus-within:outline-offset-[-3px] focus-within:outline-red-500 focus-within:ring-0",
+ default: `
+ border-zinc-300 bg-white focus-within:outline-none focus-within:outline focus-within:outline-offset-[-3px] focus-within:outline-zinc-500 focus-within:ring-0
+ dark:border-slate-700 dark:bg-slate-900 dark:focus-within:outline-indigo-500
+ `,
+ error: `
+ border-red-300 bg-red-50 text-red-900 focus-within:outline-none focus-within:outline focus-within:outline-offset-[-3px] focus-within:outline-red-500 focus-within:ring-0
+ dark:border-red-900 dark:bg-red-950/20 dark:text-red-400 dark:focus-within:outline-red-500
+ `,
};
// Heights are border-box (Tailwind default), so include the trigger's 1px borders.
@@ -71,19 +76,25 @@ function selectOption(option: Option) {
function listOptionClass(option: Option): string {
if (option.disabled) {
- return "cursor-not-allowed text-zinc-400";
+ return "cursor-not-allowed text-zinc-400 dark:text-zinc-600";
}
const selected = model.value === option.value;
if (selected) {
- return "bg-zinc-50 font-medium text-zinc-900 hover:bg-zinc-100 focus:bg-zinc-100";
+ return `
+ bg-zinc-50 font-medium text-zinc-900 hover:bg-zinc-100 focus:bg-zinc-100
+ dark:bg-slate-700 dark:text-white dark:hover:bg-slate-600 dark:focus:bg-slate-600
+ `;
}
- return "bg-white text-black hover:bg-zinc-100 focus:bg-zinc-100";
+ return `
+ bg-white text-black hover:bg-zinc-100 focus:bg-zinc-100
+ dark:bg-slate-800 dark:text-zinc-300 dark:hover:bg-slate-700 dark:focus:bg-slate-700
+ `;
}
-
+
{{ label }}
@@ -103,15 +114,16 @@ function listOptionClass(option: Option): string {
:aria-expanded="showDropdown"
:aria-haspopup="true"
:aria-required="required"
- class="relative flex w-full cursor-pointer justify-between gap-2 rounded-md border-[1px] hover:bg-zinc-50 focus:outline-none"
+ class="relative flex w-full cursor-pointer justify-between gap-2 rounded-md border-[1px] transition-colors focus:outline-none"
:class="[
states[state],
sizeHeights[size],
- 'pl-3 pr-8',
+ 'pl-3 pr-8 hover:bg-zinc-50 dark:hover:bg-slate-800',
{
- 'cursor-not-allowed bg-zinc-100 text-zinc-500': disabled,
- 'text-zinc-900': !isPlaceholderShown && !disabled,
- 'text-zinc-400': isPlaceholderShown && !disabled,
+ 'cursor-not-allowed bg-zinc-100 text-zinc-500 dark:bg-slate-950 dark:text-zinc-700':
+ disabled,
+ 'text-zinc-900 dark:text-zinc-100': !isPlaceholderShown && !disabled,
+ 'text-zinc-400 dark:text-zinc-500': isPlaceholderShown && !disabled,
},
]"
v-bind="attrsWithoutStyles"
@@ -121,8 +133,10 @@ function listOptionClass(option: Option): string {
-
+
-
+
type Props = {
disabled?: boolean;
@@ -23,17 +22,20 @@ const updateValue = (newValue: string) => {
{
{
{
>
{
Select
{
{
Top down
{
Columns
@@ -344,7 +344,7 @@ watch(isFullscreen, () => {
:max="TREE_MAX_COLUMNS"
step="1"
:value="treeColumnCount"
- class="h-2 w-28 cursor-pointer appearance-none rounded-lg bg-zinc-200 accent-zinc-700"
+ class="h-2 w-28 cursor-pointer appearance-none rounded-lg bg-zinc-200 dark:accent-zinc-300"
@input="updateTreeColumnCount"
/>
@@ -353,7 +353,7 @@ watch(isFullscreen, () => {
{
{
Use saved layout
{
{
/>
{
{
diff --git a/cms/src/components/groups/GroupSelector.vue b/cms/src/components/groups/GroupSelector.vue
index fba07b8a2b..302f42d7d9 100644
--- a/cms/src/components/groups/GroupSelector.vue
+++ b/cms/src/components/groups/GroupSelector.vue
@@ -111,10 +111,10 @@ const showEditModal = ref(false);
type="button"
:disabled="disabled"
data-test="edit-group"
- class="flex items-center rounded-lg text-sm hover:bg-zinc-300/50"
+ class="flex items-center rounded-lg text-sm hover:bg-zinc-300/50 dark:text-zinc-400 dark:hover:bg-slate-700/50"
>
edit
-
+
@@ -128,7 +128,10 @@ const showEditModal = ref(false);
leave-from-class="transform scale-100 opacity-100 absolute"
leave-to-class="transform scale-90 opacity-0"
>
-
+
No group selected
diff --git a/cms/src/components/images/ImageEditor.vue b/cms/src/components/images/ImageEditor.vue
index 24bac8d320..b9911c13c1 100644
--- a/cms/src/components/images/ImageEditor.vue
+++ b/cms/src/components/images/ImageEditor.vue
@@ -305,7 +305,7 @@ defineExpose({
/>
{{ fileTypeDescription }}
@@ -320,12 +320,12 @@ defineExpose({
@click="showFailureMessage = !showFailureMessage"
:title="failureMessage"
>
-
+
@@ -334,13 +334,13 @@ defineExpose({
diff --git a/cms/src/components/languages/EditLanguage.vue b/cms/src/components/languages/EditLanguage.vue
index 20f0bb7dcc..e893294039 100644
--- a/cms/src/components/languages/EditLanguage.vue
+++ b/cms/src/components/languages/EditLanguage.vue
@@ -415,7 +415,7 @@ const contentActions = computed(() => {
-
+
{{ isNew ? "Create new language" : `Edit ${editable.name}` }}
@@ -476,7 +476,7 @@ const contentActions = computed(() => {
-
+
{
/>
-
+
@@ -532,25 +532,25 @@ const contentActions = computed(() => {
-
+
Key
Value
Action
@@ -558,16 +558,16 @@ const contentActions = computed(() => {
Compare
-
+
{
/>
{
@@ -609,7 +609,7 @@ const contentActions = computed(() => {
{
}"
>
{
/>
{
/>
{
{{ row.comparisonValue }}
diff --git a/cms/src/components/languages/LanguageDisplayCard.vue b/cms/src/components/languages/LanguageDisplayCard.vue
index 14fb11d6c1..5545ff4469 100644
--- a/cms/src/components/languages/LanguageDisplayCard.vue
+++ b/cms/src/components/languages/LanguageDisplayCard.vue
@@ -28,13 +28,13 @@ const isLocalChanges = computed(() => props.hasLocalChanges(props.languagesDoc._
-
+
{{ languagesDoc.languageCode.toLocaleUpperCase() }}
- {{
+ {{
languagesDoc.name
}}
-
+
Default
@@ -47,9 +47,9 @@ const isLocalChanges = computed(() => props.hasLocalChanges(props.languagesDoc._
Offline changes
-
+
{{
db
diff --git a/cms/src/components/media/MediaEditor.vue b/cms/src/components/media/MediaEditor.vue
index 3e5033d71a..7d73b9646b 100644
--- a/cms/src/components/media/MediaEditor.vue
+++ b/cms/src/components/media/MediaEditor.vue
@@ -415,7 +415,7 @@ defineExpose({
/>
{{ fileTypeDescription }}
@@ -430,12 +430,12 @@ defineExpose({
@click="showFailureMessage = !showFailureMessage"
:title="failureMessage"
>
-
+
@@ -444,13 +444,13 @@ defineExpose({
@@ -541,21 +541,21 @@ defineExpose({
Choose which language this audio file belongs to:
{{ lang.name }}
(has audio)
-
+
Select a language for this audio file. If a language already has audio, it will
be replaced.
@@ -573,11 +573,11 @@ defineExpose({
context="danger"
>
-
+
The selected language already has an audio file. Uploading this new file will
replace the existing audio.
-
+
Do you want to continue and replace the existing audio?
diff --git a/cms/src/components/modals/LModal.vue b/cms/src/components/modals/LModal.vue
index 73fa0a502d..b85e6282cb 100644
--- a/cms/src/components/modals/LModal.vue
+++ b/cms/src/components/modals/LModal.vue
@@ -65,7 +65,7 @@ const isFullscreen = computed(() => {
{
:class="[
'flex w-full items-center justify-between',
transparentHeader
- ? 'pointer-events-none absolute left-0 right-0 top-0 z-10 p-2'
+ ? 'pointer-events-none absolute left-0 right-0 top-0 z-10 p-2 dark:text-zinc-100'
: 'mb-2',
]"
>
-
+
{{ heading }}
@@ -121,7 +121,7 @@ const isFullscreen = computed(() => {
{
- {{ language.name }}
+ {{ language.name }}
diff --git a/cms/src/components/modals/ThemeSelectorModal.vue b/cms/src/components/modals/ThemeSelectorModal.vue
new file mode 100644
index 0000000000..c90668fdb5
--- /dev/null
+++ b/cms/src/components/modals/ThemeSelectorModal.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+ Light
+
+
+
+
+ Dark
+
+
+
+
+ System
+
+
+
+
+
+ Close
+
+
+
+
diff --git a/cms/src/components/navigation/SideBar.vue b/cms/src/components/navigation/SideBar.vue
index e51c515a47..3706027893 100644
--- a/cms/src/components/navigation/SideBar.vue
+++ b/cms/src/components/navigation/SideBar.vue
@@ -18,6 +18,7 @@ import {
SparklesIcon,
} from "@heroicons/vue/20/solid";
+import { SunIcon } from "@heroicons/vue/24/outline";
import {
appName,
cmsLanguageIdAsRef,
@@ -206,6 +207,7 @@ const currentLanguageName = computed(
);
const showLanguageModal = ref(false);
+const showThemeModal = ref(false);
const showLogoutDialog = ref(false);
const showInstallInstructions = ref(false);
const forceReauthOnNextLogin = ref(false);
@@ -225,7 +227,7 @@ const confirmLogout = () => {
const navIconClass = "h-5 w-5 shrink-0";
// When collapsed (desktop only) nav rows center their icon and drop the label gap/padding.
const navItemClass = computed(() => [
- "mb-1 flex rounded-md text-sm font-medium text-zinc-600 hover:bg-zinc-200",
+ "mb-1 flex rounded-md text-sm font-medium text-zinc-600 hover:bg-zinc-200 dark:hover:bg-slate-700 dark:text-slate-100",
isCollapsed.value ? "justify-center p-2.5" : "items-center gap-3 px-3 py-2.5",
]);
@@ -243,7 +245,7 @@ const navItemClass = computed(() => [
-
+
{{ user?.name || user?.email }}
@@ -429,6 +446,7 @@ const navItemClass = computed(() => [
+
@@ -34,8 +34,10 @@ const show = ref(true);
/>
-
{{ notification.title }}
-
+
+ {{ notification.title }}
+
+
{{ notification.description }}
@@ -53,7 +55,7 @@ const show = ref(true);
Close
diff --git a/cms/src/components/redirects/RedirectDisplaycard.vue b/cms/src/components/redirects/RedirectDisplaycard.vue
index bf260f2507..efec478d88 100644
--- a/cms/src/components/redirects/RedirectDisplaycard.vue
+++ b/cms/src/components/redirects/RedirectDisplaycard.vue
@@ -86,7 +86,7 @@ const redirectGroups = computed(() =>
-
+
- {{ error }}
+ {{ error }}
@@ -142,14 +142,16 @@ function handleDelete() {
class="mb-1 flex items-center gap-2"
>
-
{{ validation.message }}
+
{{ validation.message }}
-
Name
-
Public URL
-
+
Must be set manually including "http://" or "https://"
@@ -233,7 +237,7 @@ function handleDelete() {
-
+
Allowed File Types
@@ -275,7 +279,7 @@ function handleDelete() {
-
+
e.g., "image/*", "video/mp4", "application/pdf"
diff --git a/cms/src/components/s3/StorageOverview.vue b/cms/src/components/s3/StorageOverview.vue
index 8a86fba570..f09697359a 100644
--- a/cms/src/components/s3/StorageOverview.vue
+++ b/cms/src/components/s3/StorageOverview.vue
@@ -375,19 +375,19 @@ const saveBucket = async () => {
/>
-
-
-
+
+
+
diff --git a/cms/src/components/users/UserOverview.vue b/cms/src/components/users/UserOverview.vue
index c2092ed5df..d6177cf466 100644
--- a/cms/src/components/users/UserOverview.vue
+++ b/cms/src/components/users/UserOverview.vue
@@ -166,7 +166,7 @@ const hasAnyContent = computed(() => (users.value?.length ?? 0) > 0);
diff --git a/cms/src/globalConfig.ts b/cms/src/globalConfig.ts
index 9c6c42be53..cc0a6198e1 100644
--- a/cms/src/globalConfig.ts
+++ b/cms/src/globalConfig.ts
@@ -167,3 +167,61 @@ export const isMac = computed(() => {
ua.includes("ipad")
);
});
+
+const _theme = ref(localStorage.getItem("theme") || "system");
+
+/**
+ * The selected theme as Vue ref.
+ */
+export const theme = computed<"system" | "dark" | "light">({
+ get: () => {
+ if (_theme.value != "dark" && _theme.value != "light") return "system";
+ return _theme.value;
+ },
+ set: (value) => {
+ _theme.value = value;
+ localStorage.setItem("theme", value);
+ },
+});
+
+/**
+ * Returns true if the theme is dark, false if it is light. When the theme is set to "System", it returns true if the system's preferred color scheme is dark.
+ */
+export const isDarkTheme = ref(document.documentElement.classList.contains("dark"));
+
+// Live-syncs with OS `prefers-color-scheme` while theme === "system". The matchMedia check inside the watcher alone only runs on in-app theme changes — without this listener, a user flipping OS dark mode mid-session wouldn't update the app.
+const systemThemeQuery =
+ typeof window !== "undefined" && window.matchMedia
+ ? window.matchMedia("(prefers-color-scheme: dark)")
+ : ({ matches: false, addEventListener: () => {}, removeEventListener: () => {} } as any);
+
+const applySystemTheme = () => {
+ if (systemThemeQuery.matches) {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
+ isDarkTheme.value = document.documentElement.classList.contains("dark");
+};
+
+// Watch the theme and apply to the document's CSS classes
+watch(
+ theme,
+ (t) => {
+ // Remove any existing system theme listener before re-evaluating
+ systemThemeQuery.removeEventListener("change", applySystemTheme);
+
+ if (t === "system") {
+ applySystemTheme();
+ // Keep dark class in sync when the OS theme changes
+ systemThemeQuery.addEventListener("change", applySystemTheme);
+ } else if (t === "dark") {
+ document.documentElement.classList.add("dark");
+ isDarkTheme.value = true;
+ } else {
+ document.documentElement.classList.remove("dark");
+ isDarkTheme.value = false;
+ }
+ },
+ { immediate: true },
+);
diff --git a/cms/src/pages/DashboardPage.vue b/cms/src/pages/DashboardPage.vue
index 8a4f8d1ec0..d3691d146f 100644
--- a/cms/src/pages/DashboardPage.vue
+++ b/cms/src/pages/DashboardPage.vue
@@ -80,7 +80,7 @@ const expiredContent = computed(() => {
-
+
{
-
+
{
-
+
All CMS data is saved locally on your device. If you experience problems, try
deleting all local data. Depending on the amount of available data, it can take some
time before all data is available again.
diff --git a/cms/src/pages/StoragePage.vue b/cms/src/pages/StoragePage.vue
index 3c218ac143..0677c0b71d 100644
--- a/cms/src/pages/StoragePage.vue
+++ b/cms/src/pages/StoragePage.vue
@@ -31,14 +31,14 @@ const createNew = () => {
-
+
Manage S3 bucket configurations for file storage and uploads.
diff --git a/cms/tailwind.config.ts b/cms/tailwind.config.ts
index 269358f7b0..8805328db3 100644
--- a/cms/tailwind.config.ts
+++ b/cms/tailwind.config.ts
@@ -2,6 +2,7 @@ import type { Config } from "tailwindcss";
const defaultTheme = require("tailwindcss/defaultTheme");
export default {
+ darkMode: "class",
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
theme: {
extend: {