mirror of
https://github.com/pheralb/svgl.git
synced 2025-12-29 08:01:36 +08:00
🎨 Add new UI components: Container, Grid, Header, ModeToggle, Search, SvgCard & CopySvg, DownloadSvg with improved functionality and styling
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user