feat: added option to offset bulk photos
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
@@ -6,6 +6,9 @@ import React, { use, useEffect, useState } from "react";
|
||||
import PotentialDateItem from "./PotentialDateItem";
|
||||
import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext";
|
||||
import { useRouter } from "next/router";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDownIcon, SortAsc, SortDesc } from "lucide-react";
|
||||
|
||||
export default function PotentialAlbumsDates() {
|
||||
const router = useRouter();
|
||||
@@ -13,13 +16,17 @@ export default function PotentialAlbumsDates() {
|
||||
const [dateRecords, setDateRecords] = React.useState<
|
||||
IPotentialAlbumsDatesResponse[]
|
||||
>([]);
|
||||
const [filters, setFilters] = useState<{ sortBy: string, sortOrder: string }>({ sortBy: "date", sortOrder: "desc" });
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
return listPotentialAlbumsDates({})
|
||||
return listPotentialAlbumsDates({
|
||||
sortBy: filters.sortBy,
|
||||
sortOrder: filters.sortOrder,
|
||||
})
|
||||
.then(setDateRecords)
|
||||
.catch(setErrorMessage)
|
||||
.finally(() => setLoading(false));
|
||||
@@ -31,15 +38,38 @@ export default function PotentialAlbumsDates() {
|
||||
pathname: router.pathname,
|
||||
query: { startDate: date },
|
||||
})
|
||||
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
}, [filters]);
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto min-w-[170px] py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] dark:bg-zinc-900 bg-gray-200 flex flex-col gap-2 px-2">
|
||||
<div className="overflow-y-auto min-w-[200px] py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] dark:bg-zinc-900 bg-gray-200 flex flex-col gap-2 px-2">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<Select
|
||||
defaultValue={filters.sortBy}
|
||||
onValueChange={(value) => setFilters({ ...filters, sortBy: value })}
|
||||
>
|
||||
<SelectTrigger className="!p-2">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent defaultValue={filters.sortBy}>
|
||||
<SelectGroup title="Sort by">
|
||||
<SelectLabel>Sort by</SelectLabel>
|
||||
<SelectItem value="date">Date</SelectItem>
|
||||
<SelectItem value="asset_count">Asset Count</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
<Button variant="default" size="sm" onClick={() => setFilters({ ...filters, sortOrder: filters.sortOrder === "asc" ? "desc" : "asc" })}>
|
||||
{filters.sortOrder === "asc" ? <SortAsc size={16} /> : <SortDesc size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dateRecords.map((record) => (
|
||||
<PotentialDateItem
|
||||
key={record.date}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { IAsset } from '@/types/asset';
|
||||
import { Dialog, DialogTitle, DialogHeader, DialogContent } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatDate, offsetDate } from '@/helpers/date.helper';
|
||||
import LazyImage from '@/components/ui/lazy-image';
|
||||
import Image from 'next/image';
|
||||
import { updateAssets } from '@/handlers/api/asset.handler';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
interface IProps {
|
||||
assets: IAsset[];
|
||||
open: boolean;
|
||||
toggleOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: IProps) {
|
||||
const { toast } = useToast();
|
||||
const [assets, setAssets] = useState<IAsset[]>(_assets);
|
||||
const [offsetData, setOffsetData] = useState<{ days: number, hours: number, minutes: number, seconds: number, years: number }>({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
years: 0
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [assetStatus, setAssetStatus] = useState<Record<string, string>>({});
|
||||
|
||||
const handleChange = (key: keyof typeof offsetData, value: number) => {
|
||||
setOffsetData({ ...offsetData, [key]: value });
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
console.log(offsetData);
|
||||
setLoading(true);
|
||||
const promises = assets.map(async (asset) => {
|
||||
setAssetStatus({ [asset.id]: 'pending' });
|
||||
await updateAssets({
|
||||
ids: [asset.id],
|
||||
dateTimeOriginal: offsetDate(asset.dateTimeOriginal, offsetData)
|
||||
})
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
toggleOpen(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
setLoading(false);
|
||||
setAssetStatus({ [asset.id]: 'error' });
|
||||
})
|
||||
});
|
||||
await Promise.all(promises);
|
||||
setLoading(false);
|
||||
toggleOpen(false);
|
||||
toast({
|
||||
title: 'Asset dates offset',
|
||||
description: 'Asset dates have been offset',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setAssets(_assets);
|
||||
}, [_assets]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={toggleOpen}>
|
||||
<DialogContent className='max-w-[800px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Offset Asset Dates</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className='flex items-center gap-2'>
|
||||
<div>
|
||||
<Label>Years</Label>
|
||||
<Input type="number" value={offsetData.years} onChange={(e) => handleChange("years", parseInt(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Days</Label>
|
||||
<Input type="number" value={offsetData.days} onChange={(e) => handleChange("days", parseInt(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Hours</Label>
|
||||
<Input type="number" value={offsetData.hours} onChange={(e) => handleChange("hours", parseInt(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Minutes</Label>
|
||||
<Input type="number" value={offsetData.minutes} onChange={(e) => handleChange("minutes", parseInt(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Seconds</Label>
|
||||
<Input type="number" value={offsetData.seconds} onChange={(e) => handleChange("seconds", parseInt(e.target.value))} />
|
||||
</div>
|
||||
<Button disabled={loading} type="submit">Offset Dates</Button>
|
||||
</form>
|
||||
<div className='grid grid-cols-3 gap-4 py-2 overflow-y-auto max-h-[500px]'>
|
||||
{assets.map((asset) => (
|
||||
<div key={asset.id} >
|
||||
<div className='relative w-full h-full rounded-md border overflow-hidden' >
|
||||
{assetStatus[asset.id] === 'pending' ? <div className='absolute text-xs text-center bottom-0 left-0 right-0 bg-blue-500/50 text-white p-2'>
|
||||
<p className='text-white'>Offsetting...</p>
|
||||
</div> : (
|
||||
<div className='absolute text-xs text-center bottom-0 left-0 right-0 bg-green-500/50 text-white p-2'>{formatDate(offsetDate(asset.dateTimeOriginal, offsetData), 'PPpp')} </div>
|
||||
)}
|
||||
<Image
|
||||
src={asset.previewUrl}
|
||||
alt={asset.originalFileName}
|
||||
width={300}
|
||||
height={300}
|
||||
objectPosition='cover'
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
<div className='absolute text-xs text-center top-0 left-0 right-0 bg-red-500/50 text-white p-2'>{formatDate(asset.dateTimeOriginal, 'PPpp')}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { IAsset } from '@/types/asset';
|
||||
import { DotsVerticalIcon, HamburgerMenuIcon } from '@radix-ui/react-icons';
|
||||
import { Clock, PlusIcon } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import AssetOffsetDialog from './AssetOffsetDialog';
|
||||
|
||||
interface IProps {
|
||||
onAdd?: () => void;
|
||||
assets: IAsset[];
|
||||
}
|
||||
export default function AssetsOptions({ onAdd, assets }: IProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className='cursor-pointer'>
|
||||
<HamburgerMenuIcon className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className='flex items-center gap-2' onSelect={() => setOpen(true)}>
|
||||
<Clock className="w-4 h-4" /> Offset Asset Dates
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{open && <AssetOffsetDialog assets={assets} open={open} toggleOpen={setOpen} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export default function PeopleList() {
|
||||
if (errorMessage) return <div>{errorMessage}</div>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9 gap-1 p-2">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-1 p-2">
|
||||
{people.map((person) => (
|
||||
<PersonItem person={person} key={person.id} onRemove={handleRemove}/>
|
||||
))}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { IAsset } from "@/types/asset";
|
||||
interface IPotentialAlbumsDatesFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}
|
||||
export interface IPotentialAlbumsDatesResponse {
|
||||
date: string;
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface IUpdateAssetsParams {
|
||||
ids: string[];
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
dateTimeOriginal?: string;
|
||||
}
|
||||
|
||||
export const updateAssets = async (params: IUpdateAssetsParams) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { format, parse } from "date-fns"
|
||||
import { addSeconds, addHours, addMinutes, format, parse, addYears } from "date-fns"
|
||||
|
||||
export const formatDate = (date: string, outputFormat?: string): string => {
|
||||
return format(date, outputFormat || "PPP")
|
||||
@@ -13,3 +13,21 @@ export const addDays = (date: Date, days: number): Date => {
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
export const offsetDate = (date: string, offset: {
|
||||
years: number,
|
||||
days: number,
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number
|
||||
}): string => {
|
||||
console.log(offset)
|
||||
const parsedDate = new Date(date);
|
||||
const result = addYears(parsedDate, offset.years || 0)
|
||||
const result2 = addDays(result, offset.days || 0)
|
||||
const result3 = addHours(result2, offset.hours || 0)
|
||||
const result4 = addMinutes(result3, offset.minutes || 0)
|
||||
const result5 = addSeconds(result4, offset.seconds || 0)
|
||||
return result5.toISOString()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import AlbumCreateDialog from "@/components/albums/AlbumCreateDialog";
|
||||
import AlbumSelectorDialog from "@/components/albums/AlbumSelectorDialog";
|
||||
import PotentialAlbumsAssets from "@/components/albums/potential-albums/PotentialAlbumsAssets";
|
||||
import PotentialAlbumsDates from "@/components/albums/potential-albums/PotentialAlbumsDates";
|
||||
import AssetsOptions from "@/components/assets/assets-options/AssetsOptions";
|
||||
import PageLayout from "@/components/layouts/PageLayout";
|
||||
import Header from "@/components/shared/Header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -14,7 +15,7 @@ import PotentialAlbumContext, {
|
||||
import { addAssetToAlbum, createAlbum } from "@/handlers/api/album.handler";
|
||||
import { IAlbum, IAlbumCreate } from "@/types/album";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
export default function PotentialAlbums() {
|
||||
const { toast } = useToast();
|
||||
@@ -28,6 +29,8 @@ export default function PotentialAlbums() {
|
||||
assets: [],
|
||||
});
|
||||
|
||||
const selectedAssets = useMemo(() => config.assets.filter((a) => config.selectedIds.includes(a.id)), [config.assets, config.selectedIds]) ;
|
||||
|
||||
const handleSelect = (album: IAlbum) => {
|
||||
return addAssetToAlbum(album.id, config.selectedIds)
|
||||
.then(() => {
|
||||
@@ -65,7 +68,7 @@ export default function PotentialAlbums() {
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<PageLayout className="!p-0 !mb-0">
|
||||
<Header
|
||||
@@ -102,6 +105,7 @@ export default function PotentialAlbums() {
|
||||
)}
|
||||
<AlbumSelectorDialog onSelected={handleSelect} />
|
||||
<AlbumCreateDialog onSubmit={handleCreate} assetIds={config.assets.map((a) => a.id)} />
|
||||
<AssetsOptions assets={selectedAssets} onAdd={() => { }} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -21,9 +21,9 @@ const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) =>
|
||||
a."sidecarPath",
|
||||
a."thumbhash",
|
||||
a."deletedAt",
|
||||
a."localDateTime",
|
||||
e."exifImageWidth",
|
||||
e."exifImageHeight"
|
||||
e."exifImageHeight",
|
||||
e."dateTimeOriginal"
|
||||
FROM
|
||||
assets a
|
||||
LEFT JOIN
|
||||
@@ -35,7 +35,7 @@ const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) =>
|
||||
WHERE
|
||||
aaa."albumsId" IS NULL
|
||||
AND a."ownerId" = '${ownerId}'
|
||||
AND a."localDateTime"::date = '${date}'
|
||||
AND e."dateTimeOriginal"::date = '${date}'
|
||||
AND a."isVisible" = true
|
||||
`);
|
||||
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import { CHART_COLORS } from "@/config/constants/chart.constant";
|
||||
import { db } from "@/config/db";
|
||||
import { IPotentialAlbumsDatesResponse } from "@/handlers/api/album.handler";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { parseDate } from "@/helpers/date.helper";
|
||||
import { exif } from "@/schema";
|
||||
import { count, desc, isNotNull, ne, sql } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
const SELECT_ORPHAN_PHOTOS = (ownerId: string) => sql`
|
||||
SELECT
|
||||
DATE(a."localDateTime") AS "date",
|
||||
DATE(e."dateTimeOriginal") AS "date",
|
||||
COUNT(a."id") AS "asset_count"
|
||||
FROM
|
||||
"assets" a
|
||||
LEFT JOIN
|
||||
"albums_assets_assets" aaa ON a."id" = aaa."assetsId"
|
||||
LEFT JOIN
|
||||
"exif" e ON a."id" = e."assetId"
|
||||
WHERE
|
||||
aaa."albumsId" IS NULL
|
||||
AND a."ownerId" = ${ownerId}
|
||||
AND a."isVisible" = true
|
||||
AND e."dateTimeOriginal" IS NOT NULL
|
||||
GROUP BY
|
||||
DATE(a."localDateTime")
|
||||
DATE(e."dateTimeOriginal")
|
||||
ORDER BY
|
||||
"asset_count" DESC;
|
||||
`;
|
||||
@@ -29,8 +34,22 @@ export default async function handler(
|
||||
res: NextApiResponse
|
||||
) {
|
||||
try {
|
||||
const { sortBy = "date", sortOrder = "desc" } = req.query;
|
||||
const currentUser = await getCurrentUser(req)
|
||||
const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(currentUser.id));
|
||||
const data = await db.execute(SELECT_ORPHAN_PHOTOS(currentUser.id)) as any;
|
||||
const rows = data.rows as IPotentialAlbumsDatesResponse[];
|
||||
|
||||
if (sortBy === "date") {
|
||||
rows.sort((a, b) => {
|
||||
const aDate = parseDate(a.date, "yyyy-MM-dd");
|
||||
const bDate = parseDate(b.date, "yyyy-MM-dd");
|
||||
return sortOrder === "asc" ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime();
|
||||
});
|
||||
} else if (sortBy === "asset_count") {
|
||||
rows.sort((a, b) => {
|
||||
return sortOrder === "asc" ? a.asset_count - b.asset_count : b.asset_count - a.asset_count;
|
||||
});
|
||||
}
|
||||
return res.status(200).json(rows);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
|
||||
@@ -43,6 +43,7 @@ export default async function handler(
|
||||
exifImageWidth: exif.exifImageWidth,
|
||||
exifImageHeight: exif.exifImageHeight,
|
||||
ownerId: assets.ownerId,
|
||||
dateTimeOriginal: exif.dateTimeOriginal,
|
||||
})
|
||||
.from(assets)
|
||||
.leftJoin(exif, eq(exif.assetId, assets.id))
|
||||
|
||||
@@ -14,10 +14,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
headers: getUserHeaders(currentUser),
|
||||
headers: {
|
||||
...getUserHeaders(currentUser),
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
},
|
||||
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : null,
|
||||
})
|
||||
|
||||
// Log response headers for debugging
|
||||
console.log('Response Headers:', response.headers);
|
||||
|
||||
// Forward the status code
|
||||
res.status(response.status)
|
||||
@@ -28,7 +33,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
res.setHeader(key, value)
|
||||
})
|
||||
|
||||
// Stream the response body
|
||||
// Stream the response body
|
||||
const reader = response.body?.getReader()
|
||||
if (reader) {
|
||||
while (true) {
|
||||
@@ -38,8 +43,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
}
|
||||
res.end()
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Proxy error:', error)
|
||||
res.status(500).json({ error })
|
||||
res.status(500).json({ error: error?.message })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import AlbumSelectorDialog from "@/components/albums/AlbumSelectorDialog";
|
||||
import PotentialAlbumsAssets from "@/components/albums/potential-albums/PotentialAlbumsAssets";
|
||||
import PotentialAlbumsDates from "@/components/albums/potential-albums/PotentialAlbumsDates";
|
||||
import AssetsOptions from "@/components/assets/assets-options/AssetsOptions";
|
||||
import MissingLocationAssets from "@/components/assets/missing-location/MissingLocationAssets";
|
||||
import MissingLocationDates from "@/components/assets/missing-location/MissingLocationDates";
|
||||
import TagMissingLocationDialog from "@/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog";
|
||||
@@ -17,7 +18,7 @@ import { updateAssets } from "@/handlers/api/asset.handler";
|
||||
|
||||
import { IPlace } from "@/types/common";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
export default function MissingLocations() {
|
||||
const { toast } = useToast();
|
||||
@@ -30,6 +31,8 @@ export default function MissingLocations() {
|
||||
assets: [],
|
||||
});
|
||||
|
||||
const selectedAssets = useMemo(() => config.assets.filter((a) => config.selectedIds.includes(a.id)), [config.assets, config.selectedIds]) ;
|
||||
|
||||
const handleSubmit = (place: IPlace) => {
|
||||
return updateAssets({
|
||||
ids: config.selectedIds,
|
||||
@@ -78,6 +81,7 @@ export default function MissingLocations() {
|
||||
</Button>
|
||||
)}
|
||||
<TagMissingLocationDialog onSubmit={handleSubmit} />
|
||||
<AssetsOptions assets={selectedAssets} onAdd={() => { }} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
Vendored
+1
@@ -17,6 +17,7 @@ export interface IAsset {
|
||||
url: string;
|
||||
previewUrl: string;
|
||||
videoURL?: string;
|
||||
dateTimeOriginal: string;
|
||||
}
|
||||
|
||||
export interface IAssetThumbhash {
|
||||
|
||||
Reference in New Issue
Block a user