🎨 Add new UI components: Container, Grid, Header, ModeToggle, Search, SvgCard & CopySvg, DownloadSvg with improved functionality and styling

This commit is contained in:
pheralb
2025-08-25 19:07:05 +01:00
parent 56d65c0619
commit 0da0ccfc37
8 changed files with 1258 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { cn } from "@/utils/cn";
let { className, children }: { className?: string; children?: Snippet } =
$props();
</script>
<div class={cn("container mx-auto px-4", className)}>
{@render children?.()}
</div>
+551
View File
@@ -0,0 +1,551 @@
<script lang="ts">
import type { iSVG } from "@/types/svg";
// Utils:
import { clipboard } from "@/utils/clipboard";
import { getPrefixFromSvgUrl, prefixSvgIds } from "@/utils/prefixSvgIds";
import { copyToClipboard as figmaCopyToClipboard } from "@/figma/copy-to-clipboard";
// Icons:
import XIcon from "@lucide/svelte/icons/x";
import CopyIcon from "@lucide/svelte/icons/copy";
import LoaderIcon from "@lucide/svelte/icons/loader";
import ClipboardIcon from "@lucide/svelte/icons/clipboard";
// UI Components:
import { toast } from "svelte-sonner";
import * as Tabs from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import * as Popover from "@/components/ui/popover";
// Templates:
import { getSource } from "@/templates/getSource";
import { getVueCode } from "@/templates/getVueCode";
import { getReactCode } from "@/templates/getReactCode";
import { getAstroCode } from "@/templates/getAstroCode";
import { getSvelteCode } from "@/templates/getSvelteCode";
import { getAngularCode } from "@/templates/getAngularCode";
import { getWebComponent } from "@/templates/getWebComponent";
// SVGs:
import Vue from "@/components/logos/vue.svelte";
import React from "@/components/logos/react.svelte";
import Astro from "@/components/logos/astro.svelte";
import Svelte from "@/components/logos/svelte.svelte";
import Angular from "@/components/logos/angular.svelte";
import WebComponents from "@/components/logos/webComponents.svelte";
// Props:
interface Props {
size?: number;
iconStroke?: number;
isInFigma?: boolean;
isWordmarkSvg?: boolean;
svgInfo: iSVG;
}
let {
size = 24,
iconStroke = 2,
isInFigma = false,
isWordmarkSvg = false,
svgInfo,
}: Props = $props();
// States:
let optionsOpen = $state<boolean>(false);
let isLoading = $state<boolean>(false);
const getSvgUrl = () => {
let svgUrlToCopy;
const dark = document.documentElement.classList.contains("dark");
if (isWordmarkSvg) {
const svgHasTheme = typeof svgInfo.wordmark !== "string";
if (!svgHasTheme) {
svgUrlToCopy =
typeof svgInfo.wordmark === "string"
? svgInfo.wordmark
: "Something went wrong. Couldn't copy the SVG.";
}
svgUrlToCopy =
typeof svgInfo.wordmark !== "string"
? dark
? svgInfo.wordmark?.dark
: svgInfo.wordmark?.light
: svgInfo.wordmark;
} else {
const svgHasTheme = typeof svgInfo.route !== "string";
if (!svgHasTheme) {
svgUrlToCopy =
typeof svgInfo.route === "string"
? svgInfo.route
: "Something went wrong. Couldn't copy the SVG.";
}
svgUrlToCopy =
typeof svgInfo.route !== "string"
? dark
? svgInfo.route.dark
: svgInfo.route.light
: svgInfo.route;
}
return svgUrlToCopy;
};
// Copy SVG to clipboard:
const copyToClipboard = async () => {
const svgUrlToCopy = getSvgUrl();
optionsOpen = false;
let content = await getSource({
url: svgUrlToCopy,
});
if (svgUrlToCopy) {
content = prefixSvgIds(content, getPrefixFromSvgUrl(svgUrlToCopy));
}
if (isInFigma) {
figmaCopyToClipboard(content);
}
await clipboard(content);
const category = Array.isArray(svgInfo.category)
? svgInfo.category.sort().join(" - ")
: svgInfo.category;
if (isInFigma) {
toast.success("Ready to paste in Figma", {
description: `${svgInfo.title} - ${category}`,
});
return;
}
if (isWordmarkSvg) {
toast.success("Copied wordmark SVG to clipboard", {
description: `${svgInfo.title} - ${category}`,
});
return;
}
toast.success("Copied to clipboard", {
description: `${svgInfo.title} - ${category}`,
});
};
// Convert SVG as React component:
const convertSvgReactComponent = async (tsx: boolean) => {
const svgUrlToCopy = getSvgUrl();
optionsOpen = false;
isLoading = true;
const title = svgInfo.title.split(" ").join("");
let content = await getSource({
url: svgUrlToCopy,
});
if (svgUrlToCopy) {
content = prefixSvgIds(content, getPrefixFromSvgUrl(svgUrlToCopy));
}
const dataComponent = { code: content, typescript: tsx, name: title };
const { data, error } = await getReactCode(dataComponent);
if (error || !data) {
toast.error("Failed to fetch React component", {
description: `${error ?? ""}`,
duration: 5000,
});
isLoading = false;
return;
}
await clipboard(data);
toast.success(`Copied as React ${tsx ? "TSX" : "JSX"} component`, {
description: `${svgInfo.title} - ${svgInfo.category}`,
});
isLoading = false;
};
// Copy SVG as Vue Component:
const convertSvgVueComponent = async (ts: boolean) => {
try {
const svgUrlToCopy = getSvgUrl();
optionsOpen = false;
let content = await getSource({
url: svgUrlToCopy,
});
if (svgUrlToCopy) {
content = prefixSvgIds(content, getPrefixFromSvgUrl(svgUrlToCopy));
}
const copyCode = getVueCode({
content: content,
lang: ts ? "ts" : "js",
});
if (copyCode) {
await clipboard(copyCode);
}
const category = Array.isArray(svgInfo?.category)
? svgInfo.category.sort().join(" - ")
: svgInfo.category;
toast.success(`Copied as Vue ${ts ? "TS" : "JS"} component`, {
description: `${svgInfo?.title} - ${category}`,
});
} catch (err) {
console.error(`Error copying Vue component:`, err);
toast.error(`Failed to copy Vue component`);
}
};
// Copy SVG as Svelte Component:
const convertSvgSvelteComponent = async (ts: boolean) => {
try {
const svgUrlToCopy = getSvgUrl();
optionsOpen = false;
let content = await getSource({
url: svgUrlToCopy,
});
if (svgUrlToCopy) {
content = prefixSvgIds(content, getPrefixFromSvgUrl(svgUrlToCopy));
}
const copyCode = getSvelteCode({
content: content,
lang: ts ? "ts" : "js",
});
if (copyCode) {
await clipboard(copyCode);
}
const category = Array.isArray(svgInfo?.category)
? svgInfo.category.sort().join(" - ")
: svgInfo.category;
toast.success(`Copied as Svelte ${ts ? "TS" : "JS"} component`, {
description: `${svgInfo?.title} - ${category}`,
});
} catch (err) {
console.error(`Error copying Svelte component:`, err);
toast.error(`Failed to copy Svelte component`);
}
};
// Copy SVG as Standalone Angular component:
const convertSvgAngularComponent = async () => {
isLoading = true;
optionsOpen = false;
const title = svgInfo.title.split(" ").join("");
const svgUrlToCopy = getSvgUrl();
let content = await getSource({
url: svgUrlToCopy,
});
if (svgUrlToCopy) {
content = prefixSvgIds(content, getPrefixFromSvgUrl(svgUrlToCopy));
}
if (!content) {
toast.error("Failed to fetch the SVG content", {
duration: 5000,
});
isLoading = false;
return;
}
const angularComponent = getAngularCode({
componentName: title,
svgContent: content,
});
await clipboard(angularComponent);
toast.success(`Copied as Standalone Angular component`, {
description: `${svgInfo.title} - ${svgInfo.category}`,
});
isLoading = false;
};
// Copy SVG as Web Component:
const convertSvgWebComponent = async () => {
isLoading = true;
optionsOpen = false;
const title = svgInfo.title.split(" ").join("");
const svgUrlToCopy = getSvgUrl();
let content = await getSource({
url: svgUrlToCopy,
});
if (svgUrlToCopy) {
content = prefixSvgIds(content, getPrefixFromSvgUrl(svgUrlToCopy));
}
if (!content) {
toast.error("Failed to fetch the SVG content", {
duration: 5000,
});
isLoading = false;
return;
}
const webComponentCode = getWebComponent({
name: title,
content: content,
});
await clipboard(webComponentCode);
toast.success(`Copied as Web Component`, {
description: `${svgInfo.title} - ${svgInfo.category}`,
});
isLoading = false;
};
// Copy SVG as Astro component:
const convertSvgAstroComponent = async () => {
isLoading = true;
optionsOpen = false;
const svgUrlToCopy = getSvgUrl();
let content = await getSource({
url: svgUrlToCopy,
});
if (svgUrlToCopy) {
content = prefixSvgIds(content, getPrefixFromSvgUrl(svgUrlToCopy));
}
if (!content) {
toast.error("Failed to fetch the SVG content", {
duration: 5000,
});
isLoading = false;
return;
}
const astroComponentCode = getAstroCode({
svgContent: content,
});
await clipboard(astroComponentCode);
toast.success(`Copied as Astro Component`, {
description: `${svgInfo.title} - ${svgInfo.category}`,
});
isLoading = false;
};
</script>
<Popover.Root bind:open={optionsOpen}>
<Popover.Trigger
title="Copy SVG element as svg file, React TSX code, or React JSX code"
class="flex items-center space-x-2 rounded-md p-2 duration-100 hover:bg-neutral-200 dark:hover:bg-neutral-700/40"
>
{#if optionsOpen}
<XIcon {size} strokeWidth={iconStroke} />
{:else if isLoading}
<LoaderIcon {size} strokeWidth={iconStroke} class="animate-spin" />
{:else}
<CopyIcon {size} strokeWidth={iconStroke} />
{/if}
</Popover.Trigger>
<Popover.Content class="flex flex-col space-y-2 p-4" sideOffset={2}>
<Tabs.Root value="source" class="flex w-full flex-col space-y-1">
<Tabs.List>
<Tabs.Trigger value="source">Source</Tabs.Trigger>
<div
class="ml-3 flex flex-row space-x-0.5 border-l border-dashed border-neutral-200 pl-3 dark:border-neutral-800"
>
<Tabs.Trigger value="web-component" title="Web Component">
<WebComponents size={21} />
</Tabs.Trigger>
<Tabs.Trigger value="react" title="React">
<React size={20} />
</Tabs.Trigger>
<Tabs.Trigger value="vue" title="Vue">
<Vue size={20} />
</Tabs.Trigger>
<Tabs.Trigger value="svelte" title="Svelte">
<Svelte size={20} />
</Tabs.Trigger>
<Tabs.Trigger value="angular" title="Angular">
<Angular size={20} />
</Tabs.Trigger>
<Tabs.Trigger
value="astro"
title="Astro"
class="text-black dark:text-white"
>
<Astro size={21} />
</Tabs.Trigger>
</div>
</Tabs.List>
<!-- Source -->
<Tabs.Content value="source">
<section class="flex flex-col space-y-2">
<Button
variant="outline"
title={isWordmarkSvg
? "Copy wordmark SVG to clipboard"
: "Copy SVG to clipboard"}
onclick={() => copyToClipboard()}
>
<ClipboardIcon size={16} strokeWidth={2} />
<span>Copy SVG</span>
</Button>
</section>
</Tabs.Content>
<!-- React -->
<Tabs.Content value="react">
<section class="flex flex-col space-y-2">
<Button
variant="outline"
title="Copy as React component"
disabled={isLoading}
onclick={() => convertSvgReactComponent(true)}
>
<React size={18} />
<span>Copy TSX</span>
</Button>
<Button
variant="outline"
title="Copy as React component"
disabled={isLoading}
onclick={() => convertSvgReactComponent(false)}
>
<React size={18} />
<span>Copy JSX</span>
</Button>
</section>
</Tabs.Content>
<!-- Svelte -->
<Tabs.Content value="svelte">
<section class="flex flex-col space-y-2">
<Button
variant="outline"
title="Copy as Svelte component"
disabled={isLoading}
onclick={() => convertSvgSvelteComponent(false)}
>
<Svelte size={18} />
<span>Copy JS</span>
</Button>
<Button
variant="outline"
title="Copy as Svelte component"
disabled={isLoading}
onclick={() => convertSvgSvelteComponent(false)}
>
<Svelte size={18} />
<span>Copy JS</span>
</Button>
<Button
variant="outline"
title="Copy as Svelte component"
disabled={isLoading}
onclick={() => convertSvgSvelteComponent(true)}
>
<Svelte size={18} />
<span>Copy TS</span>
</Button>
</section>
</Tabs.Content>
<!-- Vue -->
<Tabs.Content value="vue">
<section class="flex flex-col space-y-2">
<Button
variant="outline"
title="Copy as Vue component"
disabled={isLoading}
onclick={() => convertSvgVueComponent(false)}
>
<Vue size={18} />
<span>Copy JS</span>
</Button>
<Button
variant="outline"
title="Copy as Vue component"
disabled={isLoading}
onclick={() => convertSvgVueComponent(true)}
>
<Vue size={18} />
<span>Copy TS</span>
</Button>
</section>
</Tabs.Content>
<!-- Angular -->
<Tabs.Content value="angular">
<section class="flex flex-col space-y-2">
<Button
variant="outline"
title="Copy as Standalone Component"
disabled={isLoading}
onclick={() => convertSvgAngularComponent()}
>
<Angular size={18} />
<span>Copy Standalone Component</span>
</Button>
</section>
</Tabs.Content>
<!-- Web Component -->
<Tabs.Content value="web-component">
<section class="flex flex-col space-y-2">
<Button
variant="outline"
title="Copy as Web Component"
disabled={isLoading}
onclick={() => convertSvgWebComponent()}
>
<WebComponents size={18} />
<span>Copy Web Component</span>
</Button>
</section>
</Tabs.Content>
<!-- Astro -->
<Tabs.Content value="astro">
<section class="flex flex-col space-y-2">
<Button
variant="outline"
title="Copy as Astro Component"
disabled={isLoading}
onclick={() => convertSvgAstroComponent()}
>
<Astro size={18} />
<span>Copy Astro Component</span>
</Button>
</section>
</Tabs.Content>
</Tabs.Root>
<div
class="mt-1 flex w-full items-center text-center text-[12px] text-neutral-600 dark:text-neutral-400"
>
<p>
Remember to request permission from the creators for the use of the SVG.
Modification is not allowed.
</p>
</div>
</Popover.Content>
</Popover.Root>
+282
View File
@@ -0,0 +1,282 @@
<script lang="ts">
import type { iSVG } from "@/types/svg";
import { toast } from "svelte-sonner";
import DownloadIcon from "@lucide/svelte/icons/download";
// Utils:
import { cn } from "@/utils/cn";
import { downloadAllVariants, downloadSvg } from "@/utils/downloadSvg";
// Components:
import * as Dialog from "@/components/ui/dialog";
import { Button, buttonVariants } from "@/components/ui/button";
// Props:
interface Props {
svgInfo: iSVG;
isDarkTheme: () => boolean;
}
let { svgInfo, isDarkTheme }: Props = $props();
// Shared:
let iconSize = 16;
let iconStroke = 2;
let cardDownloadStyles =
"flex w-full h-full flex-col p-4 rounded-md shadow-sm dark:bg-neutral-800/20 bg-neutral-200/10 border border-neutral-200 dark:border-neutral-800 space-y-2";
// Functions:
const handleDownloadSvg = async (url?: string) => {
const result = await downloadSvg({
url: url!,
});
const category = Array.isArray(svgInfo.category)
? svgInfo.category.sort().join(" - ")
: svgInfo.category;
if (result) {
toast.success(`Downloading...`, {
description: `${svgInfo.title} - ${category}`,
});
} else {
toast.error(`Error downloading SVG`, {
description: `${svgInfo.title} - ${category}`,
});
}
};
const handleDownloadAllVariants = async ({
lightRoute,
darkRoute,
isWordmark,
}: {
lightRoute: string;
darkRoute: string;
isWordmark?: boolean;
}) => {
const result = await downloadAllVariants({
svgInfo,
lightRoute,
darkRoute,
isWordmark,
});
const category = Array.isArray(svgInfo.category)
? svgInfo.category.sort().join(" - ")
: svgInfo.category;
if (result) {
toast.success("Downloading light & dark variants...", {
description: isWordmark
? `${svgInfo.title} - Wordmark - ${category}`
: `${svgInfo.title} - ${category}`,
});
} else {
toast.error(`Error downloading SVG`, {
description: `${svgInfo.title} - ${category}`,
});
}
};
</script>
{#if typeof svgInfo.route === "string" && svgInfo.wordmark === undefined}
<Button
title="Download Light & Dark variants"
variant="ghost"
size="icon"
onclick={() => {
if (typeof svgInfo.route === "string") {
handleDownloadSvg(svgInfo.route);
return;
}
}}
>
<DownloadIcon size={iconSize} strokeWidth={iconStroke} />
</Button>
{:else}
<Dialog.Root>
<Dialog.Trigger
title="Download SVG"
class={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
>
<DownloadIcon size={iconSize} strokeWidth={iconStroke} />
</Dialog.Trigger>
<Dialog.Content class="max-w-[630px]">
<Dialog.Header>
<Dialog.Title>Download {svgInfo.title} SVG</Dialog.Title>
<Dialog.Description>
This logo has multiple options to download:
</Dialog.Description>
</Dialog.Header>
<div
class={cn(
"flex h-full flex-col space-y-2 pt-4 pb-0.5",
"md:flex-row md:items-center md:justify-center md:space-y-0 md:space-x-2",
)}
>
{#if typeof svgInfo.route === "string"}
<div class={cardDownloadStyles}>
<img
src={isDarkTheme() ? svgInfo.route : svgInfo.route}
alt={svgInfo.title}
class="my-4 h-8"
/>
<Button
title="Download logo"
variant="outline"
onclick={() => {
if (typeof svgInfo.route === "string") {
handleDownloadSvg(svgInfo.route);
return;
}
}}
>
<DownloadIcon class="mr-2" size={iconSize} />
<p>Icon logo</p>
</Button>
</div>
{:else}
<div class={cardDownloadStyles}>
<img
src={isDarkTheme() ? svgInfo.route.dark : svgInfo.route.light}
alt={svgInfo.title}
class="my-4 h-10"
/>
<Button
title="Logo with light & dark variants"
variant="outline"
onclick={() => {
if (typeof svgInfo.route !== "string") {
handleDownloadAllVariants({
lightRoute: svgInfo.route.light,
darkRoute: svgInfo.route.dark,
});
}
}}
>
<DownloadIcon size={iconSize} />
<p>Light & dark variants</p>
</Button>
<Button
title="Download light variant"
variant="outline"
onclick={() => {
if (typeof svgInfo.route !== "string") {
handleDownloadSvg(svgInfo.route.light);
return;
}
}}
>
<DownloadIcon class="mr-2" size={iconSize} />
<p>Only light variant</p>
</Button>
<Button
title="Download dark variant"
variant="outline"
onclick={() => {
if (typeof svgInfo.route !== "string") {
handleDownloadSvg(svgInfo.route.dark);
return;
}
}}
>
<DownloadIcon class="mr-2" size={iconSize} />
<p>Only dark variant</p>
</Button>
</div>
{/if}
{#if typeof svgInfo.wordmark === "string" && svgInfo.wordmark !== undefined}
<div class={cardDownloadStyles}>
<img
src={isDarkTheme() ? svgInfo.wordmark : svgInfo.wordmark}
alt={svgInfo.title}
class="my-4 h-8"
/>
<Button
title="Download Wordmark logo"
variant="outline"
onclick={() => {
if (typeof svgInfo.wordmark === "string") {
handleDownloadSvg(svgInfo.wordmark);
return;
}
}}
>
<DownloadIcon class="mr-2" size={iconSize} />
<p>Wordmark logo</p>
</Button>
</div>
{/if}
{#if typeof svgInfo.wordmark !== "string" && svgInfo.wordmark !== undefined}
<div class={cardDownloadStyles}>
<img
src={isDarkTheme()
? svgInfo.wordmark.dark
: svgInfo.wordmark.light}
alt={svgInfo.title}
class="my-4 h-10"
/>
<Button
title="Download Wordmark light variant"
variant="outline"
onclick={() => {
if (typeof svgInfo.wordmark !== "string") {
handleDownloadAllVariants({
lightRoute: svgInfo.wordmark?.light || "",
darkRoute: svgInfo.wordmark?.dark || "",
isWordmark: true,
});
return;
}
}}
>
<DownloadIcon class="mr-2" size={iconSize} />
<p>Light & dark variants</p>
</Button>
<Button
title="Download Wordmark light variant"
variant="outline"
onclick={() => {
if (typeof svgInfo.wordmark !== "string") {
handleDownloadSvg(svgInfo.wordmark?.light);
return;
}
}}
>
<DownloadIcon class="mr-2" size={iconSize} />
<p>Wordmark light variant</p>
</Button>
<Button
title="Download Wordmark dark variant"
variant="outline"
onclick={() => {
if (typeof svgInfo.wordmark !== "string") {
handleDownloadSvg(svgInfo.wordmark?.dark);
return;
}
}}
>
<DownloadIcon class="mr-2" size={iconSize} />
<p>Wordmark dark variant</p>
</Button>
</div>
{/if}
</div>
<Dialog.Footer
class="mt-3 text-xs text-neutral-600 dark:text-neutral-400"
>
<p>
Remember to request permission from the creators for the use of the
SVG. Modification is not allowed.
</p>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{/if}
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { cn } from "@/utils/cn";
let { className, children }: { className?: string; children?: Snippet } =
$props();
</script>
<div
class={cn(
"grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5",
className,
)}
>
{@render children?.()}
</div>
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
import { cn } from "@/utils/cn";
import { globals } from "@/globals";
import { mode } from "mode-watcher";
import ModeToggle from "@/components/modeToggle.svelte";
import Svgl from "@/components/logos/svgl.svelte";
import Github from "@/components/logos/github.svelte";
import Twitter from "@/components/logos/twitter.svelte";
import Badge from "@/components/ui/badge/badge.svelte";
import { buttonVariants } from "@/components/ui/button";
import SendIcon from "@/components/ui/moving-icons/send-icon.svelte";
const headerItemsClasses = cn(
buttonVariants({ variant: "ghost" }),
"hover:bg-neutral-200 dark:hover:bg-neutral-800",
);
</script>
<header
class="sticky top-0 w-full bg-neutral-100 px-4 py-4 dark:bg-neutral-950"
>
<nav class="flex w-full items-center justify-between">
<div class="flex items-center space-x-3">
<a
href="/"
class="flex items-center space-x-2.5 transition-colors hover:text-neutral-700 dark:hover:text-neutral-300"
>
<Svgl size={28} />
<h2 class="font-onest text-xl font-medium tracking-tight">svgl</h2>
</a>
<Badge variant="outline">{globals.currentVersion}</Badge>
</div>
<div class="flex items-center">
<div
class="mr-4 flex items-center space-x-0.5 border-r border-neutral-300 pr-3 dark:border-neutral-800"
>
<a
target="_blank"
title="X/Twitter"
href={globals.twitterUrl}
class={cn(headerItemsClasses, "h-9 w-9")}
>
<Twitter size={18} />
</a>
<a
target="_blank"
title="GitHub Repository"
href={globals.githubUrl}
class={cn(headerItemsClasses, "h-9 w-9")}
>
<Github size={20} />
</a>
<ModeToggle className={cn(headerItemsClasses, "h-9 w-9")} />
</div>
<a
target="_blank"
href={globals.submitUrl}
class={cn(
buttonVariants({
variant: mode.current === "dark" ? "default" : "radial",
}),
)}
>
<SendIcon size={14} />
<span>Submit</span>
</a>
</div>
</nav>
</header>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import SunIcon from "@lucide/svelte/icons/sun";
import MoonIcon from "@lucide/svelte/icons/moon";
import { toggleMode } from "mode-watcher";
interface Props {
className?: string;
}
let { className }: Props = $props();
</script>
<button class={className} onclick={toggleMode} title="Mode Toggle">
<SunIcon
size={20}
strokeWidth={1.5}
class="scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90"
/>
<MoonIcon
size={20}
strokeWidth={1.5}
class="absolute scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0"
/>
<span class="sr-only">Toggle theme</span>
</button>
+88
View File
@@ -0,0 +1,88 @@
<script lang="ts">
import { cn } from "@/utils/cn";
import { onMount } from "svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import SearchIcon from "@lucide/svelte/icons/search";
import CommandIcon from "@lucide/svelte/icons/command";
import { SvelteURLSearchParams } from "svelte/reactivity";
interface Props {
searchValue: string;
onSearch: (value: string) => void;
placeholder?: string;
}
let { searchValue, onSearch, placeholder }: Props = $props();
let inputElement: HTMLInputElement;
const onInput = (event: Event) => {
const param = "search";
const value = (event.target as HTMLInputElement).value;
onSearch(value);
const params = new SvelteURLSearchParams(page.url.searchParams);
if (value) {
params.set(param, value);
} else {
params.delete(param);
}
goto(`?${params.toString()}`, {
keepFocus: true,
noScroll: true,
replaceState: true,
});
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.key === "k") {
event.preventDefault();
inputElement?.focus();
}
};
onMount(() => {
window.addEventListener("keydown", handleKeydown);
return () => {
window.removeEventListener("keydown", handleKeydown);
};
});
</script>
<div class="relative">
<SearchIcon
size={20}
strokeWidth={2}
class={cn(
"pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 transition-colors",
searchValue
? "text-black dark:text-white"
: "text-neutral-400 dark:text-neutral-600",
)}
/>
<input
bind:this={inputElement}
type="search"
autocomplete="off"
placeholder={placeholder || "Search..."}
oninput={onInput}
value={searchValue}
class={cn(
"overflow-hidden shadow-sm",
"w-full py-1.5 pr-3 pl-10",
"text-lg placeholder:text-neutral-400 dark:placeholder:text-neutral-400",
"bg-white dark:bg-neutral-900",
"rounded-md border border-neutral-200 dark:border-neutral-800",
"focus:border-neutral-400 focus:outline-none dark:focus:border-neutral-600",
)}
/>
{#if !searchValue}
<div
class="absolute top-1/2 right-2 flex -translate-y-1/2 items-center space-x-1.5 rounded-md p-1 text-sm text-neutral-400 transition-colors hover:text-neutral-600"
>
<CommandIcon size={16} strokeWidth={1.5} />
<span class="select-none">K</span>
</div>
{/if}
</div>
+213
View File
@@ -0,0 +1,213 @@
<script lang="ts">
import type { iSVG } from "@/types/svg";
import { cn } from "@/utils/cn";
// Icons:
import XIcon from "@lucide/svelte/icons/x";
import TagIcon from "@lucide/svelte/icons/tag";
import LinkIcon from "@lucide/svelte/icons/link";
import SparklesIcon from "@lucide/svelte/icons/sparkles";
import BaselineIcon from "@lucide/svelte/icons/baseline";
import PaletteIcon from "@lucide/svelte/icons/palette";
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
// UI Components:
import * as Popover from "@/components/ui/popover";
import { badgeVariants } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
// Components:
import CopySvg from "@/components/copySvg.svelte";
import DownloadSvg from "@/components/downloadSvg.svelte";
// Props:
interface Props {
svgInfo: iSVG;
}
let { svgInfo }: Props = $props();
// States:
let wordmarkSvg = $state<boolean>(false);
let moreTagsOptions = $state<boolean>(false);
// Icon Stroke & Size:
let iconStroke = 1.8;
let iconSize = 16;
let maxVisibleCategories = 1;
// Global Styles:
const globalImageStyles = "mb-4 mt-2 h-10 select-none pointer-events-none";
</script>
<div
class={cn(
"group flex flex-col items-center justify-center p-4",
"rounded-md border border-neutral-200 dark:border-neutral-800",
"transition-colors duration-100 hover:bg-neutral-100/80 dark:hover:bg-neutral-800/20",
)}
>
<!-- Image -->
{#if wordmarkSvg == true && svgInfo.wordmark !== undefined}
<img
class={cn("hidden dark:block", globalImageStyles)}
src={typeof svgInfo.wordmark !== "string"
? svgInfo.wordmark?.dark || ""
: svgInfo.wordmark || ""}
alt={svgInfo.title}
title={svgInfo.title}
loading="lazy"
/>
<img
class={cn("block dark:hidden", globalImageStyles)}
src={typeof svgInfo.wordmark !== "string"
? svgInfo.wordmark?.light || ""
: svgInfo.wordmark || ""}
alt={svgInfo.title}
title={svgInfo.title}
loading="lazy"
/>
{:else}
<img
class={cn("hidden dark:block", globalImageStyles)}
src={typeof svgInfo.route !== "string"
? svgInfo.route.dark
: svgInfo.route}
alt={svgInfo.title}
title={svgInfo.title}
loading="lazy"
/>
<img
class={cn("block dark:hidden", globalImageStyles)}
src={typeof svgInfo.route !== "string"
? svgInfo.route.light
: svgInfo.route}
alt={svgInfo.title}
title={svgInfo.title}
loading="lazy"
/>
{/if}
<!-- Title -->
<div class="mb-3 flex flex-col items-center justify-center space-y-1">
<p
class="truncate text-center text-[15px] font-medium text-balance select-all"
>
{svgInfo.title}
</p>
<div class="flex items-center justify-center space-x-1">
{#if Array.isArray(svgInfo.category)}
{#each svgInfo.category.slice(0, maxVisibleCategories) as c, index}
<a
href={`/directory/${c.toLowerCase()}`}
class={badgeVariants({ variant: "outline" })}
title={`This icon is part of the ${svgInfo.category} category`}
>
{c}
</a>
{/each}
{#if svgInfo.category.length > maxVisibleCategories}
<Popover.Root
open={moreTagsOptions}
onOpenChange={(isOpen) => (moreTagsOptions = isOpen)}
>
<Popover.Trigger
class={badgeVariants({ variant: "outline" })}
title="More Tags"
>
{#if moreTagsOptions}
<XIcon size={15} strokeWidth={1.5} />
{:else}
<EllipsisIcon size={15} strokeWidth={1.5} />
{/if}
</Popover.Trigger>
<Popover.Content class="flex flex-col space-y-2">
<p class="font-medium">More tags:</p>
{#each svgInfo.category.slice(maxVisibleCategories) as c}
<a
href={`/directory/${c.toLowerCase()}`}
class={cn(buttonVariants({ variant: "outline" }), "w-full")}
>
<TagIcon size={15} strokeWidth={1.5} />
<span>{c}</span>
</a>
{/each}
</Popover.Content>
</Popover.Root>
{/if}
{:else}
<a
href={`/directory/${svgInfo.category.toLowerCase()}`}
class={badgeVariants({ variant: "outline" })}
>
{svgInfo.category}
</a>
{/if}
</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-1">
{#if wordmarkSvg && svgInfo.wordmark !== undefined}
<CopySvg
size={iconSize}
{iconStroke}
{svgInfo}
isInFigma={false}
isWordmarkSvg={true}
/>
{:else}
<CopySvg
size={iconSize}
{iconStroke}
{svgInfo}
isInFigma={false}
isWordmarkSvg={false}
/>
{/if}
<DownloadSvg
{svgInfo}
isDarkTheme={() => {
const dark = document.documentElement.classList.contains("dark");
return dark;
}}
/>
<a
href={svgInfo.url}
title="Website"
target="_blank"
rel="noopener noreferrer"
class={buttonVariants({ variant: "ghost" })}
>
<LinkIcon size={iconSize} strokeWidth={iconStroke} />
</a>
{#if svgInfo.wordmark !== undefined}
<Button
title={wordmarkSvg ? "Show logo SVG" : "Show wordmark SVG"}
onclick={() => {
wordmarkSvg = !wordmarkSvg;
}}
variant="ghost"
size="icon"
>
{#if wordmarkSvg}
<SparklesIcon size={iconSize} strokeWidth={iconStroke} />
{:else}
<BaselineIcon size={iconSize} strokeWidth={iconStroke} />
{/if}
</Button>
{/if}
{#if svgInfo.brandUrl !== undefined}
<a
href={svgInfo.brandUrl}
title="Brand Assets"
target="_blank"
rel="noopener noreferrer"
class={buttonVariants({ variant: "ghost" })}
>
<PaletteIcon size={iconSize} strokeWidth={iconStroke} />
</a>
{/if}
</div>
</div>