Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add drag and drop && improve UI a tad #35

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1"
}
}
}
}
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 63 additions & 0 deletions src/app/(tools)/svg-to-png/components/animated-scale-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import { useEffect, useRef } from "react";
import type { Scale } from "../svg-tool";

interface AnimatedScaleSelectorProps {
scales: Scale[];
selectedScale: Scale;
onChange: (scale: Scale) => void;
}

export function AnimatedScaleSelector({
scales,
selectedScale,
onChange,
}: AnimatedScaleSelectorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLButtonElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (selectedRef.current && highlightRef.current && containerRef.current) {
const container = containerRef.current;
const selected = selectedRef.current;
const highlight = highlightRef.current;

const containerRect = container.getBoundingClientRect();
const selectedRect = selected.getBoundingClientRect();

highlight.style.left = `${selectedRect.left - containerRect.left}px`;
highlight.style.width = `${selectedRect.width}px`;
}
}, [selectedScale]);

return (
<div className="flex flex-col items-center gap-2">
<span className="text-sm text-white/60">Scale Factor</span>
<div
ref={containerRef}
className="relative inline-flex rounded-lg bg-white/5 p-1"
>
<div
ref={highlightRef}
className="absolute top-1 h-[calc(100%-8px)] rounded-md bg-blue-600 transition-all duration-200"
/>
{scales.map((value) => (
<button
key={value}
ref={value === selectedScale ? selectedRef : null}
onClick={() => onChange(value)}
className={`relative rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
value === selectedScale
? "text-white"
: "text-white/80 hover:text-white"
}`}
>
{value}×
</button>
))}
</div>
</div>
);
}
81 changes: 81 additions & 0 deletions src/app/(tools)/svg-to-png/file-dropzone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";

import React, { useCallback, useState, useRef } from "react";

interface FileDropzoneProps {
children: React.ReactNode;
}

export const FileDropzone: React.FC<FileDropzoneProps> = ({ children }) => {
const [isDragging, setIsDragging] = useState(false);
const dragCounter = useRef(0);

const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);

const handleDragIn = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current += 1;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
}, []);

const handleDragOut = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current -= 1;
if (dragCounter.current === 0) {
setIsDragging(false);
}
}, []);

const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounter.current = 0;

const files = e.dataTransfer.files;
if (files && files.length > 0) {
const droppedFile = files[0];
if (
droppedFile &&
(droppedFile.type === "image/svg+xml" ||
droppedFile.name.toLowerCase().endsWith(".svg"))
) {
const fileInput = document.querySelector('input[type="file"]')!;
if (fileInput) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(droppedFile);

(fileInput as HTMLInputElement).files = dataTransfer.files;
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
}
}
}
}, []);

return (
<div
onDragEnter={handleDragIn}
onDragLeave={handleDragOut}
onDragOver={handleDrag}
onDrop={handleDrop}
className="h-full w-full"
>
{isDragging && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
<div className="animate-in fade-in zoom-in relative flex h-[90%] w-[90%] transform items-center justify-center rounded-xl border-2 border-dashed border-white/30 transition-all duration-200 ease-out">
<p className="text-2xl font-semibold text-white">Drop SVG file</p>
</div>
</div>
)}
{children}
</div>
);
};
7 changes: 6 additions & 1 deletion src/app/(tools)/svg-to-png/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { SVGTool } from "./svg-tool";
import { FileDropzone } from "./file-dropzone";

export const metadata = {
title: "SVG to PNG converter - QuickPic",
description: "Convert SVGs to PNGs. Also makes them bigger.",
};

export default function SVGToolPage() {
return <SVGTool />;
return (
<FileDropzone>
<SVGTool />
</FileDropzone>
);
}
96 changes: 61 additions & 35 deletions src/app/(tools)/svg-to-png/svg-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { useLocalStorage } from "@/hooks/use-local-storage";

import { type ChangeEvent } from "react";

type Scale = 1 | 2 | 4 | 8 | 16 | 32 | 64;
import { AnimatedScaleSelector } from "./components/animated-scale-selector";

export type Scale = 1 | 2 | 4 | 8 | 16 | 32 | 64;

function scaleSvg(svgContent: string, scale: Scale) {
const parser = new DOMParser();
Expand Down Expand Up @@ -180,11 +182,26 @@ export function SVGTool() {

if (!imageMetadata)
return (
<div className="flex flex-col gap-4 p-4">
<p className="text-center">
<div className="flex flex-col items-center justify-center gap-4 p-4">
<p className="text-center text-white">
Make SVGs into PNGs. Also makes them bigger. (100% free btw.)
</p>
<div className="flex justify-center">
<div className="flex w-72 flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed border-white/30 bg-white/10 p-6 backdrop-blur-sm">
<svg
className="h-8 w-8 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm text-gray-400">Drag and Drop</p>
<p className="text-sm text-gray-500">or</p>
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75">
<span>Upload SVG</span>
<input
Expand All @@ -199,43 +216,52 @@ export function SVGTool() {
);

return (
<div className="flex flex-col items-center justify-center gap-4 p-4 text-2xl">
<SVGRenderer svgContent={svgContent} />
<p>{imageMetadata.name}</p>
<p>
Original size: {imageMetadata.width}px x {imageMetadata.height}px
</p>
<p>
Scaled size: {imageMetadata.width * scale}px x{" "}
{imageMetadata.height * scale}px
</p>
<div className="flex gap-2">
{([1, 2, 4, 8, 16, 32, 64] as Scale[]).map((value) => (
<button
key={value}
onClick={() => setScale(value)}
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
scale === value
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-800 hover:bg-gray-300"
}`}
>
{value}x
</button>
))}
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center gap-6 p-6">
{/* Preview Section */}
<div className="flex w-full flex-col items-center gap-4 rounded-xl p-6">
<SVGRenderer svgContent={svgContent} />
<p className="text-lg font-medium text-white/80">
{imageMetadata.name}
</p>
</div>
<div className="flex gap-2">
<SaveAsPngButton
svgContent={svgContent}
scale={scale}
imageMetadata={imageMetadata}
/>

{/* Size Information */}
<div className="flex gap-6 text-base">
<div className="flex flex-col items-center rounded-lg bg-white/5 p-3">
<span className="text-sm text-white/60">Original</span>
<span className="font-medium text-white">
{imageMetadata.width} × {imageMetadata.height}
</span>
</div>

<div className="flex flex-col items-center rounded-lg bg-white/5 p-3">
<span className="text-sm text-white/60">Scaled</span>
<span className="font-medium text-white">
{imageMetadata.width * scale} × {imageMetadata.height * scale}
</span>
</div>
</div>

{/* Scale Controls */}
<AnimatedScaleSelector
scales={[1, 2, 4, 8, 16, 32, 64]}
selectedScale={scale}
onChange={setScale}
/>

{/* Action Buttons */}
<div className="flex gap-3">
<button
onClick={cancel}
className="rounded-md bg-red-700 px-3 py-1 text-sm font-medium text-white transition-colors hover:bg-red-800"
className="rounded-lg bg-red-700 px-4 py-2 text-sm font-medium text-white/90 transition-colors hover:bg-red-800"
>
Cancel
</button>
<SaveAsPngButton
svgContent={svgContent}
scale={scale}
imageMetadata={imageMetadata}
/>
</div>
</div>
);
Expand Down
Loading