Refine CRUD for artists
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@ -23,6 +23,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.58.1",
|
"react-hook-form": "^7.58.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
@ -6154,6 +6155,15 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
@ -24,6 +24,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.58.1",
|
"react-hook-form": "^7.58.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
|
21
prisma/migrations/20250625130123_artist_social/migration.sql
Normal file
21
prisma/migrations/20250625130123_artist_social/migration.sql
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `icon` on the `Social` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `name` on the `Social` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `type` on the `Social` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `url` on the `Social` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `isPrimary` to the `Social` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `platform` to the `Social` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Made the column `handle` on table `Social` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Social" DROP COLUMN "icon",
|
||||||
|
DROP COLUMN "name",
|
||||||
|
DROP COLUMN "type",
|
||||||
|
DROP COLUMN "url",
|
||||||
|
ADD COLUMN "isPrimary" BOOLEAN NOT NULL,
|
||||||
|
ADD COLUMN "link" TEXT,
|
||||||
|
ADD COLUMN "platform" TEXT NOT NULL,
|
||||||
|
ALTER COLUMN "handle" SET NOT NULL;
|
@ -70,11 +70,11 @@ model Social {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
name String?
|
platform String
|
||||||
handle String?
|
handle String
|
||||||
url String?
|
isPrimary Boolean
|
||||||
type String?
|
|
||||||
icon String?
|
link String?
|
||||||
|
|
||||||
artistId String?
|
artistId String?
|
||||||
artist Artist? @relation(fields: [artistId], references: [id])
|
artist Artist? @relation(fields: [artistId], references: [id])
|
||||||
|
@ -9,7 +9,15 @@ export async function createArtist(values: z.infer<typeof artistSchema>) {
|
|||||||
data: {
|
data: {
|
||||||
displayName: values.displayName,
|
displayName: values.displayName,
|
||||||
slug: values.slug,
|
slug: values.slug,
|
||||||
nickname: values.nickname
|
nickname: values.nickname,
|
||||||
|
socials: {
|
||||||
|
create: (values.socials || []).map((s) => ({
|
||||||
|
platform: s.platform,
|
||||||
|
handle: s.handle,
|
||||||
|
link: s.link,
|
||||||
|
isPrimary: s.isPrimary,
|
||||||
|
})),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
@ -8,6 +8,16 @@ export async function updateArtist(
|
|||||||
values: z.infer<typeof artistSchema>,
|
values: z.infer<typeof artistSchema>,
|
||||||
id: string
|
id: string
|
||||||
) {
|
) {
|
||||||
|
const existingSocials = await prisma.social.findMany({
|
||||||
|
where: { artistId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedSocialIds = (values.socials ?? []).filter(s => s.id).map(s => s.id);
|
||||||
|
|
||||||
|
const removedSocials = existingSocials.filter(
|
||||||
|
(s) => !updatedSocialIds.includes(s.id)
|
||||||
|
);
|
||||||
|
|
||||||
return await prisma.artist.update({
|
return await prisma.artist.update({
|
||||||
where: {
|
where: {
|
||||||
id: id
|
id: id
|
||||||
@ -15,7 +25,31 @@ export async function updateArtist(
|
|||||||
data: {
|
data: {
|
||||||
displayName: values.displayName,
|
displayName: values.displayName,
|
||||||
slug: values.slug,
|
slug: values.slug,
|
||||||
nickname: values.nickname
|
nickname: values.nickname,
|
||||||
|
socials: {
|
||||||
|
deleteMany: {
|
||||||
|
id: { in: removedSocials.map(s => s.id) },
|
||||||
|
},
|
||||||
|
updateMany: (values.socials ?? [])
|
||||||
|
.filter((s) => s.id)
|
||||||
|
.map((s) => ({
|
||||||
|
where: { id: s.id },
|
||||||
|
data: {
|
||||||
|
platform: s.platform,
|
||||||
|
handle: s.handle,
|
||||||
|
link: s.link,
|
||||||
|
isPrimary: s.isPrimary,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
create: (values.socials ?? [])
|
||||||
|
.filter((s) => !s.id)
|
||||||
|
.map((s) => ({
|
||||||
|
platform: s.platform,
|
||||||
|
handle: s.handle,
|
||||||
|
link: s.link,
|
||||||
|
isPrimary: s.isPrimary,
|
||||||
|
})),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
@ -7,6 +7,9 @@ export default async function ArtistsEditPage({ params }: { params: { id: string
|
|||||||
const artist = await prisma.artist.findUnique({
|
const artist = await prisma.artist.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
socials: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -4,15 +4,16 @@ import { updateArtist } from "@/actions/artists/updateArtist";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Artist } from "@/generated/prisma";
|
import { Artist, Social } from "@/generated/prisma";
|
||||||
import { artistSchema } from "@/schemas/artists/artistSchema";
|
import { artistSchema } from "@/schemas/artists/artistSchema";
|
||||||
|
import { getSocialIcon } from "@/utils/socialIconMap";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export default function EditAlbumForm({ artist }: { artist: Artist }) {
|
export default function EditAlbumForm({ artist }: { artist: Artist & { socials: Social[] } }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useForm<z.infer<typeof artistSchema>>({
|
const form = useForm<z.infer<typeof artistSchema>>({
|
||||||
resolver: zodResolver(artistSchema),
|
resolver: zodResolver(artistSchema),
|
||||||
@ -20,9 +21,23 @@ export default function EditAlbumForm({ artist }: { artist: Artist }) {
|
|||||||
displayName: artist.displayName,
|
displayName: artist.displayName,
|
||||||
slug: artist.slug,
|
slug: artist.slug,
|
||||||
nickname: artist.nickname || "",
|
nickname: artist.nickname || "",
|
||||||
|
socials: artist.socials.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
platform: s.platform,
|
||||||
|
handle: s.handle,
|
||||||
|
link: s.link ?? "", // Convert null to empty string
|
||||||
|
isPrimary: s.isPrimary,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "socials",
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchSocials = form.watch("socials");
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof artistSchema>) {
|
async function onSubmit(values: z.infer<typeof artistSchema>) {
|
||||||
const updatedArtist = await updateArtist(values, artist.id)
|
const updatedArtist = await updateArtist(values, artist.id)
|
||||||
if (updatedArtist) {
|
if (updatedArtist) {
|
||||||
@ -83,6 +98,95 @@ export default function EditAlbumForm({ artist }: { artist: Artist }) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Socials section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormLabel>Social Links</FormLabel>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex flex-col gap-2 border rounded p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getSocialIcon(watchSocials?.[index]?.platform ?? "")}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`socials.${index}.platform`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Platform</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. telegram" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`socials.${index}.handle`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Handle</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="@username" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`socials.${index}.link`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Link</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://example.com/username" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`socials.${index}.isPrimary`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={
|
||||||
|
() =>
|
||||||
|
form.setValue(
|
||||||
|
"socials",
|
||||||
|
(watchSocials ?? []).map((s, i) => ({
|
||||||
|
...s,
|
||||||
|
isPrimary: i === index,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormLabel className="m-0 text-sm">Set as primary</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
Remove Social
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => append({ platform: "", handle: "", link: "", isPrimary: false })}
|
||||||
|
>
|
||||||
|
+ Add Social
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Button type="submit">Submit</Button>
|
<Button type="submit">Submit</Button>
|
||||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||||
|
@ -5,9 +5,10 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { artistSchema } from "@/schemas/artists/artistSchema";
|
import { artistSchema } from "@/schemas/artists/artistSchema";
|
||||||
|
import { getSocialIcon } from "@/utils/socialIconMap";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
@ -19,9 +20,17 @@ export default function CreateArtistForm() {
|
|||||||
displayName: "",
|
displayName: "",
|
||||||
slug: "",
|
slug: "",
|
||||||
nickname: "",
|
nickname: "",
|
||||||
|
socials: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "socials",
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchSocials = form.watch("socials");
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof artistSchema>) {
|
async function onSubmit(values: z.infer<typeof artistSchema>) {
|
||||||
const artist = await createArtist(values)
|
const artist = await createArtist(values)
|
||||||
if (artist) {
|
if (artist) {
|
||||||
@ -81,6 +90,95 @@ export default function CreateArtistForm() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Socials section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormLabel>Social Links</FormLabel>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex flex-col gap-2 border rounded p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getSocialIcon(watchSocials?.[index]?.platform ?? "")}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`socials.${index}.platform`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Platform</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. telegram" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`socials.${index}.handle`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Handle</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="@username" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`socials.${index}.link`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Link</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://example.com/username" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`socials.${index}.isPrimary`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={
|
||||||
|
() =>
|
||||||
|
form.setValue(
|
||||||
|
"socials",
|
||||||
|
(watchSocials ?? []).map((s, i) => ({
|
||||||
|
...s,
|
||||||
|
isPrimary: i === index,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormLabel className="m-0 text-sm">Set as primary</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
Remove Social
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => append({ platform: "", handle: "", link: "", isPrimary: false })}
|
||||||
|
>
|
||||||
|
+ Add Social
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Button type="submit">Submit</Button>
|
<Button type="submit">Submit</Button>
|
||||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||||
|
@ -1,8 +1,25 @@
|
|||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export const socialSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
platform: z.string().min(1, "Platform is required"),
|
||||||
|
handle: z.string().min(1, "Handle is required"),
|
||||||
|
isPrimary: z.boolean(),
|
||||||
|
link: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const artistSchema = z.object({
|
export const artistSchema = z.object({
|
||||||
displayName: z.string().min(3, "Name is required. Min 3 characters."),
|
displayName: z.string().min(3, "Name is required. Min 3 characters."),
|
||||||
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
|
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
|
||||||
nickname: z.string().optional(),
|
nickname: z.string().optional(),
|
||||||
})
|
socials: z.array(socialSchema).optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
const socials = data.socials || [];
|
||||||
|
return socials.filter((s) => s.isPrimary).length <= 1;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Only one social can be primary",
|
||||||
|
path: ["socials"],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
14
src/utils/socialIconMap.tsx
Normal file
14
src/utils/socialIconMap.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { FaGlobe, FaLink, FaMastodon, FaTelegram } from "react-icons/fa";
|
||||||
|
|
||||||
|
export const getSocialIcon = (platform: string) => {
|
||||||
|
switch (platform.toLowerCase()) {
|
||||||
|
case "telegram":
|
||||||
|
return <FaTelegram className="text-sky-500" />;
|
||||||
|
case "linktree":
|
||||||
|
return <FaLink className="text-green-600" />;
|
||||||
|
case "fediverse":
|
||||||
|
return <FaMastodon className="text-indigo-500" />;
|
||||||
|
default:
|
||||||
|
return <FaGlobe className="text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
Reference in New Issue
Block a user