Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
064d1842de | ||
|
|
9ced0ab7d0 | ||
|
|
2000e1fff3 |
No files matched your search
@@ -60,7 +60,7 @@ jobs:
|
||||
TURBO_PLATFORM: linux/amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
if: '!env.ACT'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -81,6 +81,9 @@ jobs:
|
||||
- name: Copy environment defaults
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Check formatting in affected workspaces
|
||||
run: pnpm turbo run format --affected --continue -- --check
|
||||
|
||||
- name: Lint, typecheck, and build affected workspaces
|
||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
|
||||
|
||||
@@ -90,8 +93,12 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Report coverage
|
||||
if: always() && !env.ACT
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
|
||||
container:
|
||||
name: Container and E2E
|
||||
name: Container, E2E, and memory
|
||||
needs: fast-gate
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -104,7 +111,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
if: '!env.ACT'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
@@ -113,11 +120,11 @@ jobs:
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: "!env.ACT"
|
||||
if: '!env.ACT'
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build container once
|
||||
if: "!env.ACT"
|
||||
if: '!env.ACT'
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -158,7 +165,7 @@ jobs:
|
||||
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: "!env.ACT"
|
||||
if: '!env.ACT'
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
@@ -184,6 +191,82 @@ jobs:
|
||||
path: e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Run memory regression test
|
||||
env:
|
||||
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
run: |
|
||||
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
|
||||
-e NO_EXTERNAL_CONNECTION=true \
|
||||
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
|
||||
homarr-ci
|
||||
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
|
||||
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
|
||||
URL="http://127.0.0.1:${PORT}"
|
||||
|
||||
healthy=false
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
|
||||
echo "Container healthy after $((i * 2))s"
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$healthy" != "true" ]; then
|
||||
echo "ERROR: Container never became healthy"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
fi
|
||||
|
||||
warmup_ok=0
|
||||
for _ in $(seq 1 5); do
|
||||
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
|
||||
sleep 2
|
||||
done
|
||||
if [ "$warmup_ok" -eq 0 ]; then
|
||||
echo "ERROR: All warmup requests failed"
|
||||
docker logs "$CONTAINER_NAME" --tail 50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
|
||||
echo "ERROR: Container died before memory measurement"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
}
|
||||
|
||||
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
|
||||
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
|
||||
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
|
||||
/MiB/ { gsub(/MiB/, ""); print $1; next }
|
||||
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
|
||||
{ print $1 / 1048576 }
|
||||
')
|
||||
|
||||
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
|
||||
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
|
||||
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
}
|
||||
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
|
||||
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
|
||||
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Clean up memory test container
|
||||
if: always()
|
||||
env:
|
||||
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
- name: Determine preview tag
|
||||
id: preview
|
||||
if: |
|
||||
@@ -233,7 +316,7 @@ jobs:
|
||||
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
|
||||
|
||||
- name: Delete GHCR package version
|
||||
if: "!env.ACT"
|
||||
if: '!env.ACT'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
|
||||
|
||||
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
|
||||
|
||||
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"eslint/no-unused-expressions": "warn"
|
||||
},
|
||||
"options": {
|
||||
"reportUnusedDisableDirectives": "off"
|
||||
},
|
||||
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
|
||||
"settings": {
|
||||
"react": { "version": "19" }
|
||||
|
||||
@@ -50,11 +50,12 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
|
||||
<code>user.read</code>,{" "}
|
||||
<code>user.update</code>,{" "}
|
||||
<code>asset.read</code>,{" "}
|
||||
<code>asset.view</code>,{" "}
|
||||
<code>asset.download</code>,{" "}
|
||||
<code>asset.statistics</code>,{" "}
|
||||
<code>server.statistics</code>,{" "}
|
||||
<code>server.about</code>.
|
||||
<br />
|
||||
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
|
||||
</span>
|
||||
|
||||
),
|
||||
|
||||
@@ -193,10 +193,6 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
positionToolbarAlertBanner: "top",
|
||||
enableTableFooter: false,
|
||||
enableBottomToolbar: false,
|
||||
// MRT's built-in full-screen mode does not stretch the table to fill the
|
||||
// viewport, so with few containers it renders as a thin strip (#6230). Disable
|
||||
// it, matching the other management tables (users, invites).
|
||||
enableFullScreenToggle: false,
|
||||
positionGlobalFilter: "right",
|
||||
mantineSearchTextInputProps: {
|
||||
placeholder: tDocker("table.search", { count: String(containers.length) }),
|
||||
|
||||
-18
@@ -31,7 +31,6 @@ import {
|
||||
userChangeHomeBoardsSchema,
|
||||
userChangeSearchPreferencesSchema,
|
||||
userEditProfileSchema,
|
||||
userEnableRightClickOnWidgetsSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userPingIconsEnabledSchema,
|
||||
} from "@homarr/validation/user";
|
||||
@@ -52,7 +51,6 @@ const userGeneralSettingsSchema = z.object({
|
||||
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
|
||||
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
||||
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
||||
@@ -80,7 +78,6 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
|
||||
ddgBangsEnabled: user.ddgBangs,
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
pingIconsEnabled: user.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
|
||||
});
|
||||
|
||||
export const UserGeneralSettingsForm = ({
|
||||
@@ -98,7 +95,6 @@ export const UserGeneralSettingsForm = ({
|
||||
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
|
||||
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
||||
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
||||
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
|
||||
|
||||
const initialValues = buildInitialValues(user);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
@@ -118,7 +114,6 @@ export const UserGeneralSettingsForm = ({
|
||||
changeSearchPreferencesMutation,
|
||||
changeFirstDayOfWeekMutation,
|
||||
changePingIconsEnabledMutation,
|
||||
changeEnableRightClickOnWidgetsMutation,
|
||||
];
|
||||
const isPending = mutations.some((m) => m.isPending);
|
||||
|
||||
@@ -174,14 +169,6 @@ export const UserGeneralSettingsForm = ({
|
||||
action: () =>
|
||||
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
|
||||
},
|
||||
{
|
||||
when: changed("enableRightClickOnWidgets"),
|
||||
action: () =>
|
||||
changeEnableRightClickOnWidgetsMutation.mutateAsync({
|
||||
id: user.id,
|
||||
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
||||
@@ -318,11 +305,6 @@ export const UserGeneralSettingsForm = ({
|
||||
label={t("user.field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.enableRightClickOnWidgets.label")}
|
||||
description={t("user.field.enableRightClickOnWidgets.description")}
|
||||
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ImageProxy } from "@homarr/image-proxy";
|
||||
|
||||
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await props.params;
|
||||
|
||||
const imageProxy = new ImageProxy();
|
||||
const result = await imageProxy.forwardImageAsync(id);
|
||||
|
||||
if ("error" in result) {
|
||||
const status = result.error === "upstream-error" ? result.statusCode : result.error === "fetch-error" ? 502 : 404;
|
||||
return new Response(null, {
|
||||
status,
|
||||
headers: {
|
||||
"X-Homarr-Image-Proxy-Error": result.error,
|
||||
},
|
||||
});
|
||||
const image = await imageProxy.forwardImageAsync(id);
|
||||
if (!image) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return new Response(result.image, {
|
||||
return new Response(image, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
|
||||
...(result.contentType ? { "Content-Type": result.contentType } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -48,7 +48,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
const tMenu = useScopedI18n("item.menu.label");
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -169,7 +168,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
|
||||
if (!session) return <>{children}</>;
|
||||
if (!isRightClickEnabled) return <>{children}</>;
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.13.1"
|
||||
"pnpm": ">=11.11.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.13.1"
|
||||
"packageManager": "pnpm@11.11.0"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.71.0",
|
||||
"version": "1.70.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -69,7 +69,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.13.1"
|
||||
"pnpm": ">=11.11.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.13.1"
|
||||
"packageManager": "pnpm@11.11.0"
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, asc, eq, like } from "@homarr/db";
|
||||
import { asc, eq, like } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { searchEngines, users } from "@homarr/db/schema";
|
||||
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
|
||||
@@ -130,15 +130,9 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
|
||||
return null;
|
||||
}),
|
||||
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||
search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.searchEngines.findMany({
|
||||
// Public dashboards have no session: restrict anonymous users to generic
|
||||
// (non-integration) engines so custom search engines work there too (#4132),
|
||||
// while integration-backed engines stay available only when signed in.
|
||||
where: and(
|
||||
like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
|
||||
ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
|
||||
),
|
||||
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
|
||||
with: {
|
||||
integration: {
|
||||
columns: {
|
||||
|
||||
@@ -357,83 +357,6 @@ describe("delete should delete user", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeEnableRightClickOnWidgets should toggle the right-click preference", () => {
|
||||
test("non-admin can toggle their own preference", async () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
await db.insert(users).values({
|
||||
id: defaultOwnerId,
|
||||
name: "owner",
|
||||
});
|
||||
|
||||
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: false });
|
||||
|
||||
const updated = await db.query.users.findFirst({
|
||||
where: eq(users.id, defaultOwnerId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(updated?.enableRightClickOnWidgets).toBe(false);
|
||||
|
||||
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: true });
|
||||
|
||||
const restored = await db.query.users.findFirst({
|
||||
where: eq(users.id, defaultOwnerId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(restored?.enableRightClickOnWidgets).toBe(true);
|
||||
});
|
||||
|
||||
test("non-admin cannot toggle another user's preference", async () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const otherUserId = createId();
|
||||
await db.insert(users).values({ id: defaultOwnerId });
|
||||
await db.insert(users).values({ id: otherUserId, name: "other" });
|
||||
|
||||
await expect(
|
||||
caller.changeEnableRightClickOnWidgets({ id: otherUserId, enableRightClickOnWidgets: false }),
|
||||
).rejects.toThrow("User not found");
|
||||
|
||||
const other = await db.query.users.findFirst({
|
||||
where: eq(users.id, otherUserId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(other?.enableRightClickOnWidgets).toBe(true);
|
||||
});
|
||||
|
||||
test("admin can toggle another user's preference", async () => {
|
||||
const db = createDb();
|
||||
const adminSession = createSession(["admin"]);
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: adminSession,
|
||||
});
|
||||
|
||||
const targetUserId = createId();
|
||||
await db.insert(users).values({ id: defaultOwnerId });
|
||||
await db.insert(users).values({ id: targetUserId, name: "target" });
|
||||
|
||||
await caller.changeEnableRightClickOnWidgets({ id: targetUserId, enableRightClickOnWidgets: false });
|
||||
|
||||
const target = await db.query.users.findFirst({
|
||||
where: eq(users.id, targetUserId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(target?.enableRightClickOnWidgets).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
|
||||
await db.insert(onboarding).values({
|
||||
id: createId(),
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
userCreateSchema,
|
||||
userDdgBangsSchema,
|
||||
userEditProfileSchema,
|
||||
userEnableRightClickOnWidgetsSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userInitSchema,
|
||||
userPingIconsEnabledSchema,
|
||||
@@ -346,7 +345,6 @@ export const userRouter = createTRPCRouter({
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -383,7 +381,6 @@ export const userRouter = createTRPCRouter({
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -654,32 +651,6 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
changeEnableRightClickOnWidgets: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "PATCH",
|
||||
path: "/api/users/right-click-widgets",
|
||||
tags: ["users"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(convertIntersectionToZodObject(userEnableRightClickOnWidgetsSchema.and(byIdSchema)))
|
||||
.output(z.void())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changePingIconsEnabled: protectedProcedure
|
||||
.input(userPingIconsEnabledSchema.and(byIdSchema))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -22,10 +22,20 @@ export const downloadsRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.concat(createDownloadClientIntegrationMiddleware("query"))
|
||||
.input(z.object({ limitPerIntegration: z.number().default(50) }))
|
||||
.input(
|
||||
z.object({
|
||||
limitPerIntegration: z.number().default(50),
|
||||
qbittorrentSort: z.string().optional(),
|
||||
qbittorrentSortReverse: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
|
||||
const innerHandler = downloadClientRequestHandler.handler(integration, {
|
||||
limit: input.limitPerIntegration,
|
||||
qbittorrentSort: input.qbittorrentSort,
|
||||
qbittorrentSortReverse: input.qbittorrentSortReverse,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `user` ADD `enable_right_click_on_widgets` boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -302,13 +302,6 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0042_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "5",
|
||||
"when": 1783852800000,
|
||||
"tag": "0043_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "user" ADD COLUMN "enable_right_click_on_widgets" boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -64,13 +64,6 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0008_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1783786677702,
|
||||
"tag": "0009_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `user` ADD `enable_right_click_on_widgets` integer DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -288,13 +288,6 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0040_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1783786658943,
|
||||
"tag": "0041_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,6 @@ export const users = mysqlTable("user", {
|
||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean().default(false).notNull(),
|
||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||
completedManageTour: boolean().default(false).notNull(),
|
||||
completedBoardTour: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -85,7 +85,6 @@ export const users = pgTable("user", {
|
||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean().default(false).notNull(),
|
||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||
completedManageTour: boolean().default(false).notNull(),
|
||||
completedBoardTour: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -68,7 +68,6 @@ export const users = sqliteTable("user", {
|
||||
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
|
||||
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
|
||||
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -37,5 +37,5 @@ describe("Mysql Migration", () => {
|
||||
|
||||
connection.end();
|
||||
await mysqlContainer.stop();
|
||||
}, 120_000);
|
||||
}, 40_000);
|
||||
});
|
||||
@@ -467,6 +467,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
|
||||
category: ["archiving"],
|
||||
documentationUrl: null,
|
||||
defaultUrl: "http://localhost:8001",
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
|
||||
@@ -19,12 +19,6 @@ const createUrlByIdChannel = (id: string) =>
|
||||
headers: `${string}.${string}`;
|
||||
}>(`image-proxy:url:${id}`);
|
||||
|
||||
export type ForwardImageResult =
|
||||
| { image: ArrayBuffer; contentType: string | null }
|
||||
| { error: "not-found" }
|
||||
| { error: "upstream-error"; statusCode: number }
|
||||
| { error: "fetch-error" };
|
||||
|
||||
export class ImageProxy {
|
||||
private static hmacKey: Buffer<ArrayBuffer> | null = null;
|
||||
|
||||
@@ -53,34 +47,17 @@ export class ImageProxy {
|
||||
return this.createImageUrl(id);
|
||||
}
|
||||
|
||||
public async forwardImageAsync(id: string): Promise<ForwardImageResult> {
|
||||
public async forwardImageAsync(id: string): Promise<ArrayBuffer | null> {
|
||||
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
|
||||
if (!urlAndHeaders) {
|
||||
return { error: "not-found" };
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
|
||||
headers: urlAndHeaders.headers ?? {},
|
||||
});
|
||||
|
||||
const proxyUrl = this.createImageUrl(id);
|
||||
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
|
||||
try {
|
||||
response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
|
||||
headers: urlAndHeaders.headers ?? {},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to connect to image source",
|
||||
{
|
||||
id,
|
||||
url: this.redactUrl(urlAndHeaders.url),
|
||||
headers: this.redactHeaders(urlAndHeaders.headers),
|
||||
proxyUrl,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
return { error: "fetch-error" };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata("Failed to fetch image", {
|
||||
@@ -91,7 +68,7 @@ export class ImageProxy {
|
||||
statusCode: response.status,
|
||||
}),
|
||||
);
|
||||
return { error: "upstream-error", statusCode: response.status };
|
||||
return null;
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
@@ -103,7 +80,7 @@ export class ImageProxy {
|
||||
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
|
||||
});
|
||||
|
||||
return { image: arrayBuffer, contentType: response.headers.get("content-type") };
|
||||
return arrayBuffer;
|
||||
}
|
||||
|
||||
private createImageUrl(id: string): string {
|
||||
|
||||
@@ -27,10 +27,18 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
|
||||
};
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
public async getClientJobsAndStatusAsync(input: {
|
||||
limit: number;
|
||||
qbittorrentSort?: string;
|
||||
qbittorrentSortReverse?: boolean;
|
||||
}): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "torrent";
|
||||
const client = await this.getClientAsync();
|
||||
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
|
||||
const torrents = await client.listTorrents({
|
||||
limit: input.limit,
|
||||
sort: input.qbittorrentSort,
|
||||
reverse: input.qbittorrentSortReverse,
|
||||
});
|
||||
const rates = torrents.reduce(
|
||||
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
||||
{ down: 0, up: 0 },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AssetMediaSize,
|
||||
getAlbumInfo,
|
||||
getAllAlbums,
|
||||
getMyUser,
|
||||
@@ -76,9 +75,7 @@ export class ImmichIntegration extends Integration {
|
||||
assets: await Promise.all(
|
||||
albumAssets.map(async (asset) => {
|
||||
const publicLink = await imageProxy.createImageAsync(
|
||||
// This is the URL generated by the SDK's viewAsset call. Preview images are
|
||||
// browser-compatible while avoiding the large original asset download.
|
||||
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
|
||||
this.url(`/api/assets/${asset.id}/original`).toString(),
|
||||
{
|
||||
"x-api-key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { DownloadClientItem } from "./download-client-items";
|
||||
|
||||
export interface IDownloadClientIntegration {
|
||||
/** Get download client's status and list of all of it's items */
|
||||
getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus>;
|
||||
getClientJobsAndStatusAsync(input: {
|
||||
limit: number;
|
||||
qbittorrentSort?: string;
|
||||
qbittorrentSortReverse?: boolean;
|
||||
}): Promise<DownloadClientJobsAndStatus>;
|
||||
/** Pauses the client or all of it's items */
|
||||
pauseQueueAsync(): Promise<void>;
|
||||
/** Pause a single item using it's ID */
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
|
||||
export const downloadClientRequestHandler = createIntegrationRequestHandler<
|
||||
DownloadClientJobsAndStatus,
|
||||
IntegrationKindByCategory<"downloadClient">,
|
||||
{ limit: number }
|
||||
{ limit: number; qbittorrentSort?: string; qbittorrentSortReverse?: boolean }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
|
||||
@@ -745,19 +745,6 @@ const linuxServerIOReleasesSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
// Parse a github.com project URL (as returned by the LSIO registry) into an
|
||||
// "owner/repo" identifier, or null if it is not a github.com repository URL.
|
||||
const parseGithubRepo = (url: string): string | null => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.hostname !== "github.com") return null;
|
||||
const [owner, repo] = parsed.pathname.replace(/^\/+/, "").split("/");
|
||||
return owner && repo ? `${owner}/${repo}` : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getLinuxServerIOReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
@@ -787,29 +774,6 @@ const getLinuxServerIOReleaseAsync = async (
|
||||
error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` },
|
||||
};
|
||||
|
||||
// LSIO's /api/v1/images only exposes the single latest version, so a version
|
||||
// filter can never surface an older release (#4351). When the image has a GitHub
|
||||
// repository, delegate to the GitHub release provider to fetch the real release
|
||||
// history and apply the filter, while keeping LSIO's own image metadata. Any
|
||||
// GitHub failure (no repo, no releases, rate limit) falls back to the LSIO latest.
|
||||
const githubRepo = parseGithubRepo(release.github_url);
|
||||
if (githubRepo) {
|
||||
const githubResult = await getGithubReleaseAsync(getReleaseProviderDefaultUrl("github"), githubRepo, versionRegex);
|
||||
if (githubResult.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...githubResult.data,
|
||||
projectUrl: release.github_url,
|
||||
projectDescription: release.description,
|
||||
isArchived: release.deprecated,
|
||||
starsCount: release.stars,
|
||||
createdAt: release.initial_date ?? githubResult.data.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const regex = compileVersionRegex(versionRegex);
|
||||
if (versionRegex && !regex) {
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,6 @@ export type SettingsContextProps = Pick<
|
||||
| "openSearchInNewTab"
|
||||
| "ddgBangs"
|
||||
| "pingIconsEnabled"
|
||||
| "enableRightClickOnWidgets"
|
||||
> &
|
||||
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
|
||||
Pick<ServerSettings["user"], "enableGravatar">;
|
||||
@@ -33,7 +32,6 @@ export type UserSettings = Pick<
|
||||
| "openSearchInNewTab"
|
||||
| "ddgBangs"
|
||||
| "pingIconsEnabled"
|
||||
| "enableRightClickOnWidgets"
|
||||
>;
|
||||
|
||||
export const createSettings = ({
|
||||
@@ -50,7 +48,6 @@ export const createSettings = ({
|
||||
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
|
||||
pingIconsEnabled: user?.pingIconsEnabled ?? false,
|
||||
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
enableGravatar: serverSettings.user.enableGravatar,
|
||||
|
||||
@@ -56,13 +56,6 @@ export const userPreferenceDefinitions = [
|
||||
mutationGroup: "pingIconsEnabled",
|
||||
aliases: ["ping", "icon", "status"],
|
||||
},
|
||||
{
|
||||
key: "enableRightClickOnWidgets",
|
||||
kind: "boolean",
|
||||
guest: false,
|
||||
mutationGroup: "enableRightClickOnWidgets",
|
||||
aliases: ["right click", "context menu", "tile", "widget menu"],
|
||||
},
|
||||
{
|
||||
key: "fullPreferencesPage",
|
||||
kind: "link",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
IconActivity,
|
||||
IconCalendarWeek,
|
||||
IconClick,
|
||||
IconDeviceDesktop,
|
||||
IconExternalLink,
|
||||
IconHome,
|
||||
@@ -47,7 +46,6 @@ export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
|
||||
homeBoardId: IconHome,
|
||||
mobileHomeBoardId: IconLayoutBoard,
|
||||
pingIconsEnabled: IconActivity,
|
||||
enableRightClickOnWidgets: IconClick,
|
||||
fullPreferencesPage: IconSettings,
|
||||
};
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ export const useUserPreferences = () => {
|
||||
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
|
||||
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
|
||||
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
|
||||
const rightClickMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation({ onSuccess: refresh });
|
||||
|
||||
const getEffective = (key: UserPreferenceKey): unknown =>
|
||||
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
|
||||
@@ -117,8 +116,6 @@ export const useUserPreferences = () => {
|
||||
}),
|
||||
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
|
||||
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
|
||||
enableRightClickOnWidgets: (uid, _key, value) =>
|
||||
rightClickMutation.mutateAsync({ id: uid, enableRightClickOnWidgets: value as boolean }),
|
||||
};
|
||||
|
||||
const groupPending: Record<string, boolean> = {
|
||||
@@ -126,7 +123,6 @@ export const useUserPreferences = () => {
|
||||
homeBoards: homeBoardsMutation.isPending,
|
||||
firstDayOfWeek: firstDayMutation.isPending,
|
||||
pingIconsEnabled: pingMutation.isPending,
|
||||
enableRightClickOnWidgets: rightClickMutation.isPending,
|
||||
};
|
||||
|
||||
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
|
||||
|
||||
@@ -198,10 +198,6 @@
|
||||
"pingIconsEnabled": {
|
||||
"label": "Use icons for pings"
|
||||
},
|
||||
"enableRightClickOnWidgets": {
|
||||
"label": "Enable right-click on widgets",
|
||||
"description": "Show Homarr's context menu when right-clicking a widget. Disable to use the browser's default menu (e.g. \"Open link in new tab\")."
|
||||
},
|
||||
"defaultSearchEngine": {
|
||||
"label": "Default search engine"
|
||||
},
|
||||
@@ -3068,8 +3064,28 @@
|
||||
"limitPerIntegration": {
|
||||
"label": "Limit items per integration",
|
||||
"description": "This will limit the number of items shown per integration, not globally"
|
||||
}
|
||||
},
|
||||
},
|
||||
"qbittorrentSort": {
|
||||
"label": "qBittorrent sort order",
|
||||
"option": {
|
||||
"added_on": "Date Added",
|
||||
"completion_on": "Completion Date",
|
||||
"dlspeed": "Download Speed",
|
||||
"eta": "ETA",
|
||||
"name": "Name",
|
||||
"priority": "Priority",
|
||||
"progress": "Progress",
|
||||
"ratio": "Ratio",
|
||||
"size": "Size",
|
||||
"state": "State",
|
||||
"time_active": "Time Active",
|
||||
"upspeed": "Upload Speed",
|
||||
"uploaded": "Uploaded"
|
||||
}
|
||||
},
|
||||
"qbittorrentSortReverse": {
|
||||
"label": "Reverse qBittorrent sort order"
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "Select Columns in Items",
|
||||
"noCommunications": "Can't load data from integration"
|
||||
@@ -5957,14 +5973,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableRightClickOnWidgets": {
|
||||
"label": "Enable right-click on widgets",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Enable right-click on widgets"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fullPreferencesPage": {
|
||||
"label": "Open full preferences page"
|
||||
}
|
||||
|
||||
@@ -130,10 +130,6 @@ export const userPingIconsEnabledSchema = z.object({
|
||||
pingIconsEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const userEnableRightClickOnWidgetsSchema = z.object({
|
||||
enableRightClickOnWidgets: z.boolean(),
|
||||
});
|
||||
|
||||
export const userDdgBangsSchema = z.object({
|
||||
ddgBangs: z.boolean(),
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
@@ -11,9 +13,14 @@ interface CalendarDayProps {
|
||||
disabled: boolean;
|
||||
rootWidth: number;
|
||||
rootHeight: number;
|
||||
onOpen: (close: () => void) => void;
|
||||
}
|
||||
|
||||
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
|
||||
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onOpen }: CalendarDayProps) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const { primaryColor } = useMantineTheme();
|
||||
const { ref, height } = useElementSize();
|
||||
const board = useRequiredBoard();
|
||||
const mantineTheme = useMantineTheme();
|
||||
const actualItemRadius = mantineTheme.radius[board.itemRadius];
|
||||
@@ -22,19 +29,39 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
const shouldScaleDown = minAxisSize < 350;
|
||||
const isSmall = rootHeight < 256;
|
||||
|
||||
const isTooSmallForIndicators = height < 30;
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (disabled) return;
|
||||
onOpen(() => {
|
||||
setOpened(false);
|
||||
setPinned(false);
|
||||
});
|
||||
setOpened(true);
|
||||
}, [disabled, onOpen]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (!pinned) setOpened(false);
|
||||
}, [pinned]);
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
<Popover
|
||||
position="bottom"
|
||||
withArrow
|
||||
withinPortal
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
transitionProps={{ transition: "pop" }}
|
||||
openDelay={350}
|
||||
closeDelay={400}
|
||||
transitionProps={{
|
||||
transition: "pop",
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setOpened(value);
|
||||
if (!value) setPinned(false);
|
||||
}}
|
||||
opened={opened}
|
||||
disabled={disabled}
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<Popover.Target>
|
||||
<Container
|
||||
h="100%"
|
||||
w="100%"
|
||||
@@ -42,23 +69,38 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
pt={isSmall ? 0 : 10}
|
||||
pb={isSmall ? 0 : 10}
|
||||
m={0}
|
||||
pos="relative"
|
||||
ref={ref}
|
||||
bd={`2px solid ${opened && !disabled ? primaryColor : "transparent"}`}
|
||||
style={{
|
||||
alignContent: "center",
|
||||
borderRadius: actualItemRadius,
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!disabled) setPinned((prev) => !prev);
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
|
||||
{date.getDate()}
|
||||
</Text>
|
||||
<NotificationIndicator events={events} isSmall={isSmall} />
|
||||
{!isTooSmallForIndicators && <NotificationIndicator events={events} isSmall={isSmall} />}
|
||||
</Container>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
|
||||
</Popover.Target>
|
||||
{/* Popover has some offset on the left side, padding is removed because of scrollarea paddings */}
|
||||
<Popover.Dropdown
|
||||
maw="calc(100vw - 24px)"
|
||||
w={512}
|
||||
pe={4}
|
||||
pb={0}
|
||||
style={{ overflow: "hidden" }}
|
||||
onMouseEnter={() => setOpened(true)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<CalendarEventList events={events} />
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,6 +111,7 @@ interface NotificationIndicatorProps {
|
||||
|
||||
const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => {
|
||||
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String);
|
||||
/* position bottom is lower when small to not be on top of number*/
|
||||
return (
|
||||
<Flex
|
||||
w="75%"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMantineTheme } from "@mantine/core";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
@@ -65,6 +65,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
||||
const isSmall = width < 256;
|
||||
|
||||
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
|
||||
const activeCloseRef = useRef<(() => void) | null>(null);
|
||||
const onDayOpen = useCallback((close: () => void) => {
|
||||
activeCloseRef.current?.();
|
||||
activeCloseRef.current = close;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
@@ -141,6 +146,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
||||
disabled={isEditMode || eventsForDate.length === 0}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
onOpen={onDayOpen}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -91,6 +91,8 @@ export default function DownloadClientsWidget({
|
||||
const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
|
||||
integrationIds,
|
||||
limitPerIntegration: options.limitPerIntegration,
|
||||
qbittorrentSort: options.qbittorrentSort,
|
||||
qbittorrentSortReverse: options.qbittorrentSortReverse,
|
||||
});
|
||||
|
||||
const t = useScopedI18n("widget.downloads");
|
||||
|
||||
@@ -88,6 +88,30 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
|
||||
validate: z.number().min(1),
|
||||
withDescription: true,
|
||||
}),
|
||||
qbittorrentSort: factory.select({
|
||||
defaultValue: "added_on",
|
||||
options: [
|
||||
"added_on",
|
||||
"completion_on",
|
||||
"dlspeed",
|
||||
"eta",
|
||||
"name",
|
||||
"priority",
|
||||
"progress",
|
||||
"ratio",
|
||||
"size",
|
||||
"state",
|
||||
"time_active",
|
||||
"upspeed",
|
||||
"uploaded",
|
||||
].map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.downloads.option.qbittorrentSort.option.${value}`),
|
||||
})),
|
||||
}),
|
||||
qbittorrentSortReverse: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
defaultSort: {
|
||||
@@ -116,6 +140,14 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
|
||||
shouldHide: (_, integrationKinds) =>
|
||||
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
|
||||
},
|
||||
qbittorrentSort: {
|
||||
shouldHide: (_, integrationKinds) =>
|
||||
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
|
||||
},
|
||||
qbittorrentSortReverse: {
|
||||
shouldHide: (_, integrationKinds) =>
|
||||
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -75,7 +75,9 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
|
||||
? Math.min(100, Math.round((positionMs / durationMs) * 100))
|
||||
: null;
|
||||
const remainingMinutes =
|
||||
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
|
||||
positionMs !== null && durationMs !== null
|
||||
? Math.max(0, Math.round((durationMs - positionMs) / 60_000))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
@@ -122,8 +124,8 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
|
||||
|
||||
const isTranscoding = Boolean(
|
||||
currentlyPlaying.metadata?.transcoding.target.videoCodec ??
|
||||
currentlyPlaying.metadata?.transcoding.target.audioCodec ??
|
||||
currentlyPlaying.metadata?.transcoding.container,
|
||||
currentlyPlaying.metadata?.transcoding.target.audioCodec ??
|
||||
currentlyPlaying.metadata?.transcoding.container,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -239,7 +239,7 @@ allowBuilds:
|
||||
"@tree-sitter-grammars/tree-sitter-yaml": true
|
||||
bcrypt: true
|
||||
better-sqlite3: true
|
||||
cpu-features: false
|
||||
cpu-features: true
|
||||
esbuild: true
|
||||
sharp: true
|
||||
ssh2: true
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"beanbag420","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontentLine truncated
|
||||
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"beanbag420","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontentLine truncated
|
||||
Reference in new issue
Block a user