feat: added option for link expiry
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
1 parent
fdef491586
commit
d5c6f0ac29
8 files changed
+214
-146
No files matched your search
@@ -19,7 +19,7 @@ A unofficial immich client to provide better tools to organize and manage your i
|
||||
|
||||
If you find this tool useful, please consider supporting me by buying me a coffee.
|
||||
|
||||
[](https://www.buymeacoffee.com/varunraj){:target="_blank"}
|
||||
[](https://www.buymeacoffee.com/varunraj)
|
||||
|
||||
## 💭 Back story
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { ShareLinkFilters } from '@/types/shareLink';
|
||||
import { generateShareLink } from '@/handlers/api/shareLink.handler';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '@radix-ui/react-label';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
|
||||
import { Switch } from '../ui/switch';
|
||||
import { Select, SelectValue, SelectContent, SelectItem, SelectTrigger } from '../ui/select';
|
||||
|
||||
|
||||
interface ShareAssetsTriggerProps {
|
||||
@@ -19,10 +20,18 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [config, setConfig] = useState<Partial<ShareLinkFilters>>({});
|
||||
const [config, setConfig] = useState<Partial<ShareLinkFilters>>({
|
||||
expiresIn: "never"
|
||||
});
|
||||
|
||||
const handleReset = () => {
|
||||
setConfig({ expiresIn: "never" });
|
||||
setGeneratedLink(null);
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
return generateShareLink({ ...filters, ...config }).then(({ link }) => {
|
||||
setGeneratedLink(link);
|
||||
}).catch((err) => {
|
||||
@@ -63,20 +72,41 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets
|
||||
This is a stateless link, it will not work if you leave the page. Which means it cannot be expired.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button className="w-full" onClick={handleCopy}>{copied ? 'Copied' : 'Copy'}</Button>
|
||||
<Button className="w-full" variant="outline" onClick={handleGenerate}>Generate New Link</Button>
|
||||
<Button className="w-full" onClick={handleCopy}>{copied ? 'Copied' : 'Copy'}</Button>
|
||||
<Button className="w-full" variant="outline" onClick={handleReset}>Generate New Link</Button>
|
||||
</div>
|
||||
</div> : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm m-0">Show People</Label>
|
||||
<p className="text-xs text-muted-foreground m-0">
|
||||
Show the list of people in the shared photos
|
||||
</p>
|
||||
<Label className="text-sm m-0">Show People</Label>
|
||||
<p className="text-xs text-muted-foreground m-0">
|
||||
Show the list of people in the shared photos
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={config.p} onCheckedChange={(checked) => setConfig({ ...config, p: !!checked })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm m-0">Link Expires In</Label>
|
||||
<p className="text-xs text-muted-foreground m-0">
|
||||
Should the link expire after the selected time
|
||||
</p>
|
||||
</div>
|
||||
<Select onValueChange={(value) => setConfig({ ...config, expiresIn: value })}>
|
||||
<SelectTrigger className='w-fit'>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1h">1 Hour</SelectItem>
|
||||
<SelectItem value="1d">1 Day (24 Hours)</SelectItem>
|
||||
<SelectItem value="7d">7 Days</SelectItem>
|
||||
<SelectItem value="30d">30 Days</SelectItem>
|
||||
<SelectItem value="90d">90 Days</SelectItem>
|
||||
<SelectItem value="never">Never Expires</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleGenerate} disabled={loading}>Generate Share Link</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { desc, gte, lte } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { assetFaces, assets, exif, person } from "@/schema";
|
||||
import { and } from "drizzle-orm";
|
||||
import { sign, verify } from "jsonwebtoken";
|
||||
import { JsonWebTokenError, sign, verify } from "jsonwebtoken";
|
||||
import { NextApiResponse } from "next";
|
||||
|
||||
import { NextApiRequest } from "next";
|
||||
@@ -19,89 +19,96 @@ interface IQuery extends ShareLinkFilters {
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { token, ...rest } = req.query as any as IQuery
|
||||
try {
|
||||
const { token, ...rest } = req.query as any as IQuery
|
||||
|
||||
const filters = {
|
||||
personIds: rest['personIds[]'] ?? rest.personIds ?? [],
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(404).json({ message: "Token not found" });
|
||||
}
|
||||
|
||||
const decoded = verify(token as string, ENV.JWT_SECRET);
|
||||
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, {
|
||||
expiresIn: "2m"
|
||||
});
|
||||
|
||||
const { personIds = [], albumIds = [], startDate, endDate } = decoded as ShareLinkFilters;
|
||||
let filteredPersonIds: string[] = personIds ?? [];
|
||||
const queryPersonIds = filters.personIds ? (Array.isArray(filters.personIds) ? filters.personIds : [filters.personIds]) : [];
|
||||
|
||||
|
||||
if (queryPersonIds.length > 0) {
|
||||
|
||||
if (personIds.length === 0) {
|
||||
filteredPersonIds = queryPersonIds;
|
||||
} else {
|
||||
filteredPersonIds = queryPersonIds.filter((id) => personIds.includes(id));
|
||||
const filters = {
|
||||
personIds: rest['personIds[]'] ?? rest.personIds ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
const dbAssets = await db.select({
|
||||
id: assets.id,
|
||||
deviceId: assets.deviceId,
|
||||
type: assets.type,
|
||||
originalPath: assets.originalPath,
|
||||
isFavorite: assets.isFavorite,
|
||||
duration: assets.duration,
|
||||
encodedVideoPath: assets.encodedVideoPath,
|
||||
originalFileName: assets.originalFileName,
|
||||
sidecarPath: assets.sidecarPath,
|
||||
deletedAt: assets.deletedAt,
|
||||
localDateTime: assets.localDateTime,
|
||||
exifImageWidth: exif.exifImageWidth,
|
||||
exifImageHeight: exif.exifImageHeight,
|
||||
ownerId: assets.ownerId,
|
||||
dateTimeOriginal: exif.dateTimeOriginal,
|
||||
url: assets.id,
|
||||
previewUrl: assets.id,
|
||||
videoURL: assets.id,
|
||||
orientation: exif.orientation,
|
||||
}).from(assets)
|
||||
.innerJoin(assetFaces, eq(assets.id, assetFaces.assetId))
|
||||
.innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
|
||||
.innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id))
|
||||
.innerJoin(exif, eq(exif.assetId, assets.id))
|
||||
.where(and(
|
||||
filteredPersonIds?.length > 0 ? inArray(assetFaces.personId, filteredPersonIds) : undefined,
|
||||
albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined,
|
||||
startDate ? gte(assets.createdAt, new Date(startDate)) : undefined,
|
||||
endDate ? lte(assets.createdAt, new Date(endDate)) : undefined,
|
||||
eq(assets.isArchived, false),
|
||||
eq(assets.isVisible, true),
|
||||
eq(assets.isOffline, false),
|
||||
))
|
||||
.orderBy(desc(assets.createdAt));
|
||||
|
||||
const cleanedAssets = dbAssets.map((asset) => {
|
||||
return {
|
||||
...asset,
|
||||
localDateTime: new Date(asset.localDateTime),
|
||||
duration: asset.duration ? parseInt(asset.duration) : 0,
|
||||
exifImageWidth: (isFlipped(asset.orientation) ? asset.exifImageHeight : asset.exifImageWidth) ?? 0,
|
||||
exifImageHeight: (isFlipped(asset.orientation) ? asset.exifImageWidth : asset.exifImageHeight) ?? 0,
|
||||
dateTimeOriginal: new Date(asset.dateTimeOriginal || new Date()).toISOString(),
|
||||
if (!token) {
|
||||
return res.status(404).json({ message: "Token not found" });
|
||||
}
|
||||
}).map((asset) => cleanUpShareAsset(asset, newPreviewToken as string));
|
||||
|
||||
return res.status(200).json({
|
||||
assets: cleanedAssets,
|
||||
filters: decoded as ShareLinkFilters
|
||||
});
|
||||
|
||||
const decoded = verify(token as string, ENV.JWT_SECRET);
|
||||
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, {
|
||||
expiresIn: "2m"
|
||||
});
|
||||
|
||||
const { personIds = [], albumIds = [], startDate, endDate } = decoded as ShareLinkFilters;
|
||||
let filteredPersonIds: string[] = personIds ?? [];
|
||||
const queryPersonIds = filters.personIds ? (Array.isArray(filters.personIds) ? filters.personIds : [filters.personIds]) : [];
|
||||
|
||||
|
||||
if (queryPersonIds.length > 0) {
|
||||
|
||||
if (personIds.length === 0) {
|
||||
filteredPersonIds = queryPersonIds;
|
||||
} else {
|
||||
filteredPersonIds = queryPersonIds.filter((id) => personIds.includes(id));
|
||||
}
|
||||
}
|
||||
|
||||
const dbAssets = await db.select({
|
||||
id: assets.id,
|
||||
deviceId: assets.deviceId,
|
||||
type: assets.type,
|
||||
originalPath: assets.originalPath,
|
||||
isFavorite: assets.isFavorite,
|
||||
duration: assets.duration,
|
||||
encodedVideoPath: assets.encodedVideoPath,
|
||||
originalFileName: assets.originalFileName,
|
||||
sidecarPath: assets.sidecarPath,
|
||||
deletedAt: assets.deletedAt,
|
||||
localDateTime: assets.localDateTime,
|
||||
exifImageWidth: exif.exifImageWidth,
|
||||
exifImageHeight: exif.exifImageHeight,
|
||||
ownerId: assets.ownerId,
|
||||
dateTimeOriginal: exif.dateTimeOriginal,
|
||||
url: assets.id,
|
||||
previewUrl: assets.id,
|
||||
videoURL: assets.id,
|
||||
orientation: exif.orientation,
|
||||
}).from(assets)
|
||||
.innerJoin(assetFaces, eq(assets.id, assetFaces.assetId))
|
||||
.innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
|
||||
.innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id))
|
||||
.innerJoin(exif, eq(exif.assetId, assets.id))
|
||||
.where(and(
|
||||
filteredPersonIds?.length > 0 ? inArray(assetFaces.personId, filteredPersonIds) : undefined,
|
||||
albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined,
|
||||
startDate ? gte(assets.createdAt, new Date(startDate)) : undefined,
|
||||
endDate ? lte(assets.createdAt, new Date(endDate)) : undefined,
|
||||
eq(assets.isArchived, false),
|
||||
eq(assets.isVisible, true),
|
||||
eq(assets.isOffline, false),
|
||||
))
|
||||
.orderBy(desc(assets.createdAt));
|
||||
|
||||
const cleanedAssets = dbAssets.map((asset) => {
|
||||
return {
|
||||
...asset,
|
||||
localDateTime: new Date(asset.localDateTime),
|
||||
duration: asset.duration ? parseInt(asset.duration) : 0,
|
||||
exifImageWidth: (isFlipped(asset.orientation) ? asset.exifImageHeight : asset.exifImageWidth) ?? 0,
|
||||
exifImageHeight: (isFlipped(asset.orientation) ? asset.exifImageWidth : asset.exifImageHeight) ?? 0,
|
||||
dateTimeOriginal: new Date(asset.dateTimeOriginal || new Date()).toISOString(),
|
||||
}
|
||||
}).map((asset) => cleanUpShareAsset(asset, newPreviewToken as string));
|
||||
|
||||
return res.status(200).json({
|
||||
assets: cleanedAssets,
|
||||
filters: decoded as ShareLinkFilters
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof JsonWebTokenError) {
|
||||
return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." });
|
||||
}
|
||||
return res.status(500).json({ message: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,30 @@ import { NextApiResponse } from "next";
|
||||
|
||||
import { ENV } from "@/config/environment";
|
||||
import { ShareLinkFilters } from "@/types/shareLink";
|
||||
import { verify } from "jsonwebtoken";
|
||||
import { JsonWebTokenError, verify } from "jsonwebtoken";
|
||||
import { NextApiRequest } from "next";
|
||||
|
||||
export default async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { token } = req.query;
|
||||
try {
|
||||
|
||||
if (!token) {
|
||||
return res.status(404).json({ message: "Token not found" });
|
||||
}
|
||||
|
||||
const decoded = verify(token as string, ENV.JWT_SECRET);
|
||||
console.log(decoded);
|
||||
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
|
||||
return res.status(200).json(decoded);
|
||||
} catch (error) {
|
||||
if (error instanceof JsonWebTokenError) {
|
||||
return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." });
|
||||
}
|
||||
return res.status(500).json({ message: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { count, desc, gte, isNotNull, lte, ne } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { assetFaces, assets, exif, person } from "@/schema";
|
||||
import { and } from "drizzle-orm";
|
||||
import { sign, verify } from "jsonwebtoken";
|
||||
import { JsonWebTokenError, sign, verify } from "jsonwebtoken";
|
||||
import { NextApiResponse } from "next";
|
||||
|
||||
import { NextApiRequest } from "next";
|
||||
@@ -21,52 +21,59 @@ interface ShareLinkFilters {
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { token } = req.query;
|
||||
if (!token) {
|
||||
return res.status(404).json({ message: "Token not found" });
|
||||
}
|
||||
|
||||
const decoded = verify(token as string, ENV.JWT_SECRET);
|
||||
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, {
|
||||
expiresIn: "2m"
|
||||
});
|
||||
const { personIds, albumIds, startDate, endDate } = decoded as ShareLinkFilters;
|
||||
|
||||
const dbPeople = await db.select({
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
thumbnailAssetId: person.faceAssetId,
|
||||
assetCount: count(assets.id),
|
||||
}).from(assets)
|
||||
.innerJoin(assetFaces, eq(assets.id, assetFaces.assetId))
|
||||
.innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
|
||||
.innerJoin(person, eq(assetFaces.personId, person.id))
|
||||
.innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id))
|
||||
.innerJoin(exif, eq(exif.assetId, assets.id))
|
||||
.where(and(
|
||||
personIds?.length > 0 ? inArray(assetFaces.personId, personIds) : undefined,
|
||||
albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined,
|
||||
startDate ? gte(assets.createdAt, new Date(startDate)) : undefined,
|
||||
endDate ? lte(assets.createdAt, new Date(endDate)) : undefined,
|
||||
eq(assets.isArchived, false),
|
||||
eq(assets.isVisible, true),
|
||||
eq(assets.isOffline, false),
|
||||
isNotNull(person.id),
|
||||
ne(person.name, "")
|
||||
))
|
||||
.groupBy(person.id)
|
||||
.orderBy(desc(count(assets.id)));
|
||||
|
||||
const cleanedPeople = dbPeople.map((person) => {
|
||||
return {
|
||||
...person,
|
||||
thumbnailPath: person.thumbnailAssetId ? ASSET_SHARE_THUMBNAIL_PATH({ id: person.id, size: "thumbnail", token: newPreviewToken, isPeople: true }) : null
|
||||
try {
|
||||
if (!token) {
|
||||
return res.status(404).json({ message: "Token not found" });
|
||||
}
|
||||
})
|
||||
|
||||
return res.status(200).json(cleanedPeople);
|
||||
const decoded = verify(token as string, ENV.JWT_SECRET);
|
||||
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, {
|
||||
expiresIn: "2m"
|
||||
});
|
||||
const { personIds, albumIds, startDate, endDate } = decoded as ShareLinkFilters;
|
||||
|
||||
const dbPeople = await db.select({
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
thumbnailAssetId: person.faceAssetId,
|
||||
assetCount: count(assets.id),
|
||||
}).from(assets)
|
||||
.innerJoin(assetFaces, eq(assets.id, assetFaces.assetId))
|
||||
.innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
|
||||
.innerJoin(person, eq(assetFaces.personId, person.id))
|
||||
.innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id))
|
||||
.innerJoin(exif, eq(exif.assetId, assets.id))
|
||||
.where(and(
|
||||
personIds?.length > 0 ? inArray(assetFaces.personId, personIds) : undefined,
|
||||
albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined,
|
||||
startDate ? gte(assets.createdAt, new Date(startDate)) : undefined,
|
||||
endDate ? lte(assets.createdAt, new Date(endDate)) : undefined,
|
||||
eq(assets.isArchived, false),
|
||||
eq(assets.isVisible, true),
|
||||
eq(assets.isOffline, false),
|
||||
isNotNull(person.id),
|
||||
ne(person.name, "")
|
||||
))
|
||||
.groupBy(person.id)
|
||||
.orderBy(desc(count(assets.id)));
|
||||
|
||||
const cleanedPeople = dbPeople.map((person) => {
|
||||
return {
|
||||
...person,
|
||||
thumbnailPath: person.thumbnailAssetId ? ASSET_SHARE_THUMBNAIL_PATH({ id: person.id, size: "thumbnail", token: newPreviewToken, isPeople: true }) : null
|
||||
}
|
||||
})
|
||||
|
||||
return res.status(200).json(cleanedPeople);
|
||||
} catch (error) {
|
||||
if (error instanceof JsonWebTokenError) {
|
||||
return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." });
|
||||
}
|
||||
return res.status(500).json({ message: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
import { ENV } from "@/config/environment";
|
||||
import { SHARE_LINK_ASSETS_PATH } from "@/config/routes";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { sign } from "jsonwebtoken";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
@@ -8,8 +7,10 @@ import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const currentUser = await getCurrentUser(req);
|
||||
const filters = req.body;
|
||||
const allFilters = req.body;
|
||||
const { expiresIn, ...filters } = allFilters;
|
||||
|
||||
try {
|
||||
if (!currentUser) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
@@ -18,6 +19,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const token = sign(filters, ENV.JWT_SECRET);
|
||||
return res.status(200).json({ link: `${ENV.POWER_TOOLS_ENDPOINT_URL}/s/${token}` });
|
||||
}
|
||||
console.log(expiresIn);
|
||||
|
||||
const token = sign(filters, ENV.JWT_SECRET, expiresIn !== "never" ? {
|
||||
expiresIn: expiresIn
|
||||
} : {});
|
||||
|
||||
return res.status(200).json({ link: `${ENV.POWER_TOOLS_ENDPOINT_URL}/s/${token}` });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { IPerson } from '@/types/person'
|
||||
import PeopleList from '@/components/shared/PeopleList'
|
||||
import { ShareLinkFilters } from '@/types/shareLink'
|
||||
import clsx from 'clsx'
|
||||
import { LinkBreak2Icon } from '@radix-ui/react-icons'
|
||||
|
||||
export default function AlbumListPage() {
|
||||
const router = useRouter()
|
||||
@@ -107,7 +108,10 @@ export default function AlbumListPage() {
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) return <Loader />
|
||||
else if (errorMessage) return <div>{errorMessage}</div>
|
||||
else if (errorMessage) return <div className="flex flex-col gap-4 items-center justify-center h-full">
|
||||
<LinkBreak2Icon className="w-10 h-10 text-zinc-500" />
|
||||
<p className="text-zinc-500">{errorMessage}</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex gap-1 max-h-full">
|
||||
{config.p && (
|
||||
@@ -132,7 +136,7 @@ export default function AlbumListPage() {
|
||||
return (
|
||||
<PageLayout className="!p-0 !mb-0">
|
||||
<Header
|
||||
leftComponent={"Assets"}
|
||||
leftComponent={"Shared Link"}
|
||||
rightComponent={
|
||||
!loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Vendored
+1
@@ -4,4 +4,5 @@ export interface ShareLinkFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
p?: boolean;
|
||||
expiresIn?: string;
|
||||
}
|
||||
Reference in new issue
Block a user