Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3457bb3288 | ||
|
|
e6403bbe52 | ||
|
|
3a9f10e6ba |
No files matched your search
@@ -1,155 +0,0 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export type Translator = {
|
||||
(key: string): string;
|
||||
};
|
||||
|
||||
export interface HoverMetadataItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const maxMetadataItems = 3;
|
||||
|
||||
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
|
||||
app: true,
|
||||
anchorNote: true,
|
||||
dynamicSelect: true,
|
||||
sortableItemList: true,
|
||||
multiReleasesRepositories: true,
|
||||
umamiEventName: true,
|
||||
umamiEventNames: true,
|
||||
umamiWebsite: true,
|
||||
};
|
||||
|
||||
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
|
||||
|
||||
type OptionDefinitionWithRules = WidgetOptionDefinition & {
|
||||
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
|
||||
options?: readonly { value: unknown; label: unknown }[];
|
||||
};
|
||||
|
||||
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
|
||||
|
||||
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
|
||||
const match = definition.options?.find((option) => option.value === value);
|
||||
const label = match?.label;
|
||||
const fallbackLabel = () => String(value);
|
||||
const labelResolvers: Record<string, () => string> = {
|
||||
function: () => (label as (translator: Translator) => string)(t),
|
||||
string: () => label as string,
|
||||
missing: fallbackLabel,
|
||||
};
|
||||
const labelType = typeof label;
|
||||
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
|
||||
return resolveLabel();
|
||||
};
|
||||
|
||||
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
|
||||
switch: (value, _, t) => {
|
||||
const switchLabels: Record<string, string> = {
|
||||
true: t("common.action.show"),
|
||||
false: t("common.action.hide"),
|
||||
};
|
||||
return switchLabels[String(Boolean(value))] ?? null;
|
||||
},
|
||||
text: (value) => {
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
},
|
||||
number: (value) => String(value),
|
||||
slider: (value) => String(value),
|
||||
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
|
||||
multiSelect: (value) => {
|
||||
const items = value as unknown[];
|
||||
return items.length > 0 ? String(items.length) : null;
|
||||
},
|
||||
multiText: (value) => {
|
||||
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
|
||||
return items.length > 0 ? items.join(", ") : null;
|
||||
},
|
||||
location: (value) => {
|
||||
const location = value as { name?: string };
|
||||
const name = location.name?.trim() ?? "";
|
||||
return name.length > 0 ? name : null;
|
||||
},
|
||||
};
|
||||
|
||||
const formatOptionValue = (
|
||||
type: WidgetOptionType,
|
||||
value: unknown,
|
||||
definition: OptionDefinitionWithRules,
|
||||
t: Translator,
|
||||
) => {
|
||||
const formatter = optionTypeFormatters[type];
|
||||
return formatter?.(value, definition, t) ?? null;
|
||||
};
|
||||
|
||||
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
|
||||
const labelKey = `widget.${kind}.option.${key}.label`;
|
||||
const translated = t(labelKey);
|
||||
return translated === labelKey ? key : translated;
|
||||
};
|
||||
|
||||
export const collectWidgetHoverMetadata = (
|
||||
item: SectionItem,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
integrationKinds: IntegrationKind[],
|
||||
t: Translator,
|
||||
): HoverMetadataItem[] => {
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
|
||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
|
||||
return objectEntries(optionDefinitions)
|
||||
.filter(([key, optionDefinition]) => {
|
||||
const skip = skippedOptionTypes[optionDefinition.type];
|
||||
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
|
||||
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
|
||||
return !skip && !hidden && !unchanged;
|
||||
})
|
||||
.map(([key, optionDefinition]) => {
|
||||
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
|
||||
return {
|
||||
label: getOptionLabel(item.kind, key, t),
|
||||
value: formattedValue ?? "",
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.value.length > 0)
|
||||
.slice(0, maxMetadataItems);
|
||||
};
|
||||
|
||||
interface DisplayNameSource {
|
||||
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
|
||||
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
const displayNameSources: DisplayNameSource[] = [
|
||||
{
|
||||
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
|
||||
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
|
||||
},
|
||||
{
|
||||
matches: (item, options) =>
|
||||
item.kind === "clock" &&
|
||||
Boolean(options.customTitleToggle) &&
|
||||
String(options.customTitle ?? "").trim().length > 0,
|
||||
resolve: (_, options) => String(options.customTitle).trim(),
|
||||
},
|
||||
];
|
||||
|
||||
export const resolveWidgetDisplayName = (
|
||||
item: SectionItem,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
t: Translator,
|
||||
) => {
|
||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
|
||||
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: calc(100% - 10px);
|
||||
margin-bottom: 1px;
|
||||
z-index: 200;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.panel {
|
||||
@mixin dark {
|
||||
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
|
||||
--border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9);
|
||||
}
|
||||
@mixin light {
|
||||
--background-color: rgb(from var(--mantine-color-white) r g b / 0.96);
|
||||
--border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9);
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--background-color);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nameSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.widgetIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.integrations {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.integrationAvatar {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.metadataItem {
|
||||
@mixin dark {
|
||||
--chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9);
|
||||
}
|
||||
@mixin light {
|
||||
--chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95);
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 140px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--chip-background);
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metadataLabel {
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metadataValue {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { Translator } from "./widget-hover-metadata";
|
||||
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
|
||||
import classes from "./widget-hover-overlay.module.css";
|
||||
|
||||
interface WidgetHoverOverlayProps {
|
||||
item: SectionItem;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
}
|
||||
|
||||
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const settings = useSettings();
|
||||
const t = useI18n();
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const WidgetIcon = definition.icon;
|
||||
|
||||
const translate = ((key: string) => t(key as never)) as Translator;
|
||||
const displayName = resolveWidgetDisplayName(item, settings, translate);
|
||||
|
||||
const connectedIntegrations = (integrations ?? []).filter((integration) =>
|
||||
item.integrationIds.includes(integration.id),
|
||||
);
|
||||
const metadataItems = collectWidgetHoverMetadata(
|
||||
item,
|
||||
settings,
|
||||
connectedIntegrations.map((integration) => integration.kind),
|
||||
translate,
|
||||
);
|
||||
const hasIntegrations = connectedIntegrations.length > 0;
|
||||
const hasMetadata = metadataItems.length > 0;
|
||||
|
||||
if (isEditMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.panel}>
|
||||
<div className={classes.nameSection}>
|
||||
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
|
||||
<Text className={classes.name} title={displayName}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{hasIntegrations && (
|
||||
<Avatar.Group spacing="xs" className={classes.integrations}>
|
||||
{connectedIntegrations.map((integration) => (
|
||||
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
|
||||
<Avatar
|
||||
src={getIconUrl(integration.kind)}
|
||||
size={20}
|
||||
radius="sm"
|
||||
className={classes.integrationAvatar}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Avatar.Group>
|
||||
)}
|
||||
|
||||
{hasMetadata && (
|
||||
<Group className={classes.metadata} gap={4}>
|
||||
{metadataItems.map((metadataItem) => (
|
||||
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
|
||||
<span className={classes.metadataLabel}>{metadataItem.label}</span>
|
||||
<span className={classes.metadataValue}>{metadataItem.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,12 +2,8 @@ import { useMemo } from "react";
|
||||
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
|
||||
import { BoardDynamicSection } from "./dynamic-section";
|
||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||
import { useSectionContext } from "./section-context";
|
||||
@@ -15,7 +11,6 @@ import { useSectionItems } from "./use-section-items";
|
||||
|
||||
export const SectionContent = () => {
|
||||
const { innerSections, items, refs } = useSectionContext();
|
||||
const { data: integrations } = clientApi.integration.all.useQuery();
|
||||
|
||||
/**
|
||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||
@@ -23,7 +18,7 @@ export const SectionContent = () => {
|
||||
* @see https://github.com/homarr-labs/homarr/pull/1770
|
||||
*/
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...items, ...innerSections].toSorted((itemA, itemB) => {
|
||||
return [...items, ...innerSections].sort((itemA, itemB) => {
|
||||
if (itemA.yOffset === itemB.yOffset) {
|
||||
return itemA.xOffset - itemB.xOffset;
|
||||
}
|
||||
@@ -35,7 +30,7 @@ export const SectionContent = () => {
|
||||
return (
|
||||
<>
|
||||
{sortedItems.map((item) => (
|
||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
|
||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -44,12 +39,12 @@ export const SectionContent = () => {
|
||||
interface ItemProps {
|
||||
item: DynamicSectionItem | SectionItem;
|
||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
}
|
||||
|
||||
const Item = ({ item, innerRef, integrations }: ItemProps) => {
|
||||
const Item = ({ item, innerRef }: ItemProps) => {
|
||||
const minWidth = useMinSize(item, "x");
|
||||
const minHeight = useMinSize(item, "y");
|
||||
|
||||
return (
|
||||
<GridStackItem
|
||||
key={item.id}
|
||||
@@ -64,7 +59,6 @@ const Item = ({ item, innerRef, integrations }: ItemProps) => {
|
||||
minWidth={minWidth}
|
||||
minHeight={minHeight}
|
||||
>
|
||||
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
||||
</GridStackItem>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Indicator,
|
||||
Menu,
|
||||
Modal,
|
||||
Radio,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconMessageCircle } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||
import { surveyAnsweredCookieKey } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
|
||||
const surveySchema = z
|
||||
.object({
|
||||
features: z.array(z.string()).min(1),
|
||||
integration: z.string().min(1),
|
||||
integrationOther: z.string().optional(),
|
||||
aiImportance: z.string().min(1),
|
||||
wishlist: z.string().optional(),
|
||||
frustrations: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.integration === "Other" && !data.integrationOther?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Required",
|
||||
path: ["integrationOther"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type SurveyFormValues = z.infer<typeof surveySchema>;
|
||||
|
||||
const featureChoices = [
|
||||
"Export / import config",
|
||||
"MCP (Model Context Protocol) support",
|
||||
"New widgets (weather, calendar, RSS, etc.)",
|
||||
"New integrations",
|
||||
"Custom widgets (create your own)",
|
||||
"Automatic mobile layouts / better drag and drop",
|
||||
] as const;
|
||||
|
||||
const integrationChoices = [
|
||||
"Home automation (Home Assistant, openHAB, Zigbee2MQTT…)",
|
||||
"Media servers (Plex, Jellyfin, Emby, Navidrome…)",
|
||||
"Monitoring & observability (Prometheus, Grafana, Uptime Kuma…)",
|
||||
"Infrastructure (Proxmox, TrueNAS, Portainer…)",
|
||||
"Cloud & productivity (Nextcloud, Paperless, Vaultwarden…)",
|
||||
"Other",
|
||||
] as const;
|
||||
|
||||
const aiImportanceChoices = [
|
||||
"Very important – I'd use it immediately",
|
||||
"Somewhat important – nice to have",
|
||||
"Not important – I don't care",
|
||||
"I'm not sure what this means yet",
|
||||
] as const;
|
||||
|
||||
const requiredSteps = [0, 1, 2] as const;
|
||||
|
||||
const integrationStepValidators: Record<string, (values: SurveyFormValues) => boolean> = {
|
||||
Other: (values) => Boolean(values.integrationOther?.trim()),
|
||||
...(Object.fromEntries(
|
||||
integrationChoices.filter((choice) => choice !== "Other").map((choice) => [choice, () => true]),
|
||||
) as Record<string, () => true>),
|
||||
};
|
||||
|
||||
const stepValidators: Record<number, (values: SurveyFormValues) => boolean> = {
|
||||
0: (values) => values.features.length >= 1,
|
||||
1: (values) => integrationStepValidators[values.integration]?.(values) ?? false,
|
||||
2: (values) => values.aiImportance.length >= 1,
|
||||
};
|
||||
|
||||
const surveyResponse1Getters: Record<string, (values: SurveyFormValues) => string | undefined> = {
|
||||
Other: (values) => values.integrationOther,
|
||||
};
|
||||
|
||||
function createStore<T>(initialValue: T) {
|
||||
let value = initialValue;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
return {
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getSnapshot() {
|
||||
return value;
|
||||
},
|
||||
set(nextValue: T) {
|
||||
value = nextValue;
|
||||
listeners.forEach((listener) => listener());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const showSurveyStore = createStore(false);
|
||||
const surveyModalStore = createStore(false);
|
||||
|
||||
function useShowSurvey() {
|
||||
const showSurvey = useSyncExternalStore(
|
||||
(listener) => showSurveyStore.subscribe(listener),
|
||||
() => showSurveyStore.getSnapshot(),
|
||||
() => false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeDeadline = new Date() < new Date("2026-07-01");
|
||||
const cookies = parseCookies(document.cookie);
|
||||
const hasAnswered = cookies[surveyAnsweredCookieKey] !== undefined;
|
||||
|
||||
if (beforeDeadline && !hasAnswered) {
|
||||
showSurveyStore.set(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return showSurvey;
|
||||
}
|
||||
|
||||
function useSurveyModalOpen() {
|
||||
return useSyncExternalStore(
|
||||
(listener) => surveyModalStore.subscribe(listener),
|
||||
() => surveyModalStore.getSnapshot(),
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
function isStepValid(step: number, values: SurveyFormValues) {
|
||||
const validator = stepValidators[step];
|
||||
if (!validator) {
|
||||
return true;
|
||||
}
|
||||
return validator(values);
|
||||
}
|
||||
|
||||
function canNavigateToStep(targetStep: number, activeStep: number, values: SurveyFormValues) {
|
||||
if (targetStep <= activeStep) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return requiredSteps.filter((step) => step < targetStep).every((step) => isStepValid(step, values));
|
||||
}
|
||||
|
||||
function getSurveyResponse1(values: SurveyFormValues) {
|
||||
const getter = surveyResponse1Getters[values.integration];
|
||||
if (getter) {
|
||||
return getter(values);
|
||||
}
|
||||
return values.integration;
|
||||
}
|
||||
|
||||
function optionalResponse(value: string | undefined) {
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const SurveyIndicator = ({ children }: PropsWithChildren) => {
|
||||
const showSurvey = useShowSurvey();
|
||||
|
||||
return (
|
||||
<Indicator color="grape" size={15} processing withBorder disabled={!showSurvey}>
|
||||
{children}
|
||||
</Indicator>
|
||||
);
|
||||
};
|
||||
|
||||
const SurveyMenuItem = () => {
|
||||
const showSurvey = useShowSurvey();
|
||||
|
||||
if (!showSurvey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.Item onClick={() => surveyModalStore.set(true)} leftSection={<IconMessageCircle size="1rem" />} color="grape">
|
||||
Share your feedback
|
||||
</Menu.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const SurveyModal = () => {
|
||||
const showSurvey = useShowSurvey();
|
||||
const opened = useSurveyModalOpen();
|
||||
const [active, setActive] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const close = () => surveyModalStore.set(false);
|
||||
|
||||
const form = useZodForm(surveySchema, {
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
features: [],
|
||||
integration: "",
|
||||
integrationOther: "",
|
||||
aiImportance: "",
|
||||
wishlist: "",
|
||||
frustrations: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch("https://hog.homarr.dev/i/v0/e/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: "phc_pWxeD1hbl4ip02JYReX1Crjkt5DhB3dduigirHMCtFE",
|
||||
event: "survey sent",
|
||||
distinct_id: crypto.randomUUID(),
|
||||
properties: {
|
||||
$survey_id: "019ea900-2649-0000-5914-8bdfacc01219",
|
||||
$survey_response: values.features,
|
||||
$survey_response_1: getSurveyResponse1(values),
|
||||
$survey_response_2: values.aiImportance,
|
||||
$survey_response_3: optionalResponse(values.wishlist),
|
||||
$survey_response_4: optionalResponse(values.frustrations),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
setClientCookie(surveyAnsweredCookieKey, "true", {
|
||||
expires: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
path: "/",
|
||||
});
|
||||
showSurveyStore.set(false);
|
||||
close();
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleStepClick = (step: number) => {
|
||||
if (step === active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canNavigateToStep(step, active, form.values)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActive(step);
|
||||
};
|
||||
|
||||
if (!showSurvey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={close} size="xl" title="Homarr – Feature feedback & wishlist" centered>
|
||||
<Stepper active={active} onStepClick={handleStepClick} orientation="horizontal">
|
||||
<Stepper.Step label="Features">
|
||||
<Stack gap="md" mt="md">
|
||||
<Title order={4}>Which new features would you most like to see in Homarr? (pick all that apply)</Title>
|
||||
<Checkbox.Group {...form.getInputProps("features")}>
|
||||
<Stack gap="xs">
|
||||
{featureChoices.map((choice) => (
|
||||
<Checkbox key={choice} value={choice} label={choice} />
|
||||
))}
|
||||
</Stack>
|
||||
</Checkbox.Group>
|
||||
<Group justify="flex-end">
|
||||
<Button color="grape" disabled={!isStepValid(0, form.values)} onClick={() => setActive(1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Integrations">
|
||||
<Stack gap="md" mt="md">
|
||||
<Title order={4}>What type of new integration would benefit you the most?</Title>
|
||||
<Radio.Group {...form.getInputProps("integration")}>
|
||||
<Stack gap="xs">
|
||||
{integrationChoices.map((choice) => (
|
||||
<Radio key={choice} value={choice} label={choice} />
|
||||
))}
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
{form.values.integration === "Other" && (
|
||||
<TextInput label="Please specify" {...form.getInputProps("integrationOther")} />
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button color="grape" disabled={!isStepValid(1, form.values)} onClick={() => setActive(2)}>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="AI">
|
||||
<Stack gap="md" mt="md">
|
||||
<Title order={4}>
|
||||
How important is AI compatibility to you? (e.g. AI widgets, MCP tools, LLM integrations)
|
||||
</Title>
|
||||
<Radio.Group {...form.getInputProps("aiImportance")}>
|
||||
<Stack gap="xs">
|
||||
{aiImportanceChoices.map((choice) => (
|
||||
<Radio key={choice} value={choice} label={choice} />
|
||||
))}
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
<Group justify="flex-end">
|
||||
<Button color="grape" disabled={!isStepValid(2, form.values)} onClick={() => setActive(3)}>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Wishlist">
|
||||
<Stack gap="md" mt="md">
|
||||
<Title order={4}>
|
||||
Anything else you'd like to see in Homarr? Feel free to describe a feature, integration, or
|
||||
improvement you're missing.
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Optional
|
||||
</Text>
|
||||
<Textarea autosize minRows={3} {...form.getInputProps("wishlist")} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" color="gray" onClick={() => setActive(4)}>
|
||||
Skip
|
||||
</Button>
|
||||
<Button color="grape" onClick={() => setActive(4)}>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Feedback">
|
||||
<Stack gap="md" mt="md">
|
||||
<Title order={4}>
|
||||
What frustrates you most about Homarr today? Any small fix or quality-of-life improvement you wish it
|
||||
already did?
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Optional
|
||||
</Text>
|
||||
<Textarea autosize minRows={3} {...form.getInputProps("frustrations")} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" color="gray" loading={isSubmitting} onClick={handleSubmit}>
|
||||
Skip & Submit
|
||||
</Button>
|
||||
<Button color="grape" loading={isSubmitting} onClick={handleSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export { SurveyIndicator, SurveyMenuItem, SurveyModal };
|
||||
@@ -6,6 +6,7 @@ import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
import { SurveyIndicator } from "./survey";
|
||||
import { UpdateIndicator } from "./update";
|
||||
|
||||
export const UserButton = async () => {
|
||||
@@ -16,9 +17,11 @@ export const UserButton = async () => {
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
|
||||
<UnstyledButton>
|
||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||
<CurrentUserAvatar size="md" />
|
||||
</UpdateIndicator>
|
||||
<SurveyIndicator>
|
||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||
<CurrentUserAvatar size="md" />
|
||||
</UpdateIndicator>
|
||||
</SurveyIndicator>
|
||||
</Suspense>
|
||||
</UnstyledButton>
|
||||
</UserAvatarMenu>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
||||
import { SurveyMenuItem, SurveyModal } from "./layout/header/survey";
|
||||
|
||||
interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
@@ -51,50 +52,54 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
}, [logoutUrl, openModal, router]);
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
>
|
||||
{t("preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
<>
|
||||
<SurveyModal />
|
||||
{/* We use keepMounted so we can add event listeners to prevent navigating away without saving the board */}
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
<SurveyMenuItem />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
<Menu.Target>{children}</Menu.Target>
|
||||
</Menu>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
>
|
||||
{t("preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
<Menu.Target>{children}</Menu.Target>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -102,10 +102,6 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item[data-type="item"] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-animate {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
"proxmox-api>undici": "7.24.6",
|
||||
"react-is": "^19.2.7",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.61.0",
|
||||
"sha.js@<=2.4.11": ">=2.4.12",
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const colorSchemeCookieKey = "homarr.color-scheme";
|
||||
export const localeCookieKey = "homarr.locale";
|
||||
export const surveyAnsweredCookieKey = "homarr-answered-survey";
|
||||
@@ -53,7 +53,6 @@ import * as weather from "./weather";
|
||||
|
||||
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
|
||||
export type { WidgetComponentProps };
|
||||
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
|
||||
|
||||
export const widgetImports = {
|
||||
clock,
|
||||
|
||||
Generated
+534
-554
File diff suppressed because it is too large.
Load diff
+32
-32
@@ -21,17 +21,17 @@ catalog:
|
||||
"@immich/sdk": ^2.7.5
|
||||
"@jellyfin/sdk": ^0.13.0
|
||||
"@kubernetes/client-node": ^1.4.0
|
||||
"@mantine/charts": ^9.3.0
|
||||
"@mantine/colors-generator": ^9.3.0
|
||||
"@mantine/core": ^9.3.0
|
||||
"@mantine/dates": ^9.3.0
|
||||
"@mantine/dropzone": ^9.3.0
|
||||
"@mantine/form": ^9.3.0
|
||||
"@mantine/hooks": ^9.3.0
|
||||
"@mantine/modals": ^9.3.0
|
||||
"@mantine/notifications": ^9.3.0
|
||||
"@mantine/spotlight": ^9.3.0
|
||||
"@mantine/tiptap": ^9.3.0
|
||||
"@mantine/charts": ^9.2.2
|
||||
"@mantine/colors-generator": ^9.2.2
|
||||
"@mantine/core": ^9.2.2
|
||||
"@mantine/dates": ^9.2.2
|
||||
"@mantine/dropzone": ^9.2.2
|
||||
"@mantine/form": ^9.2.2
|
||||
"@mantine/hooks": ^9.2.2
|
||||
"@mantine/modals": ^9.2.2
|
||||
"@mantine/notifications": ^9.2.2
|
||||
"@mantine/spotlight": ^9.2.2
|
||||
"@mantine/tiptap": ^9.2.2
|
||||
"@next/eslint-plugin-next": 16.2.7
|
||||
"@octokit/auth-app": ^8.2.0
|
||||
"@paralleldrive/cuid2": ^3.3.0
|
||||
@@ -44,28 +44,28 @@ catalog:
|
||||
"@semantic-release/release-notes-generator": ^14.1.1
|
||||
"@t3-oss/env-nextjs": ^0.13.11
|
||||
"@tabler/icons-react": ^3.44.0
|
||||
"@tanstack/react-query": ^5.101.0
|
||||
"@tanstack/react-query-devtools": ^5.101.0
|
||||
"@tanstack/react-query-next-experimental": ^5.101.0
|
||||
"@tanstack/react-query": ^5.100.14
|
||||
"@tanstack/react-query-devtools": ^5.100.14
|
||||
"@tanstack/react-query-next-experimental": ^5.100.14
|
||||
"@testcontainers/mysql": ^12.0.1
|
||||
"@testcontainers/postgresql": ^12.0.1
|
||||
"@testcontainers/redis": ^12.0.1
|
||||
"@tiptap/extension-color": 3.25.0
|
||||
"@tiptap/extension-highlight": 3.25.0
|
||||
"@tiptap/extension-image": 3.25.0
|
||||
"@tiptap/extension-link": ^3.25.0
|
||||
"@tiptap/extension-placeholder": ^3.25.0
|
||||
"@tiptap/extension-table": 3.25.0
|
||||
"@tiptap/extension-table-cell": 3.25.0
|
||||
"@tiptap/extension-table-header": 3.25.0
|
||||
"@tiptap/extension-table-row": 3.25.0
|
||||
"@tiptap/extension-task-item": 3.25.0
|
||||
"@tiptap/extension-task-list": 3.25.0
|
||||
"@tiptap/extension-text-align": 3.25.0
|
||||
"@tiptap/extension-text-style": 3.25.0
|
||||
"@tiptap/extension-underline": 3.25.0
|
||||
"@tiptap/react": ^3.25.0
|
||||
"@tiptap/starter-kit": ^3.25.0
|
||||
"@tiptap/extension-color": 3.24.0
|
||||
"@tiptap/extension-highlight": 3.24.0
|
||||
"@tiptap/extension-image": 3.24.0
|
||||
"@tiptap/extension-link": ^3.24.0
|
||||
"@tiptap/extension-placeholder": ^3.24.0
|
||||
"@tiptap/extension-table": 3.24.0
|
||||
"@tiptap/extension-table-cell": 3.24.0
|
||||
"@tiptap/extension-table-header": 3.24.0
|
||||
"@tiptap/extension-table-row": 3.24.0
|
||||
"@tiptap/extension-task-item": 3.24.0
|
||||
"@tiptap/extension-task-list": 3.24.0
|
||||
"@tiptap/extension-text-align": 3.24.0
|
||||
"@tiptap/extension-text-style": 3.24.0
|
||||
"@tiptap/extension-underline": 3.24.0
|
||||
"@tiptap/react": ^3.24.0
|
||||
"@tiptap/starter-kit": ^3.24.0
|
||||
"@trpc/client": ^11.17.0
|
||||
"@trpc/next": ^11.17.0
|
||||
"@trpc/react-query": ^11.17.0
|
||||
@@ -126,7 +126,7 @@ catalog:
|
||||
glob: ^13.0.6
|
||||
ical.js: ^2.2.1
|
||||
ioredis: 5.11.0
|
||||
isomorphic-dompurify: ^3.16.0
|
||||
isomorphic-dompurify: ^3.15.0
|
||||
jotai: ^2.20.0
|
||||
jsdom: ^29.1.1
|
||||
json5: ^2.2.3
|
||||
@@ -155,7 +155,7 @@ catalog:
|
||||
recharts: ^3.8.1
|
||||
sass: ^1.100.0
|
||||
semantic-release: ^25.0.3
|
||||
semver-parser: ^4.2.1
|
||||
semver-parser: ^4.2.0
|
||||
superjson: 2.2.6
|
||||
svgson: ^5.3.1
|
||||
swagger-ui-react: ^5.32.6
|
||||
|
||||
Reference in new issue
Block a user