feat: Importing shared assets
- Implemented `upload.ts` for handling the upload of shared assets, including validation and error handling. - Created `video.ts` for proxying video playback requests from shared assets. - Developed `import-shared.tsx` page for importing shared albums, including UI for asset selection and album creation options. - Updated album asset schema to use more descriptive field names (`albumId` and `assetId`).
This commit is contained in:
1 parent
5af9e85f16
commit
ac8267b431
12 files changed
+1984
-5
No files matched your search
@@ -52,6 +52,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-defaulticon-compatibility": "0.1.2",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User, Video } from "lucide-react";
|
||||
import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, Share2, User, Video } from "lucide-react";
|
||||
|
||||
export const sidebarNavs = [
|
||||
// {
|
||||
@@ -36,6 +36,11 @@ export const sidebarNavs = [
|
||||
link: "/albums",
|
||||
icon: <GalleryHorizontal className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Import shared",
|
||||
link: "/assets/import-shared",
|
||||
icon: <Share2 className="h-4 w-4" />,
|
||||
},
|
||||
|
||||
{
|
||||
title: "Geo Heatmap",
|
||||
|
||||
@@ -27,12 +27,12 @@ const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) =>
|
||||
asset a
|
||||
LEFT JOIN
|
||||
album_asset aaa
|
||||
ON a.id = aaa."assetsId"
|
||||
ON a.id = aaa."assetId"
|
||||
LEFT JOIN
|
||||
asset_exif e
|
||||
ON a.id = e."assetId"
|
||||
WHERE
|
||||
aaa."albumsId" IS NULL
|
||||
aaa."albumId" IS NULL
|
||||
AND a."ownerId" = '${ownerId}'
|
||||
AND e."dateTimeOriginal"::date = '${date}'
|
||||
AND a."visibility" = 'timeline'
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
interface IAlbumContributorCount {
|
||||
userId: string;
|
||||
assetCount: number;
|
||||
}
|
||||
|
||||
interface ImmichOwner {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
interface ImmichExifInfo {
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
country?: string | null;
|
||||
description?: string | null;
|
||||
fileSizeInByte?: number | null;
|
||||
}
|
||||
|
||||
interface ImmichAsset {
|
||||
id: string;
|
||||
originalFileName: string;
|
||||
type: string;
|
||||
fileCreatedAt?: string | null;
|
||||
localDateTime?: string | null;
|
||||
thumbhash?: string | null;
|
||||
exifInfo?: ImmichExifInfo | null;
|
||||
}
|
||||
|
||||
interface ImmichAlbumResponse {
|
||||
albumName: string;
|
||||
assetCount?: number;
|
||||
owner?: ImmichOwner | null;
|
||||
description?: string | null;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
shared?: boolean;
|
||||
hasSharedLink?: boolean;
|
||||
lastModifiedAssetTimestamp?: string | null;
|
||||
order?: string | null;
|
||||
contributorCounts?: IAlbumContributorCount[];
|
||||
assets: ImmichAsset[];
|
||||
}
|
||||
|
||||
interface ImmichSharedLinkResponse {
|
||||
id: string;
|
||||
key: string;
|
||||
type: string;
|
||||
createdAt: string;
|
||||
expiresAt?: string | null;
|
||||
allowUpload?: boolean;
|
||||
allowDownload?: boolean;
|
||||
showMetadata?: boolean;
|
||||
album?: { id: string } | null;
|
||||
}
|
||||
|
||||
interface IImportSharedAsset {
|
||||
id: string;
|
||||
originalFileName: string;
|
||||
type: string;
|
||||
fileCreatedAt?: string | null;
|
||||
localDateTime?: string | null;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
thumbhash?: string | null;
|
||||
fileSizeInByte?: number | null;
|
||||
}
|
||||
|
||||
interface IImportSharedAlbum {
|
||||
albumName: string;
|
||||
assetCount: number;
|
||||
owner?: ImmichOwner | null;
|
||||
description?: string | null;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
shared?: boolean;
|
||||
hasSharedLink?: boolean;
|
||||
lastModifiedAssetTimestamp?: string | null;
|
||||
order?: string | null;
|
||||
contributorCounts?: IAlbumContributorCount[];
|
||||
assets: IImportSharedAsset[];
|
||||
}
|
||||
|
||||
interface IImportSharedResponse {
|
||||
link: string;
|
||||
origin: string;
|
||||
key: string;
|
||||
sharedLink: {
|
||||
id: string;
|
||||
type: string;
|
||||
createdAt: string;
|
||||
expiresAt?: string | null;
|
||||
allowUpload?: boolean;
|
||||
allowDownload?: boolean;
|
||||
showMetadata?: boolean;
|
||||
};
|
||||
album: IImportSharedAlbum | null;
|
||||
}
|
||||
|
||||
const respondWithError = (res: NextApiResponse, status: number, message: string) => {
|
||||
return res.status(status).json({ error: message });
|
||||
};
|
||||
|
||||
const parseSharedLink = (link: string) => {
|
||||
const trimmed = link.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
const key = segments.pop();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
return { key, origin: url.origin, original: trimmed };
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildLocationString = (exif?: ImmichExifInfo | null) => {
|
||||
if (!exif) {
|
||||
return null;
|
||||
}
|
||||
const parts = [exif.city, exif.state, exif.country]
|
||||
.filter((value): value is string => !!value && value.trim().length > 0);
|
||||
return parts.length > 0 ? parts.join(", ") : null;
|
||||
};
|
||||
|
||||
const fetchJson = async <T>(url: string) => {
|
||||
const response = await fetch(url, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed for ${url} (status ${response.status})`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return respondWithError(res, 405, "Method Not Allowed");
|
||||
}
|
||||
|
||||
const payload = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
|
||||
const link = payload?.link;
|
||||
|
||||
if (!link || typeof link !== "string") {
|
||||
return respondWithError(res, 400, "A shared album link is required");
|
||||
}
|
||||
|
||||
const parsed = parseSharedLink(link);
|
||||
if (!parsed) {
|
||||
return respondWithError(res, 400, "Invalid Immich share link");
|
||||
}
|
||||
|
||||
try {
|
||||
const sharedLink = await fetchJson<ImmichSharedLinkResponse>(
|
||||
`${parsed.origin}/api/shared-links/me?key=${parsed.key}`
|
||||
);
|
||||
|
||||
const albumId = sharedLink.album?.id;
|
||||
let albumResult: IImportSharedAlbum | null = null;
|
||||
|
||||
if (albumId) {
|
||||
const album = await fetchJson<ImmichAlbumResponse>(
|
||||
`${parsed.origin}/api/albums/${albumId}?key=${parsed.key}&withoutAssets=false`
|
||||
);
|
||||
|
||||
const assets = (album.assets || []).map((asset) => ({
|
||||
id: asset.id,
|
||||
originalFileName: asset.originalFileName,
|
||||
type: asset.type,
|
||||
fileCreatedAt: asset.fileCreatedAt ?? null,
|
||||
localDateTime: asset.localDateTime ?? null,
|
||||
description: asset.exifInfo?.description ?? null,
|
||||
location: buildLocationString(asset.exifInfo),
|
||||
thumbhash: asset.thumbhash ?? null,
|
||||
fileSizeInByte: asset.exifInfo?.fileSizeInByte ?? null,
|
||||
} satisfies IImportSharedAsset));
|
||||
|
||||
albumResult = {
|
||||
albumName: album.albumName,
|
||||
assetCount: album.assetCount ?? assets.length,
|
||||
owner: album.owner ?? null,
|
||||
description: album.description ?? null,
|
||||
startDate: album.startDate ?? null,
|
||||
endDate: album.endDate ?? null,
|
||||
shared: album.shared ?? false,
|
||||
hasSharedLink: album.hasSharedLink ?? false,
|
||||
lastModifiedAssetTimestamp: album.lastModifiedAssetTimestamp ?? null,
|
||||
order: album.order ?? null,
|
||||
contributorCounts: album.contributorCounts ?? [],
|
||||
assets,
|
||||
} satisfies IImportSharedAlbum;
|
||||
}
|
||||
|
||||
const responseBody: IImportSharedResponse = {
|
||||
link: parsed.original,
|
||||
origin: parsed.origin,
|
||||
key: parsed.key,
|
||||
sharedLink: {
|
||||
id: sharedLink.id,
|
||||
type: sharedLink.type,
|
||||
createdAt: sharedLink.createdAt,
|
||||
expiresAt: sharedLink.expiresAt ?? null,
|
||||
allowUpload: sharedLink.allowUpload ?? false,
|
||||
allowDownload: sharedLink.allowDownload ?? false,
|
||||
showMetadata: sharedLink.showMetadata ?? false,
|
||||
},
|
||||
album: albumResult,
|
||||
};
|
||||
|
||||
return res.status(200).json(responseBody);
|
||||
} catch (error: any) {
|
||||
console.error("Import shared error", error);
|
||||
return respondWithError(res, 500, error?.message ?? "Failed to import shared album");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ENV } from "@/config/environment";
|
||||
|
||||
export const POWER_TOOLS_TAG_NAME = "immich-power-tools";
|
||||
|
||||
export type HeadersRecord = Record<string, string>;
|
||||
|
||||
interface ImmichTagResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const ensurePowerToolsTag = async (headers: HeadersRecord) => {
|
||||
const listResponse = await fetch(`${ENV.IMMICH_URL}/api/tags`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
throw new Error("Failed to fetch tags from Immich instance");
|
||||
}
|
||||
|
||||
const tags = (await listResponse.json()) as ImmichTagResponse[];
|
||||
const existing = tags.find((tag) => tag.name === POWER_TOOLS_TAG_NAME);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const createResponse = await fetch(`${ENV.IMMICH_URL}/api/tags`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ name: POWER_TOOLS_TAG_NAME }),
|
||||
});
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error("Failed to create immich-power-tools tag");
|
||||
}
|
||||
|
||||
return (await createResponse.json()) as ImmichTagResponse;
|
||||
};
|
||||
|
||||
export const tagAssetWithPowerTools = async (
|
||||
tagId: string,
|
||||
assetId: string,
|
||||
headers: HeadersRecord
|
||||
) => {
|
||||
const taggingHeaders: HeadersRecord = {
|
||||
...headers,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
const tagResponse = await fetch(`${ENV.IMMICH_URL}/api/tags/assets`, {
|
||||
method: "PUT",
|
||||
headers: taggingHeaders,
|
||||
body: JSON.stringify({ tagIds: [tagId], assetIds: [assetId] }),
|
||||
});
|
||||
|
||||
if (!tagResponse.ok) {
|
||||
const errorBody = await tagResponse.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to tag uploaded asset (status ${tagResponse.status}): ${errorBody || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
const respondWithError = (res: NextApiResponse, status: number, message: string) => {
|
||||
return res.status(status).json({ error: message });
|
||||
};
|
||||
|
||||
const ALLOWED_SIZES = new Set(["thumbnail", "preview"]);
|
||||
|
||||
const validateParams = (req: NextApiRequest) => {
|
||||
const { origin, assetId, key, thumbhash, size } = req.query;
|
||||
|
||||
if (!origin || Array.isArray(origin)) {
|
||||
return { error: "Query parameter 'origin' is required" };
|
||||
}
|
||||
if (!assetId || Array.isArray(assetId)) {
|
||||
return { error: "Query parameter 'assetId' is required" };
|
||||
}
|
||||
if (!key || Array.isArray(key)) {
|
||||
return { error: "Query parameter 'key' is required" };
|
||||
}
|
||||
|
||||
let resolvedSize = "thumbnail";
|
||||
if (typeof size === "string") {
|
||||
resolvedSize = size;
|
||||
} else if (Array.isArray(size)) {
|
||||
return { error: "Query parameter 'size' must be a single value" };
|
||||
}
|
||||
|
||||
if (!ALLOWED_SIZES.has(resolvedSize)) {
|
||||
return { error: "Query parameter 'size' must be either 'thumbnail' or 'preview'" };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedOrigin = new URL(origin);
|
||||
return {
|
||||
origin: parsedOrigin.origin,
|
||||
assetId,
|
||||
key,
|
||||
size: resolvedSize,
|
||||
thumbhash: typeof thumbhash === "string" ? thumbhash : undefined,
|
||||
};
|
||||
} catch (_err) {
|
||||
return { error: "Invalid origin provided" };
|
||||
}
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") {
|
||||
res.setHeader("Allow", "GET");
|
||||
return respondWithError(res, 405, "Method Not Allowed");
|
||||
}
|
||||
|
||||
const params = validateParams(req);
|
||||
if ("error" in params) {
|
||||
return respondWithError(res, 400, params.error);
|
||||
}
|
||||
|
||||
const search = new URLSearchParams({ key: params.key, size: params.size });
|
||||
if (params.thumbhash) {
|
||||
search.set("c", params.thumbhash);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${params.origin}/api/assets/${params.assetId}/thumbnail?${search.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
accept: "image/*",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return respondWithError(res, response.status, "Failed to fetch thumbnail");
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const contentType = response.headers.get("content-type") ?? "image/jpeg";
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=60");
|
||||
return res.status(200).send(Buffer.from(arrayBuffer));
|
||||
} catch (error: any) {
|
||||
console.error("Thumbnail proxy error", error);
|
||||
return respondWithError(res, 500, error?.message ?? "Thumbnail proxy failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { ENV } from "@/config/environment";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { getUserHeaders } from "@/helpers/user.helper";
|
||||
import {
|
||||
HeadersRecord,
|
||||
ensurePowerToolsTag,
|
||||
tagAssetWithPowerTools,
|
||||
} from "./helpers";
|
||||
|
||||
interface ISharedAssetPayload {
|
||||
id: string;
|
||||
originalFileName?: string;
|
||||
type: string;
|
||||
fileCreatedAt?: string | null;
|
||||
localDateTime?: string | null;
|
||||
duration?: string | null;
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
interface IAlbumImportOptions {
|
||||
createAlbum: boolean;
|
||||
albumName?: string;
|
||||
addToAlbumId?: string;
|
||||
}
|
||||
|
||||
interface IImportAllPayload {
|
||||
origin: string;
|
||||
key: string;
|
||||
assets: ISharedAssetPayload[];
|
||||
albumOptions?: IAlbumImportOptions | null;
|
||||
}
|
||||
|
||||
interface IFailureDetail {
|
||||
assetId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface DownloadedAssetPayload {
|
||||
buffer: Buffer;
|
||||
fileName: string;
|
||||
contentType: string | null;
|
||||
}
|
||||
|
||||
const DEVICE_ID = "immich-power-tools";
|
||||
const ALLOWED_ASSET_TYPES = new Set(["IMAGE", "VIDEO"]);
|
||||
const CONCURRENT_TRANSFERS = 4;
|
||||
const makeDeviceAssetId = (assetId: string) => `shared-${assetId}`;
|
||||
|
||||
const respondWithError = (res: NextApiResponse, status: number, message: string) => {
|
||||
return res.status(status).json({ error: message });
|
||||
};
|
||||
|
||||
const parseBody = (req: NextApiRequest) => {
|
||||
try {
|
||||
return typeof req.body === "string" ? JSON.parse(req.body) : req.body;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeAlbumOptions = (value: any): IAlbumImportOptions => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return { createAlbum: false };
|
||||
}
|
||||
|
||||
const createAlbum = Boolean(value.createAlbum);
|
||||
const addToAlbumId = typeof value.addToAlbumId === "string" && value.addToAlbumId.trim().length > 0 ? value.addToAlbumId.trim() : undefined;
|
||||
|
||||
if (!createAlbum && !addToAlbumId) {
|
||||
return { createAlbum: false };
|
||||
}
|
||||
|
||||
const albumName =
|
||||
typeof value.albumName === "string" && value.albumName.trim().length > 0
|
||||
? value.albumName.trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
createAlbum,
|
||||
albumName,
|
||||
addToAlbumId,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAssetPayload = (value: any): ISharedAssetPayload | null => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (!value.id || typeof value.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (!value.type || typeof value.type !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: value.id,
|
||||
originalFileName: typeof value.originalFileName === "string" ? value.originalFileName : undefined,
|
||||
type: value.type,
|
||||
fileCreatedAt: value.fileCreatedAt ?? null,
|
||||
localDateTime: value.localDateTime ?? null,
|
||||
duration: value.duration ?? null,
|
||||
isFavorite: value.isFavorite ?? false,
|
||||
isArchived: value.isArchived ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
const validatePayload = (payload: any) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return { error: "Invalid request payload" };
|
||||
}
|
||||
|
||||
const { origin, key, assets } = payload as IImportAllPayload;
|
||||
const normalizedAlbumOptions = normalizeAlbumOptions((payload as IImportAllPayload).albumOptions);
|
||||
|
||||
if (!origin || typeof origin !== "string") {
|
||||
return { error: "Field 'origin' is required" };
|
||||
}
|
||||
if (!key || typeof key !== "string") {
|
||||
return { error: "Field 'key' is required" };
|
||||
}
|
||||
if (!Array.isArray(assets) || assets.length === 0) {
|
||||
return { error: "Field 'assets' must include at least one entry" };
|
||||
}
|
||||
|
||||
let parsedOrigin: URL;
|
||||
try {
|
||||
parsedOrigin = new URL(origin.trim());
|
||||
} catch (_err) {
|
||||
return { error: "Invalid origin provided" };
|
||||
}
|
||||
|
||||
const normalizedAssets = assets
|
||||
.map(normalizeAssetPayload)
|
||||
.filter((asset): asset is ISharedAssetPayload => !!asset);
|
||||
|
||||
if (normalizedAssets.length === 0) {
|
||||
return { error: "No valid assets provided" };
|
||||
}
|
||||
|
||||
return {
|
||||
origin: parsedOrigin.origin,
|
||||
key,
|
||||
assets: normalizedAssets,
|
||||
albumOptions: normalizedAlbumOptions,
|
||||
};
|
||||
};
|
||||
|
||||
const inferAssetTypeFromName = (fileName: string): "IMAGE" | "VIDEO" => {
|
||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||
if (!extension) {
|
||||
return "IMAGE";
|
||||
}
|
||||
switch (extension) {
|
||||
case "mp4":
|
||||
case "mov":
|
||||
case "m4v":
|
||||
case "avi":
|
||||
case "mkv":
|
||||
case "webm":
|
||||
case "3gp":
|
||||
case "3g2":
|
||||
case "mts":
|
||||
case "m2ts":
|
||||
return "VIDEO";
|
||||
default:
|
||||
return "IMAGE";
|
||||
}
|
||||
};
|
||||
|
||||
const parseFileNameFromDisposition = (disposition?: string | null) => {
|
||||
if (!disposition) {
|
||||
return null;
|
||||
}
|
||||
const match = disposition.match(/filename\*?=([^;]+)/i);
|
||||
if (!match || !match[1]) {
|
||||
return null;
|
||||
}
|
||||
const value = match[1].trim().replace(/^UTF-8''/i, "").replace(/^"|"$/g, "").replace(/^'|'$/g, "");
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const guessContentType = (fileName: string) => {
|
||||
const lower = fileName.toLowerCase();
|
||||
const extension = lower.split(".").pop();
|
||||
switch (extension) {
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "jfif":
|
||||
return "image/jpeg";
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "heic":
|
||||
return "image/heic";
|
||||
case "heif":
|
||||
return "image/heif";
|
||||
case "webp":
|
||||
return "image/webp";
|
||||
case "gif":
|
||||
return "image/gif";
|
||||
case "tif":
|
||||
case "tiff":
|
||||
return "image/tiff";
|
||||
case "bmp":
|
||||
return "image/bmp";
|
||||
case "mp4":
|
||||
case "m4v":
|
||||
return "video/mp4";
|
||||
case "mov":
|
||||
return "video/quicktime";
|
||||
case "avi":
|
||||
return "video/x-msvideo";
|
||||
case "mkv":
|
||||
return "video/x-matroska";
|
||||
case "webm":
|
||||
return "video/webm";
|
||||
case "3gp":
|
||||
case "3g2":
|
||||
return "video/3gpp";
|
||||
case "mts":
|
||||
case "m2ts":
|
||||
return "video/MP2T";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
};
|
||||
|
||||
const createImmichAlbum = async (albumName: string, headers: HeadersRecord) => {
|
||||
const response = await fetch(`${ENV.IMMICH_URL}/api/albums`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ albumName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to create album "${albumName}" (status ${response.status}): ${body || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
|
||||
const album = await response.json().catch(() => null);
|
||||
const albumId = album?.id;
|
||||
if (!albumId || typeof albumId !== "string") {
|
||||
throw new Error("Immich album creation response did not include an id");
|
||||
}
|
||||
return albumId;
|
||||
};
|
||||
|
||||
const addAssetToAlbum = async (albumId: string, assetId: string, headers: HeadersRecord) => {
|
||||
const response = await fetch(`${ENV.IMMICH_URL}/api/albums/${albumId}/assets`, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify({ ids: [assetId] }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to attach asset ${assetId} to album ${albumId} (status ${response.status}): ${
|
||||
body || "Unknown error"
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSharedAsset = async (
|
||||
asset: ISharedAssetPayload,
|
||||
origin: string,
|
||||
key: string
|
||||
): Promise<DownloadedAssetPayload> => {
|
||||
const url = `${origin}/api/assets/${asset.id}/original?key=${key}`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${asset.originalFileName ?? asset.id} (status ${response.status})`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const disposition = response.headers.get("content-disposition");
|
||||
const inferredName = parseFileNameFromDisposition(disposition);
|
||||
const fallbackExtension = asset.type === "VIDEO" ? "mp4" : "jpg";
|
||||
const fileName = inferredName ?? asset.originalFileName ?? `${asset.id}.${fallbackExtension}`;
|
||||
|
||||
return {
|
||||
buffer,
|
||||
fileName,
|
||||
contentType: response.headers.get("content-type"),
|
||||
};
|
||||
};
|
||||
|
||||
const uploadAssetBuffer = async (
|
||||
asset: ISharedAssetPayload,
|
||||
payload: DownloadedAssetPayload,
|
||||
uploadHeaders: HeadersRecord,
|
||||
jsonHeaders: HeadersRecord,
|
||||
tagId: string
|
||||
) => {
|
||||
const fileType = asset.type ?? inferAssetTypeFromName(payload.fileName);
|
||||
const resolvedFileName = payload.fileName || asset.originalFileName || `${asset.id}.bin`;
|
||||
const contentType = payload.contentType ?? guessContentType(resolvedFileName);
|
||||
const blob = new Blob([payload.buffer], { type: contentType });
|
||||
const createdAt = asset.fileCreatedAt ?? asset.localDateTime ?? new Date().toISOString();
|
||||
const modifiedAt = asset.localDateTime ?? asset.fileCreatedAt ?? createdAt;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set("deviceAssetId", makeDeviceAssetId(asset.id));
|
||||
formData.set("deviceId", DEVICE_ID);
|
||||
formData.set("fileCreatedAt", createdAt);
|
||||
formData.set("fileModifiedAt", modifiedAt);
|
||||
formData.set("fileType", fileType);
|
||||
formData.set("duration", asset.duration ?? "0:00:00.000000");
|
||||
formData.set("assetData", blob, resolvedFileName);
|
||||
|
||||
if (asset.isArchived) {
|
||||
formData.set("isArchived", String(asset.isArchived));
|
||||
}
|
||||
if (asset.isFavorite) {
|
||||
formData.set("isFavorite", String(asset.isFavorite));
|
||||
}
|
||||
|
||||
const uploadResponse = await fetch(`${ENV.IMMICH_URL}/api/assets`, {
|
||||
method: "POST",
|
||||
headers: uploadHeaders,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const body = await uploadResponse.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Upload failed for ${asset.originalFileName ?? asset.id} (status ${uploadResponse.status}): ${
|
||||
body || "Unknown error"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const uploaded = await uploadResponse.json();
|
||||
const uploadedId = uploaded?.id;
|
||||
if (!uploadedId) {
|
||||
throw new Error("Immich upload response did not include an asset id");
|
||||
}
|
||||
|
||||
await tagAssetWithPowerTools(tagId, uploadedId, jsonHeaders);
|
||||
return uploadedId;
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return respondWithError(res, 405, "Method Not Allowed");
|
||||
}
|
||||
|
||||
if (!ENV.IMMICH_URL) {
|
||||
return respondWithError(res, 500, "IMMICH_URL must be configured");
|
||||
}
|
||||
|
||||
const currentUser = await getCurrentUser(req);
|
||||
if (!currentUser) {
|
||||
return respondWithError(res, 401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = parseBody(req);
|
||||
const validation = validatePayload(payload);
|
||||
if ("error" in validation) {
|
||||
return respondWithError(res, 400, validation.error ?? "Validation failed");
|
||||
}
|
||||
|
||||
const { origin, key, assets, albumOptions } = validation;
|
||||
const uploadableAssets = assets.filter((asset) => ALLOWED_ASSET_TYPES.has(asset.type));
|
||||
const normalizedAlbumOptions = albumOptions ?? { createAlbum: false };
|
||||
if (uploadableAssets.length === 0) {
|
||||
return respondWithError(res, 400, "No image or video assets available to import");
|
||||
}
|
||||
|
||||
const jsonHeaders = getUserHeaders(currentUser) as HeadersRecord;
|
||||
const jsonHeadersWithContentType: HeadersRecord = {
|
||||
...jsonHeaders,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const deviceAssetIds: string[] = [];
|
||||
const deviceAssetIdLookup = new Map<string, string>();
|
||||
for (const asset of uploadableAssets) {
|
||||
const deviceAssetId = makeDeviceAssetId(asset.id);
|
||||
deviceAssetIds.push(deviceAssetId);
|
||||
deviceAssetIdLookup.set(deviceAssetId.toLowerCase(), asset.id);
|
||||
}
|
||||
|
||||
const existingAssetIds = new Set<string>();
|
||||
if (deviceAssetIds.length > 0) {
|
||||
try {
|
||||
const checkResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/exist`, {
|
||||
method: "POST",
|
||||
headers: jsonHeadersWithContentType,
|
||||
body: JSON.stringify({
|
||||
deviceAssetIds,
|
||||
deviceId: DEVICE_ID,
|
||||
}),
|
||||
});
|
||||
|
||||
if (checkResponse.ok) {
|
||||
const existPayload = await checkResponse.json().catch(() => ({}));
|
||||
const existingIds: string[] = existPayload?.existingIds ?? [];
|
||||
for (const deviceAssetId of existingIds) {
|
||||
if (typeof deviceAssetId !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalizedId = deviceAssetId.toLowerCase();
|
||||
const matchedAssetId = deviceAssetIdLookup.get(normalizedId);
|
||||
if (matchedAssetId) {
|
||||
existingAssetIds.add(matchedAssetId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`Failed to check existing assets before import (status ${checkResponse.status})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Unable to check existing assets before import", error);
|
||||
}
|
||||
}
|
||||
|
||||
const assetsToImport = uploadableAssets.filter((asset) => !existingAssetIds.has(asset.id));
|
||||
const skippedAssetIds = uploadableAssets
|
||||
.filter((asset) => existingAssetIds.has(asset.id))
|
||||
.map((asset) => asset.id);
|
||||
|
||||
if (assetsToImport.length === 0) {
|
||||
return res.status(200).json({
|
||||
uploadedIds: [],
|
||||
processedAssetIds: [],
|
||||
uploadedCount: 0,
|
||||
failed: [],
|
||||
requestedCount: 0,
|
||||
skippedAssetIds,
|
||||
});
|
||||
}
|
||||
|
||||
if (skippedAssetIds.length) {
|
||||
console.log(`Skipping ${skippedAssetIds.length} assets already stored in Immich`);
|
||||
}
|
||||
|
||||
let targetAlbumId: string | null = null;
|
||||
if (normalizedAlbumOptions.createAlbum) {
|
||||
const desiredAlbumName =
|
||||
normalizedAlbumOptions.albumName?.trim() || `Shared import ${new Date().toISOString()}`;
|
||||
try {
|
||||
targetAlbumId = await createImmichAlbum(desiredAlbumName, jsonHeadersWithContentType);
|
||||
console.log(`Created Immich album ${targetAlbumId} for shared import (${desiredAlbumName})`);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to create Immich album for import", error);
|
||||
return respondWithError(res, 500, error?.message ?? "Unable to create Immich album");
|
||||
}
|
||||
} else if (normalizedAlbumOptions.addToAlbumId) {
|
||||
targetAlbumId = normalizedAlbumOptions.addToAlbumId;
|
||||
}
|
||||
|
||||
const assetIds = assetsToImport.map((asset) => asset.id);
|
||||
console.log(
|
||||
`Starting per-asset import for ${assetIds.length} assets (concurrency ${Math.min(
|
||||
CONCURRENT_TRANSFERS,
|
||||
assetsToImport.length
|
||||
)})`
|
||||
);
|
||||
|
||||
const { ["Content-Type"]: _omitContentType, ...uploadHeaders } = jsonHeaders;
|
||||
const tag = await ensurePowerToolsTag(jsonHeaders);
|
||||
|
||||
const uploadedImmichIds: string[] = [];
|
||||
const processedAssetIds: string[] = [];
|
||||
const failed: IFailureDetail[] = [];
|
||||
|
||||
let currentIndex = 0;
|
||||
const concurrency = Math.min(CONCURRENT_TRANSFERS, assetsToImport.length);
|
||||
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const index = currentIndex++;
|
||||
if (index >= assetsToImport.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const asset = assetsToImport[index];
|
||||
try {
|
||||
const downloaded = await downloadSharedAsset(asset, origin, key);
|
||||
const uploadedId = await uploadAssetBuffer(asset, downloaded, uploadHeaders, jsonHeaders, tag.id);
|
||||
if (targetAlbumId) {
|
||||
await addAssetToAlbum(targetAlbumId, uploadedId, jsonHeadersWithContentType);
|
||||
}
|
||||
uploadedImmichIds.push(uploadedId);
|
||||
processedAssetIds.push(asset.id);
|
||||
} catch (error: any) {
|
||||
failed.push({ assetId: asset.id, reason: error?.message ?? "Upload failed" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
|
||||
return res.status(200).json({
|
||||
uploadedIds: uploadedImmichIds,
|
||||
processedAssetIds,
|
||||
uploadedCount: uploadedImmichIds.length,
|
||||
failed,
|
||||
requestedCount: assetsToImport.length,
|
||||
skippedAssetIds,
|
||||
albumId: targetAlbumId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { ENV } from "@/config/environment";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { getUserHeaders } from "@/helpers/user.helper";
|
||||
import {
|
||||
HeadersRecord,
|
||||
ensurePowerToolsTag,
|
||||
tagAssetWithPowerTools,
|
||||
} from "./helpers";
|
||||
|
||||
const respondWithError = (res: NextApiResponse, status: number, message: string) => {
|
||||
return res.status(status).json({ error: message });
|
||||
};
|
||||
|
||||
const parseBody = (req: NextApiRequest) => {
|
||||
try {
|
||||
return typeof req.body === "string" ? JSON.parse(req.body) : req.body;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const validatePayload = (payload: any) => {
|
||||
const { assetId, origin, key } = payload || {};
|
||||
if (!assetId || typeof assetId !== "string") {
|
||||
return { error: "Field 'assetId' is required" };
|
||||
}
|
||||
if (!origin || typeof origin !== "string") {
|
||||
return { error: "Field 'origin' is required" };
|
||||
}
|
||||
if (!key || typeof key !== "string") {
|
||||
return { error: "Field 'key' is required" };
|
||||
}
|
||||
|
||||
let parsedOrigin: URL | null = null;
|
||||
try {
|
||||
parsedOrigin = new URL(origin);
|
||||
} catch (_err) {
|
||||
return { error: "Invalid origin provided" };
|
||||
}
|
||||
|
||||
return {
|
||||
assetId,
|
||||
key,
|
||||
origin: parsedOrigin.origin,
|
||||
};
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return respondWithError(res, 405, "Method Not Allowed");
|
||||
}
|
||||
|
||||
if (!ENV.IMMICH_URL) {
|
||||
return respondWithError(res, 500, "IMMICH_URL must be configured");
|
||||
}
|
||||
|
||||
const currentUser = await getCurrentUser(req);
|
||||
if (!currentUser) {
|
||||
return respondWithError(res, 401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = parseBody(req);
|
||||
if (!payload) {
|
||||
return respondWithError(res, 400, "Invalid request payload");
|
||||
}
|
||||
|
||||
const jsonHeaders = getUserHeaders(currentUser) as HeadersRecord;
|
||||
|
||||
const validation = validatePayload(payload);
|
||||
if ("error" in validation) {
|
||||
return respondWithError(res, 400, validation.error);
|
||||
}
|
||||
|
||||
const params = validation;
|
||||
|
||||
try {
|
||||
const assetResponse = await fetch(
|
||||
`${params.origin}/api/assets/${params.assetId}?key=${params.key}`,
|
||||
{
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!assetResponse.ok) {
|
||||
return respondWithError(res, assetResponse.status, "Failed to fetch remote asset metadata");
|
||||
}
|
||||
|
||||
const asset = await assetResponse.json();
|
||||
|
||||
if (!asset || asset.type !== "IMAGE") {
|
||||
return respondWithError(res, 400, "Only photo assets can be uploaded from this view");
|
||||
}
|
||||
|
||||
const originalResponse = await fetch(
|
||||
`${params.origin}/api/assets/${params.assetId}/original?key=${params.key}`,
|
||||
{
|
||||
headers: {
|
||||
accept: "application/octet-stream",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!originalResponse.ok) {
|
||||
return respondWithError(res, originalResponse.status, "Failed to download original asset data");
|
||||
}
|
||||
|
||||
const contentType = originalResponse.headers.get("content-type") ?? "application/octet-stream";
|
||||
const originalBuffer = await originalResponse.arrayBuffer();
|
||||
const assetBlob = new Blob([originalBuffer], { type: contentType });
|
||||
const fileName = asset.originalFileName ?? `${params.assetId}.bin`;
|
||||
|
||||
const createdAt = asset.fileCreatedAt ?? asset.localDateTime ?? new Date().toISOString();
|
||||
const modifiedAt = asset.localDateTime ?? asset.fileCreatedAt ?? createdAt;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set("deviceAssetId", `shared-${params.assetId}`);
|
||||
formData.set("deviceId", "immich-power-tools");
|
||||
formData.set("fileCreatedAt", createdAt);
|
||||
formData.set("fileModifiedAt", modifiedAt);
|
||||
formData.set("fileType", asset.type ?? "IMAGE");
|
||||
formData.set("duration", asset.duration ?? "0:00:00.000000");
|
||||
formData.set("assetData", assetBlob, fileName);
|
||||
|
||||
if (asset.isArchived) {
|
||||
formData.set("isArchived", asset.isArchived.toString());
|
||||
}
|
||||
if (asset.isFavorite) {
|
||||
formData.set("isFavorite", asset.isFavorite.toString());
|
||||
}
|
||||
|
||||
const { ["Content-Type"]: _omitContentType, ...uploadHeaders } = jsonHeaders;
|
||||
|
||||
const uploadResponse = await fetch(`${ENV.IMMICH_URL}/api/assets`, {
|
||||
method: "POST",
|
||||
headers: uploadHeaders,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const errorBody = await uploadResponse.text();
|
||||
console.error("Immich upload error", uploadResponse.status, errorBody);
|
||||
return respondWithError(res, uploadResponse.status, "Failed to upload asset to Immich instance");
|
||||
}
|
||||
|
||||
const uploaded = await uploadResponse.json();
|
||||
const uploadedAssetId = uploaded?.id;
|
||||
|
||||
if (!uploadedAssetId) {
|
||||
throw new Error("Immich upload response did not include an asset id");
|
||||
}
|
||||
|
||||
const tagHeaders: HeadersRecord = {
|
||||
...jsonHeaders,
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
const tag = await ensurePowerToolsTag(tagHeaders);
|
||||
await tagAssetWithPowerTools(tag.id, uploadedAssetId, tagHeaders);
|
||||
|
||||
return res.status(200).json({
|
||||
status: "uploaded",
|
||||
immichId: uploadedAssetId,
|
||||
deviceAssetId: `shared-${params.assetId}`,
|
||||
tagId: tag.id,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Upload shared asset error", error);
|
||||
return respondWithError(res, 500, error?.message ?? "Unexpected error while uploading asset");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Readable } from "stream";
|
||||
|
||||
const respondWithError = (res: NextApiResponse, status: number, message: string) => {
|
||||
return res.status(status).json({ error: message });
|
||||
};
|
||||
|
||||
const validateParams = (req: NextApiRequest) => {
|
||||
const { origin, assetId, key, thumbhash } = req.query;
|
||||
|
||||
if (!origin || Array.isArray(origin)) {
|
||||
return { error: "Query parameter 'origin' is required" };
|
||||
}
|
||||
if (!assetId || Array.isArray(assetId)) {
|
||||
return { error: "Query parameter 'assetId' is required" };
|
||||
}
|
||||
if (!key || Array.isArray(key)) {
|
||||
return { error: "Query parameter 'key' is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedOrigin = new URL(origin);
|
||||
return {
|
||||
origin: parsedOrigin.origin,
|
||||
assetId,
|
||||
key,
|
||||
thumbhash: typeof thumbhash === "string" ? thumbhash : undefined,
|
||||
};
|
||||
} catch (_err) {
|
||||
return { error: "Invalid origin provided" };
|
||||
}
|
||||
};
|
||||
|
||||
const passthroughHeaders = [
|
||||
"content-type",
|
||||
"content-length",
|
||||
"content-range",
|
||||
"accept-ranges",
|
||||
"cache-control",
|
||||
];
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") {
|
||||
res.setHeader("Allow", "GET");
|
||||
return respondWithError(res, 405, "Method Not Allowed");
|
||||
}
|
||||
|
||||
const params = validateParams(req);
|
||||
if ("error" in params) {
|
||||
return respondWithError(res, 400, params.error);
|
||||
}
|
||||
|
||||
const search = new URLSearchParams({ key: params.key });
|
||||
if (params.thumbhash) {
|
||||
search.set("c", params.thumbhash);
|
||||
}
|
||||
|
||||
const targetUrl = `${params.origin}/api/assets/${params.assetId}/video/playback?${search.toString()}`;
|
||||
|
||||
try {
|
||||
const upstream = await fetch(targetUrl, {
|
||||
headers: {
|
||||
accept: "video/*",
|
||||
...(req.headers.range ? { range: req.headers.range } : {}),
|
||||
referer: req.headers.referer?.toString() ?? params.origin,
|
||||
"sec-fetch-dest": "video",
|
||||
},
|
||||
});
|
||||
|
||||
if (!upstream.ok && upstream.status !== 206) {
|
||||
return respondWithError(res, upstream.status, "Failed to fetch video playback");
|
||||
}
|
||||
|
||||
passthroughHeaders.forEach((header) => {
|
||||
const value = upstream.headers.get(header);
|
||||
if (value) {
|
||||
res.setHeader(header, value);
|
||||
}
|
||||
});
|
||||
|
||||
res.status(upstream.status);
|
||||
|
||||
const body = upstream.body;
|
||||
if (body) {
|
||||
const nodeStream = Readable.fromWeb(body as any);
|
||||
nodeStream.pipe(res);
|
||||
} else {
|
||||
const buffer = Buffer.from(await upstream.arrayBuffer());
|
||||
res.send(buffer);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Video playback proxy error", error);
|
||||
return respondWithError(res, 500, error?.message ?? "Video playback proxy failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
import React, { FormEvent, useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import PageLayout from "@/components/layouts/PageLayout";
|
||||
import Header from "@/components/shared/Header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { listAlbums } from "@/handlers/api/album.handler";
|
||||
import { IAlbum } from "@/types/album";
|
||||
|
||||
interface IAlbumContributorCount {
|
||||
userId: string;
|
||||
assetCount: number;
|
||||
}
|
||||
|
||||
interface IImportSharedAsset {
|
||||
id: string;
|
||||
originalFileName: string;
|
||||
type: string;
|
||||
fileCreatedAt?: string | null;
|
||||
localDateTime?: string | null;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
thumbhash?: string | null;
|
||||
fileSizeInByte?: number | null;
|
||||
duration?: string | null;
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
interface IImportSharedAlbum {
|
||||
albumName: string;
|
||||
assetCount: number;
|
||||
owner?: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
} | null;
|
||||
description?: string | null;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
shared?: boolean;
|
||||
hasSharedLink?: boolean;
|
||||
lastModifiedAssetTimestamp?: string | null;
|
||||
order?: string | null;
|
||||
contributorCounts?: IAlbumContributorCount[];
|
||||
assets: IImportSharedAsset[];
|
||||
}
|
||||
|
||||
interface IImportSharedResponse {
|
||||
link: string;
|
||||
origin: string;
|
||||
key: string;
|
||||
sharedLink: {
|
||||
id: string;
|
||||
type: string;
|
||||
createdAt: string;
|
||||
expiresAt?: string | null;
|
||||
allowUpload?: boolean;
|
||||
allowDownload?: boolean;
|
||||
showMetadata?: boolean;
|
||||
};
|
||||
album: IImportSharedAlbum | null;
|
||||
}
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return "—";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const formatDateOnly = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return "—";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => (
|
||||
<div className="flex flex-col gap-1 rounded-md border border-border bg-muted/30 p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-medium text-foreground break-words">{value ?? "—"}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const formatContributors = (counts?: IAlbumContributorCount[]) => {
|
||||
if (!counts || counts.length === 0) {
|
||||
return "—";
|
||||
}
|
||||
return counts.map((entry) => `${entry.assetCount} assets`).join(", ");
|
||||
};
|
||||
|
||||
const formatPermissions = (link?: IImportSharedResponse["sharedLink"]) => {
|
||||
if (!link) {
|
||||
return "View only";
|
||||
}
|
||||
const labels: string[] = [];
|
||||
if (link.allowDownload) {
|
||||
labels.push("Download");
|
||||
}
|
||||
if (link.allowUpload) {
|
||||
labels.push("Upload");
|
||||
}
|
||||
return labels.length ? labels.join(" / ") : "View only";
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number | null) => {
|
||||
if (!bytes || bytes <= 0) {
|
||||
return null;
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size % 1 === 0 ? size : size.toFixed(1)} ${units[unit]}`;
|
||||
};
|
||||
|
||||
export default function ImportSharedPage() {
|
||||
const [shareLink, setShareLink] = useState("");
|
||||
const [submittedLink, setSubmittedLink] = useState<string | null>(null);
|
||||
const [sharedData, setSharedData] = useState<IImportSharedResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeVideoAsset, setActiveVideoAsset] = useState<IImportSharedAsset | null>(null);
|
||||
const [activeImageAsset, setActiveImageAsset] = useState<IImportSharedAsset | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [uploadBanner, setUploadBanner] = useState<{ type: "success" | "error"; message: string } | null>(null);
|
||||
const [importAllLoading, setImportAllLoading] = useState(false);
|
||||
const [importAllDialogOpen, setImportAllDialogOpen] = useState(false);
|
||||
const [albumImportMode, setAlbumImportMode] = useState<"album" | "no-album" | "existing-album">("album");
|
||||
const [albumNameInput, setAlbumNameInput] = useState("");
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(new Set());
|
||||
const [existingAlbums, setExistingAlbums] = useState<IAlbum[]>([]);
|
||||
const [selectedAlbumId, setSelectedAlbumId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (importAllDialogOpen) {
|
||||
listAlbums().then(setExistingAlbums).catch(console.error);
|
||||
}
|
||||
}, [importAllDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sharedData?.album?.albumName) {
|
||||
setAlbumNameInput(sharedData.album.albumName);
|
||||
} else if (sharedData) {
|
||||
setAlbumNameInput("Imported shared album");
|
||||
} else {
|
||||
setAlbumNameInput("");
|
||||
}
|
||||
}, [sharedData]);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSharedData(null);
|
||||
setSubmittedLink(null);
|
||||
setUploadBanner(null);
|
||||
setImportAllLoading(false);
|
||||
setImportAllDialogOpen(false);
|
||||
setAlbumImportMode("album");
|
||||
setAlbumNameInput("");
|
||||
setSelectedAssetIds(new Set());
|
||||
setSelectedAlbumId(null);
|
||||
|
||||
if (!shareLink.trim()) {
|
||||
setError("Please paste a shared album link.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/import-shared", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ link: shareLink }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({}));
|
||||
throw new Error(errorPayload.error || "Failed to import shared album");
|
||||
}
|
||||
|
||||
const payload: IImportSharedResponse = await response.json();
|
||||
setSharedData(payload);
|
||||
setSubmittedLink(payload.link);
|
||||
setUploadBanner(null);
|
||||
setImportAllLoading(false);
|
||||
setAlbumImportMode("album");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unexpected error";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sharedLinkDetails = sharedData?.sharedLink;
|
||||
const albumDetails = sharedData?.album;
|
||||
const importableAssetCount =
|
||||
albumDetails?.assets.filter((asset) => asset.type === "IMAGE" || asset.type === "VIDEO").length ?? 0;
|
||||
|
||||
const buildThumbnailUrl = (asset: IImportSharedAsset) => {
|
||||
if (!sharedData) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
assetId: asset.id,
|
||||
origin: sharedData.origin,
|
||||
key: sharedData.key,
|
||||
size: "thumbnail",
|
||||
});
|
||||
if (asset.thumbhash) {
|
||||
params.set("thumbhash", asset.thumbhash);
|
||||
}
|
||||
return `/api/import-shared/thumbnail?${params.toString()}`;
|
||||
};
|
||||
|
||||
const buildVideoPlaybackUrl = (asset: IImportSharedAsset) => {
|
||||
if (!sharedData) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
assetId: asset.id,
|
||||
origin: sharedData.origin,
|
||||
key: sharedData.key,
|
||||
});
|
||||
if (asset.thumbhash) {
|
||||
params.set("thumbhash", asset.thumbhash);
|
||||
}
|
||||
return `/api/import-shared/video?${params.toString()}`;
|
||||
};
|
||||
|
||||
const buildPreviewUrl = (asset: IImportSharedAsset) => {
|
||||
if (!sharedData) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
assetId: asset.id,
|
||||
origin: sharedData.origin,
|
||||
key: sharedData.key,
|
||||
size: "preview",
|
||||
});
|
||||
if (asset.thumbhash) {
|
||||
params.set("thumbhash", asset.thumbhash);
|
||||
}
|
||||
return `/api/import-shared/thumbnail?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleImportAll = async (options: { createAlbum: boolean; albumName?: string; addToAlbumId?: string }) => {
|
||||
if (!sharedData || !albumDetails) {
|
||||
return;
|
||||
}
|
||||
let uploadableAssets = albumDetails.assets.filter(
|
||||
(asset) => asset.type === "IMAGE" || asset.type === "VIDEO"
|
||||
);
|
||||
|
||||
if (selectedAssetIds.size > 0) {
|
||||
uploadableAssets = uploadableAssets.filter((asset) => selectedAssetIds.has(asset.id));
|
||||
}
|
||||
|
||||
if (uploadableAssets.length === 0) {
|
||||
setUploadBanner({ type: "error", message: "No assets selected to import." });
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedAlbumName = options.createAlbum
|
||||
? options.albumName?.trim() || albumDetails.albumName || "Imported shared album"
|
||||
: undefined;
|
||||
|
||||
const albumOptions = options.createAlbum
|
||||
? { createAlbum: true, albumName: normalizedAlbumName }
|
||||
: options.addToAlbumId
|
||||
? { createAlbum: false, addToAlbumId: options.addToAlbumId }
|
||||
: { createAlbum: false };
|
||||
|
||||
setUploadBanner(null);
|
||||
setImportAllLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/import-shared/upload-all", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
origin: sharedData.origin,
|
||||
key: sharedData.key,
|
||||
assets: uploadableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
originalFileName: asset.originalFileName,
|
||||
type: asset.type,
|
||||
fileCreatedAt: asset.fileCreatedAt ?? null,
|
||||
localDateTime: asset.localDateTime ?? null,
|
||||
duration: asset.duration ?? null,
|
||||
isFavorite: asset.isFavorite ?? false,
|
||||
isArchived: asset.isArchived ?? false,
|
||||
})),
|
||||
albumOptions,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || "Failed to import shared archive");
|
||||
}
|
||||
|
||||
const processedAssetIds: string[] = result.processedAssetIds ?? [];
|
||||
const skippedAssetIds: string[] = result.skippedAssetIds ?? [];
|
||||
const failed: { assetId: string; reason?: string }[] = result.failed ?? [];
|
||||
|
||||
const failedCount = failed.length;
|
||||
const skippedCount = skippedAssetIds.length;
|
||||
const uploadedCount = result.uploadedCount ?? processedAssetIds.length;
|
||||
let bannerType: "success" | "error" = failedCount ? "error" : "success";
|
||||
let message: string;
|
||||
if (!uploadedCount && skippedCount && !failedCount) {
|
||||
message = `All ${skippedCount} assets already exist on Immich.`;
|
||||
} else if (failedCount) {
|
||||
const skippedSuffix = skippedCount ? `, ${skippedCount} skipped` : "";
|
||||
message = `Imported ${uploadedCount} assets, ${failedCount} failed${skippedSuffix}.`;
|
||||
} else if (skippedCount) {
|
||||
message = `Imported ${uploadedCount} assets, skipped ${skippedCount} already on Immich.`;
|
||||
} else {
|
||||
message = `Imported ${uploadedCount} assets successfully.`;
|
||||
}
|
||||
if (options.createAlbum && uploadedCount > 0 && normalizedAlbumName) {
|
||||
message = `${message} Added to album "${normalizedAlbumName}".`;
|
||||
} else if (options.addToAlbumId && uploadedCount > 0) {
|
||||
const album = existingAlbums.find((a) => a.id === options.addToAlbumId);
|
||||
message = `${message} Added to album "${album?.albumName || "existing album"}".`;
|
||||
}
|
||||
setUploadBanner({ type: bannerType, message });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to import shared archive";
|
||||
setUploadBanner({ type: "error", message });
|
||||
} finally {
|
||||
setImportAllLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout className="!p-0 !mb-0 relative pb-20">
|
||||
<Header
|
||||
leftComponent={
|
||||
sharedData ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => {
|
||||
setSharedData(null);
|
||||
setShareLink("");
|
||||
setSubmittedLink(null);
|
||||
setError(null);
|
||||
setActiveVideoAsset(null);
|
||||
setActiveImageAsset(null);
|
||||
setPreviewLoading(false);
|
||||
setUploadBanner(null);
|
||||
setImportAllLoading(false);
|
||||
setImportAllDialogOpen(false);
|
||||
setAlbumImportMode("album");
|
||||
setAlbumNameInput("");
|
||||
setSelectedAssetIds(new Set());
|
||||
setSelectedAlbumId(null);
|
||||
}}>
|
||||
← Back
|
||||
</Button>
|
||||
) : (
|
||||
"Import shared"
|
||||
)
|
||||
}
|
||||
rightComponent={
|
||||
sharedData && albumDetails ? (
|
||||
<div className="flex gap-2">
|
||||
{selectedAssetIds.size > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedAssetIds(new Set())}
|
||||
disabled={importAllLoading}
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={importAllLoading || importableAssetCount === 0}
|
||||
onClick={() => {
|
||||
if (importAllLoading) {
|
||||
return;
|
||||
}
|
||||
if (!albumNameInput.trim()) {
|
||||
setAlbumNameInput(albumDetails?.albumName || "Imported shared album");
|
||||
}
|
||||
setAlbumImportMode("album");
|
||||
setImportAllDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{importAllLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{selectedAssetIds.size > 0 ? `Import selected (${selectedAssetIds.size})` : "Import all"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className={!sharedData ? "flex flex-1 justify-center px-4 py-6" : "flex flex-col gap-6 p-4"}>
|
||||
<section className={!sharedData ? "w-full max-w-6xl flex flex-col gap-6" : "w-full flex flex-col gap-6"}>
|
||||
{!sharedData && (
|
||||
<Card>
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="text-2xl">Import a shared album</CardTitle>
|
||||
<CardDescription>
|
||||
Paste the shared public link for the album you want to import.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="share-link">Shared album link</Label>
|
||||
<Input
|
||||
id="share-link"
|
||||
value={shareLink}
|
||||
onChange={(event) => setShareLink(event.target.value)}
|
||||
placeholder="https://demo.immich.app/share/<key>"
|
||||
type="url"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="submit" disabled={!shareLink.trim() || loading}>
|
||||
{loading ? "Processing..." : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{sharedLinkDetails && sharedData && albumDetails && (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-xl">{albumDetails.albumName}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-3">
|
||||
<InfoRow label="Expires" value={sharedLinkDetails.expiresAt ? formatDate(sharedLinkDetails.expiresAt) : "No expiry"} />
|
||||
{albumDetails && (
|
||||
<>
|
||||
<InfoRow label="Owner" value={albumDetails.owner?.name ?? "Unknown"} />
|
||||
<InfoRow label="Assets" value={albumDetails.assetCount} />
|
||||
<InfoRow label="Date range" value={`${formatDateOnly(albumDetails.startDate)} → ${formatDateOnly(albumDetails.endDate)}`} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{albumDetails && sharedData && (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-xl">Gallery</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{uploadBanner && (
|
||||
<div
|
||||
className={`rounded-md border px-4 py-2 text-sm ${
|
||||
uploadBanner.type === "success"
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600"
|
||||
: "border-destructive/40 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{uploadBanner.message}
|
||||
</div>
|
||||
)}
|
||||
{albumDetails.description && (
|
||||
<p className="text-sm text-muted-foreground">{albumDetails.description}</p>
|
||||
)}
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8">
|
||||
{albumDetails.assets.length === 0 && (
|
||||
<div className="col-span-full rounded-lg border border-border p-6 text-center text-sm text-muted-foreground">
|
||||
This album has no assets yet.
|
||||
</div>
|
||||
)}
|
||||
{albumDetails.assets.map((asset) => {
|
||||
const formattedSize = formatFileSize(asset.fileSizeInByte);
|
||||
const thumbnailUrl = buildThumbnailUrl(asset);
|
||||
const isVideoAsset = asset.type === "VIDEO";
|
||||
const isInteractive = true;
|
||||
const ariaLabel = isVideoAsset
|
||||
? `Play video ${asset.originalFileName}`
|
||||
: `View photo ${asset.originalFileName}`;
|
||||
const isSelected = selectedAssetIds.has(asset.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm ${
|
||||
isInteractive ?
|
||||
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" :
|
||||
""
|
||||
} ${isSelected ? "ring-2 ring-primary" : ""}`}
|
||||
onClick={() => {
|
||||
if (isVideoAsset) {
|
||||
setActiveImageAsset(null);
|
||||
setActiveVideoAsset(asset);
|
||||
setPreviewLoading(false);
|
||||
} else {
|
||||
setActiveVideoAsset(null);
|
||||
setPreviewLoading(true);
|
||||
setActiveImageAsset(asset);
|
||||
}
|
||||
}}
|
||||
role={isInteractive ? "button" : undefined}
|
||||
tabIndex={isInteractive ? 0 : undefined}
|
||||
aria-label={ariaLabel}
|
||||
onKeyDown={(event) => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (isVideoAsset) {
|
||||
setActiveImageAsset(null);
|
||||
setActiveVideoAsset(asset);
|
||||
setPreviewLoading(false);
|
||||
} else {
|
||||
setActiveVideoAsset(null);
|
||||
setPreviewLoading(true);
|
||||
setActiveImageAsset(asset);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative aspect-square w-full bg-muted">
|
||||
<div className="absolute top-2 left-2 z-10" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => {
|
||||
const newSelection = new Set(selectedAssetIds);
|
||||
if (newSelection.has(asset.id)) {
|
||||
newSelection.delete(asset.id);
|
||||
} else {
|
||||
newSelection.add(asset.id);
|
||||
}
|
||||
setSelectedAssetIds(newSelection);
|
||||
}}
|
||||
className="bg-white/80 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
{thumbnailUrl ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={`Thumbnail for ${asset.originalFileName}`}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||
No preview
|
||||
</div>
|
||||
)}
|
||||
{isVideoAsset && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="rounded-full bg-black/60 px-4 py-2 text-sm font-semibold uppercase tracking-wide text-white">
|
||||
▶ Play
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 right-2 flex gap-2">
|
||||
<span className="rounded-full bg-black/60 px-2 py-0.5 text-xs text-white">
|
||||
{asset.type}
|
||||
</span>
|
||||
{formattedSize && (
|
||||
<span className="rounded-full bg-black/60 px-2 py-0.5 text-xs text-white">
|
||||
{formattedSize}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3">
|
||||
<p className="text-sm font-semibold text-foreground" title={asset.originalFileName}>
|
||||
{asset.originalFileName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Captured {formatDate(asset.fileCreatedAt || asset.localDateTime)}</p>
|
||||
{asset.location && (
|
||||
<p className="text-xs text-muted-foreground">{asset.location}</p>
|
||||
)}
|
||||
{asset.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{asset.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<Dialog
|
||||
open={importAllDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (importAllLoading) {
|
||||
return;
|
||||
}
|
||||
setImportAllDialogOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedAssetIds.size > 0 ? "Import selected assets" : "Import all assets"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Decide whether to create a new Immich album for these imported assets.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-sm font-medium text-foreground">Destination</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant={albumImportMode === "album" ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => setAlbumImportMode("album")}
|
||||
disabled={importAllLoading}
|
||||
>
|
||||
Create a new album
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={albumImportMode === "existing-album" ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => setAlbumImportMode("existing-album")}
|
||||
disabled={importAllLoading}
|
||||
>
|
||||
Existing Album
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={albumImportMode === "no-album" ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => setAlbumImportMode("no-album")}
|
||||
disabled={importAllLoading}
|
||||
>
|
||||
No Album
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{albumImportMode === "album" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="import-album-name">Album name</Label>
|
||||
<Input
|
||||
id="import-album-name"
|
||||
value={albumNameInput}
|
||||
onChange={(event) => setAlbumNameInput(event.target.value)}
|
||||
placeholder="Imported shared album"
|
||||
disabled={importAllLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{albumImportMode === "existing-album" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="existing-album">Select Album</Label>
|
||||
<Select onValueChange={setSelectedAlbumId} value={selectedAlbumId || undefined}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an album" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{existingAlbums.map((album) => (
|
||||
<SelectItem key={album.id} value={album.id}>
|
||||
{album.albumName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setImportAllDialogOpen(false)}
|
||||
disabled={importAllLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (importAllLoading) {
|
||||
return;
|
||||
}
|
||||
const shouldCreateAlbum = albumImportMode === "album";
|
||||
const trimmedAlbumName = albumNameInput.trim();
|
||||
setImportAllDialogOpen(false);
|
||||
handleImportAll({
|
||||
createAlbum: shouldCreateAlbum,
|
||||
albumName: shouldCreateAlbum ? trimmedAlbumName : undefined,
|
||||
addToAlbumId: albumImportMode === "existing-album" && selectedAlbumId ? selectedAlbumId : undefined,
|
||||
});
|
||||
}}
|
||||
disabled={
|
||||
importAllLoading ||
|
||||
(albumImportMode === "album" && albumNameInput.trim().length === 0) ||
|
||||
(albumImportMode === "existing-album" && !selectedAlbumId)
|
||||
}
|
||||
>
|
||||
{importAllLoading ? "Importing..." : "Start import"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!activeVideoAsset}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setActiveVideoAsset(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl">
|
||||
{activeVideoAsset && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{activeVideoAsset.originalFileName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex w-full justify-center">
|
||||
<video
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="max-h-[70vh] w-auto max-w-full rounded-lg bg-black object-contain"
|
||||
src={buildVideoPlaybackUrl(activeVideoAsset) ?? undefined}
|
||||
poster={buildThumbnailUrl(activeVideoAsset) ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={!!activeImageAsset}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setActiveImageAsset(null);
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl">
|
||||
{activeImageAsset && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{activeImageAsset.originalFileName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative flex w-full justify-center min-h-[200px]">
|
||||
<img
|
||||
src={buildPreviewUrl(activeImageAsset) ?? undefined}
|
||||
alt={activeImageAsset.originalFileName}
|
||||
className={`max-h-[80vh] w-auto max-w-full rounded-lg object-contain transition-opacity ${
|
||||
previewLoading ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
onLoad={() => setPreviewLoading(false)}
|
||||
onError={() => setPreviewLoading(false)}
|
||||
/>
|
||||
{previewLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pgTable, uuid, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const albumsAssetsAssets = pgTable('album_asset', {
|
||||
albumsId: uuid('albumsId').notNull(),
|
||||
assetsId: uuid('assetsId').notNull(),
|
||||
albumsId: uuid('albumId').notNull(),
|
||||
assetsId: uuid('assetId').notNull(),
|
||||
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
Reference in new issue
Block a user