mirror of
https://github.com/immich-app/immich.git
synced 2026-01-20 08:40:53 -08:00
Compare commits
10 Commits
chore-prev
...
fix-consid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03e8f98c2c | ||
|
|
843d563178 | ||
|
|
256d62e22d | ||
|
|
91592aa48e | ||
|
|
2ac113624b | ||
|
|
0052979853 | ||
|
|
79b6c4ac70 | ||
|
|
95eb3e26c3 | ||
|
|
613dc858cb | ||
|
|
2f3fbd7dc5 |
@@ -2124,7 +2124,6 @@
|
||||
"sync": "Sync",
|
||||
"sync_albums": "Sync albums",
|
||||
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
|
||||
"sync_cloud_ids": "Sync Cloud IDs",
|
||||
"sync_local": "Sync Local",
|
||||
"sync_remote": "Sync Remote",
|
||||
"sync_status": "Sync Status",
|
||||
|
||||
2
mobile/drift_schemas/main/drift_schema_v16.json
generated
2
mobile/drift_schemas/main/drift_schema_v16.json
generated
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return;
|
||||
}
|
||||
final logger = Logger('migrateCloudIds');
|
||||
|
||||
final db = ref.read(driftProvider);
|
||||
// Populate cloud IDs for local assets that don't have one yet
|
||||
@@ -29,9 +30,7 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
|
||||
final serverInfo = await ref.read(serverInfoProvider.notifier).getServerInfo();
|
||||
final canUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 4);
|
||||
if (!canUpdateMetadata) {
|
||||
Logger(
|
||||
'migrateCloudIds',
|
||||
).fine('Server version does not support asset metadata updates. Skipping cloudId migration.');
|
||||
logger.fine('Server version does not support asset metadata updates. Skipping cloudId migration.');
|
||||
return;
|
||||
}
|
||||
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
|
||||
@@ -40,25 +39,35 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
|
||||
try {
|
||||
await ref.read(syncStreamServiceProvider).sync();
|
||||
} catch (e, s) {
|
||||
Logger('migrateCloudIds').fine('Failed to complete remote sync before cloudId migration.', e, s);
|
||||
logger.fine('Failed to complete remote sync before cloudId migration.', e, s);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
Logger('migrateCloudIds').warning('Current user is null. Aborting cloudId migration.');
|
||||
logger.warning('Current user is null. Aborting cloudId migration.');
|
||||
return;
|
||||
}
|
||||
|
||||
final mappingsToUpdate = await _fetchCloudIdMappings(db, currentUser.id);
|
||||
// Deduplicate mappings as a single remote asset ID can match multiple local assets
|
||||
final seenRemoteAssetIds = <String>{};
|
||||
final uniqueMapping = mappingsToUpdate.where((mapping) {
|
||||
if (!seenRemoteAssetIds.add(mapping.remoteAssetId)) {
|
||||
logger.fine('Duplicate remote asset ID found: ${mapping.remoteAssetId}. Skipping duplicate entry.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
final assetApi = ref.read(apiServiceProvider).assetsApi;
|
||||
|
||||
if (canBulkUpdateMetadata) {
|
||||
await _bulkUpdateCloudIds(assetApi, mappingsToUpdate);
|
||||
await _bulkUpdateCloudIds(assetApi, uniqueMapping);
|
||||
return;
|
||||
}
|
||||
await _sequentialUpdateCloudIds(assetApi, mappingsToUpdate);
|
||||
await _sequentialUpdateCloudIds(assetApi, uniqueMapping);
|
||||
}
|
||||
|
||||
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
|
||||
@@ -152,10 +161,10 @@ Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId)
|
||||
// Skip locked assets as we cannot update them without unlocking first
|
||||
drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) &
|
||||
(drift.remoteAssetCloudIdEntity.cloudId.isNull() |
|
||||
((drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime)) &
|
||||
(drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude)) &
|
||||
(drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude)) &
|
||||
(drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)))),
|
||||
drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) |
|
||||
drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) |
|
||||
drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) |
|
||||
drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)),
|
||||
);
|
||||
return query.map((row) {
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin {
|
||||
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
TextColumn get cloudId => text().unique().nullable()();
|
||||
TextColumn get cloudId => text().nullable()();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().nullable()();
|
||||
|
||||
|
||||
@@ -438,7 +438,6 @@ class $RemoteAssetCloudIdEntityTable extends i2.RemoteAssetCloudIdEntity
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: i0.GeneratedColumn.constraintIsAlways('UNIQUE'),
|
||||
);
|
||||
static const i0.VerificationMeta _createdAtMeta = const i0.VerificationMeta(
|
||||
'createdAt',
|
||||
|
||||
@@ -6903,7 +6903,6 @@ i1.GeneratedColumn<String> _column_99(String aliasedName) =>
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'),
|
||||
);
|
||||
i1.GeneratedColumn<DateTime> _column_100(String aliasedName) =>
|
||||
i1.GeneratedColumn<DateTime>(
|
||||
|
||||
1
mobile/test/drift/main/generated/schema_v16.dart
generated
1
mobile/test/drift/main/generated/schema_v16.dart
generated
@@ -5391,7 +5391,6 @@ class RemoteAssetCloudIdEntity extends Table
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'),
|
||||
);
|
||||
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
|
||||
'created_at',
|
||||
|
||||
506
pnpm-lock.yaml
generated
506
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -258,6 +258,7 @@ export class MediaRepository {
|
||||
colorPrimaries: stream.color_primaries,
|
||||
colorSpace: stream.color_space,
|
||||
colorTransfer: stream.color_transfer,
|
||||
displayAspectRatio: stream.display_aspect_ratio,
|
||||
})),
|
||||
audioStreams: results.streams
|
||||
.filter((stream) => stream.codec_type === 'audio')
|
||||
|
||||
@@ -599,6 +599,21 @@ describe(MetadataService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply Display Aspect Ratio (DAR) for anamorphic video', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamAnamorphic);
|
||||
mockReadTags({});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id);
|
||||
// Anamorphic video: 1440x1080 with DAR 16:9 should display as 1920x1080
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ exifImageWidth: 1920, exifImageHeight: 1080 }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||
...assetStub.livePhotoWithOriginalFileName,
|
||||
|
||||
@@ -989,12 +989,15 @@ export class MetadataService extends BaseService {
|
||||
const tags: Pick<ImmichTags, 'Duration' | 'Orientation' | 'ImageWidth' | 'ImageHeight'> = {};
|
||||
|
||||
if (videoStreams[0]) {
|
||||
// Set video dimensions
|
||||
if (videoStreams[0].width) {
|
||||
tags.ImageWidth = videoStreams[0].width;
|
||||
// Set video dimensions, considering Display Aspect Ratio (DAR) for anamorphic videos
|
||||
const { width, height, displayAspectRatio } = videoStreams[0];
|
||||
const displayDimensions = this.applyDisplayAspectRatio(width, height, displayAspectRatio);
|
||||
|
||||
if (displayDimensions.width) {
|
||||
tags.ImageWidth = displayDimensions.width;
|
||||
}
|
||||
if (videoStreams[0].height) {
|
||||
tags.ImageHeight = videoStreams[0].height;
|
||||
if (displayDimensions.height) {
|
||||
tags.ImageHeight = displayDimensions.height;
|
||||
}
|
||||
|
||||
switch (videoStreams[0].rotation) {
|
||||
@@ -1023,4 +1026,44 @@ export class MetadataService extends BaseService {
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the display dimensions of a video based on its Display Aspect Ratio (DAR).
|
||||
* DAR accounts for anamorphic videos where the stored pixel dimensions differ from the intended display dimensions.
|
||||
* For example, a 1440x1080 video with DAR 16:9 should display as 1920x1080 (1440 * 16/9 / (1440/1080) = 1920).
|
||||
*/
|
||||
private applyDisplayAspectRatio(
|
||||
width: number | undefined,
|
||||
height: number | undefined,
|
||||
displayAspectRatio: string | undefined,
|
||||
): { width?: number; height?: number } {
|
||||
if (!width || !height || !displayAspectRatio) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
// Parse DAR string (e.g., "16:9" or "4:3")
|
||||
const darMatch = displayAspectRatio.match(/^(\d+):(\d+)$/);
|
||||
if (!darMatch) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const darWidth = Number.parseInt(darMatch[1], 10);
|
||||
const darHeight = Number.parseInt(darMatch[2], 10);
|
||||
if (!darWidth || !darHeight) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const dar = darWidth / darHeight;
|
||||
const storedAspectRatio = width / height;
|
||||
|
||||
// If DAR is effectively the same as stored aspect ratio (within a small tolerance), no adjustment needed
|
||||
if (Math.abs(dar - storedAspectRatio) < 0.01) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
// Apply DAR by adjusting width while keeping height constant
|
||||
// This matches how video players typically handle anamorphic content
|
||||
const displayWidth = Math.round(height * dar);
|
||||
return { width: displayWidth, height };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface VideoStreamInfo {
|
||||
colorPrimaries?: string;
|
||||
colorSpace?: string;
|
||||
colorTransfer?: string;
|
||||
displayAspectRatio?: string;
|
||||
}
|
||||
|
||||
export interface AudioStreamInfo {
|
||||
|
||||
17
server/test/fixtures/media.stub.ts
vendored
17
server/test/fixtures/media.stub.ts
vendored
@@ -272,4 +272,21 @@ export const probeStub = {
|
||||
},
|
||||
],
|
||||
}),
|
||||
videoStreamAnamorphic: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
videoStreams: [
|
||||
{
|
||||
index: 0,
|
||||
height: 1080,
|
||||
width: 1440,
|
||||
codecName: 'h264',
|
||||
frameCount: 100,
|
||||
rotation: 0,
|
||||
isHDR: false,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p',
|
||||
displayAspectRatio: '16:9',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^6.0.0",
|
||||
"svelte": "5.46.1",
|
||||
"svelte": "5.46.4",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
|
||||
@@ -3,17 +3,27 @@ import { thumbHashToRGBA } from 'thumbhash';
|
||||
|
||||
/**
|
||||
* Renders a thumbnail onto a canvas from a base64 encoded hash.
|
||||
* @param canvas
|
||||
* @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString)
|
||||
*/
|
||||
export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash));
|
||||
const pixels = ctx.createImageData(w, h);
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
pixels.data.set(rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
}
|
||||
export function thumbhash(canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) {
|
||||
render(canvas, options);
|
||||
|
||||
return {
|
||||
update(newOptions: { base64ThumbHash: string }) {
|
||||
render(canvas, newOptions);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const render = (canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(options.base64ThumbHash));
|
||||
const pixels = ctx.createImageData(w, h);
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
pixels.data.set(rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
};
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<script lang="ts">
|
||||
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { NavbarItem } from '@immich/ui';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col justify-between gap-2">
|
||||
<div class="flex flex-col pt-8 pe-4 gap-1">
|
||||
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARIES} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
|
||||
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
|
||||
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
||||
</div>
|
||||
|
||||
<div class="mb-2 me-4">
|
||||
<BottomInfo />
|
||||
</div>
|
||||
</div>
|
||||
61
web/src/lib/components/BreadcrumbActionPage.svelte
Normal file
61
web/src/lib/components/BreadcrumbActionPage.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Container,
|
||||
ContextMenuButton,
|
||||
HStack,
|
||||
MenuItemType,
|
||||
Scrollable,
|
||||
isMenuItemType,
|
||||
type BreadcrumbItem,
|
||||
} from '@immich/ui';
|
||||
import { mdiSlashForward } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
actions?: Array<HeaderButtonActionItem | MenuItemType>;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { breadcrumbs = [], actions = [], children }: Props = $props();
|
||||
|
||||
const enabledActions = $derived(
|
||||
actions
|
||||
.filter((action): action is HeaderButtonActionItem => !isMenuItemType(action))
|
||||
.filter((action) => action.$if?.() ?? true),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
|
||||
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
|
||||
|
||||
{#if enabledActions.length > 0}
|
||||
<div class="hidden md:block">
|
||||
<HStack gap={0}>
|
||||
{#each enabledActions as action, i (i)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
color={action.color ?? 'secondary'}
|
||||
leadingIcon={action.icon}
|
||||
onclick={() => action.onAction(action)}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
{/each}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
|
||||
{/if}
|
||||
</div>
|
||||
<Scrollable class="grow">
|
||||
<Container class="p-2 pb-16" {children} />
|
||||
</Scrollable>
|
||||
</div>
|
||||
@@ -1,23 +1,12 @@
|
||||
<script lang="ts">
|
||||
import AdminSidebar from '$lib/components/AdminSidebar.svelte';
|
||||
import PageContent from '$lib/components/layouts/PageContent.svelte';
|
||||
import BreadcrumbActionPage from '$lib/components/BreadcrumbActionPage.svelte';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import {
|
||||
AppShell,
|
||||
AppShellHeader,
|
||||
AppShellSidebar,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
ContextMenuButton,
|
||||
HStack,
|
||||
MenuItemType,
|
||||
Scrollable,
|
||||
isMenuItemType,
|
||||
type BreadcrumbItem,
|
||||
} from '@immich/ui';
|
||||
import { mdiSlashForward } from '@mdi/js';
|
||||
import { AppShell, AppShellHeader, AppShellSidebar, MenuItemType, NavbarItem, type BreadcrumbItem } from '@immich/ui';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -27,52 +16,31 @@
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { breadcrumbs, actions = [], children }: Props = $props();
|
||||
|
||||
const enabledActions = $derived(
|
||||
actions
|
||||
.filter((action): action is HeaderButtonActionItem => !isMenuItemType(action))
|
||||
.filter((action) => action.$if?.() ?? true),
|
||||
);
|
||||
let { breadcrumbs, actions, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<AppShell>
|
||||
<AppShellHeader>
|
||||
<NavigationBar showUploadButton={false} noBorder />
|
||||
<NavigationBar noBorder />
|
||||
</AppShellHeader>
|
||||
<AppShellSidebar bind:open={sidebarStore.isOpen} class="border-none shadow-none">
|
||||
<AdminSidebar />
|
||||
<AppShellSidebar
|
||||
bind:open={sidebarStore.isOpen}
|
||||
class="border-none shadow-none h-full flex flex-col justify-between gap-2"
|
||||
>
|
||||
<div class="flex flex-col pt-8 pe-4 gap-1">
|
||||
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARIES} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
|
||||
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
|
||||
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
||||
</div>
|
||||
|
||||
<div class="mb-2 me-4">
|
||||
<BottomInfo />
|
||||
</div>
|
||||
</AppShellSidebar>
|
||||
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
|
||||
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
|
||||
|
||||
{#if enabledActions.length > 0}
|
||||
<div class="hidden md:block">
|
||||
<HStack gap={0}>
|
||||
{#each enabledActions as action, i (i)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
color={action.color ?? 'secondary'}
|
||||
leadingIcon={action.icon}
|
||||
onclick={() => action.onAction(action)}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
{/each}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
|
||||
{/if}
|
||||
</div>
|
||||
<Scrollable class="grow">
|
||||
<PageContent>
|
||||
{@render children?.()}
|
||||
</PageContent>
|
||||
</Scrollable>
|
||||
</div>
|
||||
<BreadcrumbActionPage {breadcrumbs} {actions}>
|
||||
{@render children?.()}
|
||||
</BreadcrumbActionPage>
|
||||
</AppShell>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Container } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
const { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Container class="p-2 pb-16" {children} />
|
||||
@@ -14,13 +14,11 @@
|
||||
|
||||
interface Props {
|
||||
hideNavbar?: boolean;
|
||||
showUploadButton?: boolean;
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
scrollbar?: boolean;
|
||||
use?: ActionArray;
|
||||
actions?: Array<HeaderButtonActionItem | MenuItemType>;
|
||||
header?: Snippet;
|
||||
sidebar?: Snippet;
|
||||
buttons?: Snippet;
|
||||
children?: Snippet;
|
||||
@@ -28,13 +26,11 @@
|
||||
|
||||
let {
|
||||
hideNavbar = false,
|
||||
showUploadButton = false,
|
||||
title = undefined,
|
||||
description = undefined,
|
||||
scrollbar = true,
|
||||
use = [],
|
||||
actions = [],
|
||||
header,
|
||||
sidebar,
|
||||
buttons,
|
||||
children,
|
||||
@@ -52,10 +48,8 @@
|
||||
|
||||
<header>
|
||||
{#if !hideNavbar}
|
||||
<NavigationBar {showUploadButton} onUploadClick={() => openFileUploadDialog()} />
|
||||
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
|
||||
{/if}
|
||||
|
||||
{@render header?.()}
|
||||
</header>
|
||||
<div
|
||||
tabindex="-1"
|
||||
|
||||
@@ -25,14 +25,13 @@
|
||||
import UserAvatar from '../user-avatar.svelte';
|
||||
import AccountInfoPanel from './account-info-panel.svelte';
|
||||
|
||||
interface Props {
|
||||
showUploadButton?: boolean;
|
||||
type Props = {
|
||||
onUploadClick?: () => void;
|
||||
// TODO: remove once this is only used in <AppShellHeader>
|
||||
noBorder?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
let { showUploadButton = true, onUploadClick, noBorder = false }: Props = $props();
|
||||
let { onUploadClick, noBorder = false }: Props = $props();
|
||||
|
||||
let shouldShowAccountInfoPanel = $state(false);
|
||||
let shouldShowNotificationPanel = $state(false);
|
||||
@@ -105,7 +104,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !page.url.pathname.includes('/admin') && showUploadButton && onUploadClick}
|
||||
{#if !page.url.pathname.includes('/admin') && onUploadClick}
|
||||
<Button
|
||||
leadingIcon={mdiTrayArrowUp}
|
||||
onclick={onUploadClick}
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
bind:isSelected={isSharingSelected}
|
||||
></SideBarLink>
|
||||
|
||||
<p class="text-xs p-6 dark:text-immich-dark-fg uppercase">{$t('library')}</p>
|
||||
<p class="text-xs py-5 ps-6 dark:text-immich-dark-fg uppercase">{$t('library')}</p>
|
||||
|
||||
<SideBarLink
|
||||
title={$t('favorites')}
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
|
||||
{#if keys.length > 0}
|
||||
<Table class="mt-4" striped spacing="small">
|
||||
<Table class="mt-4" striped spacing="small" size="small">
|
||||
<TableHeader>
|
||||
<TableHeading>{$t('name')}</TableHeading>
|
||||
<TableHeading>{$t('permission')}</TableHeading>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
<section class="my-6 w-full">
|
||||
<Heading size="tiny">{$t('photos_and_videos')}</Heading>
|
||||
<Table striped spacing="medium" class="mt-4">
|
||||
<Table striped spacing="small" class="mt-4" size="small">
|
||||
<TableHeader>
|
||||
<TableHeading class="w-1/4">{$t('view_name')}</TableHeading>
|
||||
<TableHeading class="w-1/4">{$t('photos')}</TableHeading>
|
||||
@@ -83,7 +83,7 @@
|
||||
</Table>
|
||||
|
||||
<Heading size="tiny" class="mt-8">{$t('albums')}</Heading>
|
||||
<Table striped spacing="medium" class="mt-4">
|
||||
<Table striped spacing="small" class="mt-4" size="small">
|
||||
<TableHeader>
|
||||
<TableHeading class="w-1/2">{$t('owned')}</TableHeading>
|
||||
<TableHeading class="w-1/2">{$t('shared')}</TableHeading>
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} showUploadButton scrollbar={false}>
|
||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} scrollbar={false}>
|
||||
<Timeline
|
||||
enableRouting={true}
|
||||
bind:timelineManager
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<Container size="large" center class="my-4">
|
||||
<div class="flex flex-col items-center gap-2" in:fade={{ duration: 500 }}>
|
||||
{#if libraries.length > 0}
|
||||
<Table striped>
|
||||
<Table striped size="small" spacing="small">
|
||||
<TableHeader>
|
||||
<TableHeading class={classes.column1}>{$t('name')}</TableHeading>
|
||||
<TableHeading class={classes.column2}>{$t('owner')}</TableHeading>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
|
||||
<Container center size="large">
|
||||
<Table class="mt-4" striped spacing="large">
|
||||
<Table class="mt-4" striped spacing="small" size="small">
|
||||
<TableHeader>
|
||||
<TableHeading class={classes.column1}>{$t('email')}</TableHeading>
|
||||
<TableHeading class={classes.column2}>{$t('name')}</TableHeading>
|
||||
|
||||
Reference in New Issue
Block a user