├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── TODO.md ├── app ├── components │ ├── Configuration │ │ ├── Configuration.tsx │ │ ├── defaultIcons.ts │ │ └── index.ts │ ├── Folder.tsx │ ├── FolderEditor.tsx │ ├── GridBackground.tsx │ ├── HowToUse.tsx │ └── MobileUnderConstruction.tsx ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components └── Button.tsx ├── consts └── index.ts ├── hooks ├── index.ts └── useUpdatePreview.ts ├── icons ├── icons8-brightness.svg └── index.tsx ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── fallback-folder.png ├── icons │ ├── apple.svg │ ├── figma.svg │ ├── git.svg │ ├── github.svg │ ├── js.svg │ ├── react.svg │ ├── tailwind.svg │ ├── ts.svg │ ├── vercel.svg │ └── vscode.svg └── resources │ └── folders │ ├── dark │ └── icon_512x512@2x.png │ └── light │ └── icon_512x512@2x.png ├── tailwind.config.ts ├── tests └── format-icon.test.js ├── tsconfig.json └── utils ├── icons ├── client.ts ├── common.ts ├── consts.ts ├── create-constraints.ts ├── create-icns.ts ├── format-icon.ts ├── index.ts └── types.ts ├── load-image.ts └── sh.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # previews and results 39 | /previews 40 | /results -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "embeddedLanguageFormatting": "auto", 6 | "htmlWhitespaceSensitivity": "css", 7 | "insertPragma": false, 8 | "jsxSingleQuote": true, 9 | "printWidth": 100, 10 | "proseWrap": "always", 11 | "quoteProps": "as-needed", 12 | "requirePragma": false, 13 | "semi": false, 14 | "singleQuote": true, 15 | "tabWidth": 3, 16 | "trailingComma": "es5" 17 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Folder Studio

2 | 3 | ![CleanShot 2024-03-14 at 1  59 56](https://github.com/sameerasw/folderart/assets/68902530/c8cda18c-866e-492e-986c-3a71623aef1b) 4 | 5 | 6 |
7 | 8 |

9 | Create custom folder icons for Windows. 10 |
11 | Live Demo » 12 |
13 |
14 |

15 | 16 | ![Next.js Badge](https://img.shields.io/badge/Next.js-000000?logo=next.js&logoColor=fff&style=flat) 17 | ![Tailwind CSS Badge](https://img.shields.io/badge/Tailwind%20CSS-06B6D4?logo=tailwindcss&logoColor=fff&style=flat) 18 |
19 | 20 |
21 | 22 | ### Installation 23 | 24 | 1. Clone the repo 25 | 26 | ```sh 27 | git clone https://github.com/sameerasw/folderart.git 28 | ``` 29 | 30 | 2. Install NPM packages 31 | 32 | ```sh 33 | npm install 34 | ``` 35 | 36 | 3. Start Next.js server 37 | ```sh 38 | npm run dev 39 | ``` 40 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - [ ] Create abstraction to reutilize client and server code 2 | - [ ] Handle errors in icon utils 3 | - [ ] Allow user to change filename 4 | -------------------------------------------------------------------------------- /app/components/Configuration/Configuration.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import { Button } from '@/components/Button' 3 | import { OnChangeConfig } from '@/app/components/FolderEditor' 4 | import { DownloadIcon, FolderIcon } from '@/icons' 5 | import { Config } from '@/utils/icons' 6 | import { useRef } from 'react' 7 | import { devIcons } from './defaultIcons' 8 | import { updateIconColor } from '@/utils/icons/consts' 9 | 10 | let pageTheme = 'dark' 11 | 12 | export function Configuration({ 13 | configuration, 14 | onChangeConfig, 15 | downloadFile, 16 | }: { 17 | configuration: Config 18 | onChangeConfig: OnChangeConfig 19 | downloadFile: () => void 20 | }) { 21 | const inputRef = useRef(null) 22 | 23 | function openFileExporer() { 24 | if (inputRef.current) { 25 | inputRef.current.click() 26 | } 27 | } 28 | 29 | function changePageTheme() { 30 | if (pageTheme !== 'dark') { 31 | // select body and change the theme 32 | document.querySelector('main')!.classList.add('dark') 33 | pageTheme = 'dark' 34 | } else { 35 | document.querySelector('main')!.classList.remove('dark') 36 | pageTheme = 'light' 37 | } 38 | } 39 | 40 | function colorChange(e: React.ChangeEvent) { 41 | // separate the color into rgb 42 | const color = e.target.value 43 | const red = parseInt(color.slice(1, 3), 16) 44 | const green = parseInt(color.slice(3, 5), 16) 45 | const blue = parseInt(color.slice(5, 7), 16) 46 | 47 | // console.log(red, green, blue) 48 | 49 | // update the IconColor 50 | updateIconColor(red, green, blue) 51 | refresh() 52 | 53 | // update the configuration color 54 | onChangeConfig('color', color) 55 | } 56 | 57 | function refresh () { 58 | // update the icon by re-generating the preview 59 | onChangeConfig('icon', configuration.icon) 60 | } 61 | 62 | return ( 63 | 151 | ) 152 | } 153 | -------------------------------------------------------------------------------- /app/components/Configuration/defaultIcons.ts: -------------------------------------------------------------------------------- 1 | import AppleIcon from '@/public/icons/apple.svg' 2 | import FigmaIcon from '@/public/icons/figma.svg' 3 | import GithubIcon from '@/public/icons/github.svg' 4 | import ReactIcon from '@/public/icons/react.svg' 5 | import VercelIcon from '@/public/icons/vercel.svg' 6 | import TailwindIcon from '@/public/icons/tailwind.svg' 7 | import JSIcon from '@/public/icons/js.svg' 8 | import TSIcon from '@/public/icons/ts.svg' 9 | import GitIcon from '@/public/icons/git.svg' 10 | import VsCodeIcon from '@/public/icons/vscode.svg' 11 | 12 | export const devIcons = [ 13 | { 14 | name: 'github', 15 | src: GithubIcon, 16 | }, 17 | 18 | { 19 | name: 'vercel', 20 | src: VercelIcon, 21 | }, 22 | { 23 | name: 'apple', 24 | src: AppleIcon, 25 | }, 26 | { 27 | name: 'react', 28 | src: ReactIcon, 29 | }, 30 | { 31 | name: 'figma', 32 | src: FigmaIcon, 33 | }, 34 | { 35 | name: 'tailwind', 36 | src: TailwindIcon, 37 | }, 38 | { 39 | name: 'js', 40 | src: JSIcon, 41 | }, 42 | { 43 | name: 'ts', 44 | src: TSIcon, 45 | }, 46 | { 47 | name: 'git', 48 | src: GitIcon, 49 | }, 50 | { 51 | name: 'vscode', 52 | src: VsCodeIcon, 53 | }, 54 | ] 55 | -------------------------------------------------------------------------------- /app/components/Configuration/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Configuration' -------------------------------------------------------------------------------- /app/components/Folder.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import Image from 'next/image' 3 | import fallbackFolder from '../../public/fallback-folder.png' 4 | import { RefObject } from 'react' 5 | 6 | export function Folder({ 7 | loading, 8 | canvasRef, 9 | }: { 10 | loading: boolean 11 | canvasRef: RefObject 12 | }) { 13 | return ( 14 | <> 15 | 16 | 17 | {loading && ( 18 | macOS folder icon 25 | )} 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /app/components/FolderEditor.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { Folder } from '@/app/components/Folder' 3 | import { useState } from 'react' 4 | import { Configuration } from '@/app/components/Configuration' 5 | import { type Config } from '@/utils/icons' 6 | import { useUpdatePreview } from '@/hooks' 7 | import { HowToUse } from '@/app/components/HowToUse' 8 | 9 | export type OnChangeConfig = (key: T, value: Config[T]) => void 10 | 11 | export function FolderEditor() { 12 | const [filename] = useState('icon') 13 | const [configuration, setConfiguration] = useState({ 14 | color: '000,000,000', 15 | theme: 'dark', 16 | adjustColor: 1, 17 | icon: 'github', 18 | }) 19 | const [canvasRef, loading] = useUpdatePreview(configuration) 20 | 21 | const onChangeConfig: OnChangeConfig = async (key, value) => { 22 | const config = { ...configuration, [key]: value } 23 | setConfiguration(config) 24 | } 25 | 26 | async function onDownload() { 27 | if (!configuration.icon || !canvasRef.current) return 28 | 29 | const image = canvasRef.current.toDataURL() 30 | 31 | const link = document.createElement('a') 32 | link.setAttribute('download', `${filename}.png`) 33 | link.setAttribute('href', image) 34 | link.click() 35 | } 36 | 37 | return ( 38 |
39 | 44 | 45 |
46 |

47 | Folder Studio / {filename}.png 48 |

49 | 50 |
51 | 52 |
53 | 54 | 55 |
56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /app/components/GridBackground.tsx: -------------------------------------------------------------------------------- 1 | export function GridBackground(props: { className?: string }) { 2 | return ( 3 |
4 | 12 | 20 | 28 | 36 | 44 | 52 | 60 | 68 | 76 | 84 | 92 | 100 | 108 | 116 | 124 | 132 | 140 | 148 | 156 | 164 | 172 | 180 | 189 | 198 | 209 | 218 | 227 | 236 | 245 | 254 | 263 | 274 | 283 | 292 | 301 | 310 | 319 | 328 | 337 | 346 | 355 | 364 | 373 | 382 | 391 | 400 | 409 | 418 | 427 | 436 | 445 | 454 | 463 | 472 | 481 | 490 | 499 | 508 | 517 | 528 | 537 | 546 | 555 | 564 | 573 | 582 | 591 | 600 | 609 | 618 | 627 | 636 | 645 | 654 | 663 | 672 | 681 | 690 | 699 | 708 | 717 | 726 | 735 | 744 | 753 | 762 | 771 | 780 | 791 | 800 | 809 | 818 | 827 | 836 | 845 | 854 | 863 | 874 | 883 | 892 | 901 | 910 | 919 | 928 | 937 | 946 | 955 | 964 | 973 | 982 | 991 | 1000 | 1009 | 1018 | 1027 | 1036 | 1045 | 1054 | 1063 | 1072 | 1081 | 1090 | 1099 | 1108 | 1119 | 1128 | 1137 | 1146 | 1155 | 1164 | 1173 | 1182 | 1191 | 1200 | 1209 | 1218 | 1227 | 1236 | 1245 | 1254 | 1263 | 1272 | 1281 | 1290 | 1299 | 1308 | 1317 | 1326 | 1335 | 1344 | 1353 | 1362 | 1371 | 1380 | 1389 | 1398 | 1409 | 1418 | 1427 | 1436 | 1445 | 1454 | 1463 | 1472 | 1481 | 1490 | 1499 | 1508 | 1517 | 1526 | 1535 | 1544 | 1553 | 1564 | 1573 | 1582 | 1591 | 1600 | 1609 | 1618 | 1627 | 1636 | 1645 | 1654 | 1663 | 1672 | 1681 | 1690 | 1699 | 1708 | 1717 | 1726 | 1735 | 1744 | 1753 | 1762 | 1771 | 1780 | 1789 | 1798 | 1807 | 1816 | 1825 | 1834 | 1843 | 1852 | 1861 | 1870 | 1879 | 1888 | 1897 | 1906 | 1915 | 1924 | 1933 | 1942 | 1951 | 1960 | 1969 | 1978 | 1987 | 1996 | 2005 | 2014 | 2023 | 2032 | 2041 | 2050 | 2059 | 2068 | 2077 | 2086 | 2095 | 2104 | 2113 | 2122 | 2131 | 2140 | 2149 | 2158 | 2167 | 2176 | 2185 | 2194 | 2203 | 2212 | 2221 | 2230 | 2239 | 2248 | 2257 | 2266 | 2275 | 2284 | 2293 | 2302 | 2311 | 2320 | 2329 | 2338 | 2347 | 2356 | 2365 | 2374 | 2383 | 2392 | 2401 | 2410 | 2419 | 2428 | 2437 | 2446 | 2455 | 2464 | 2465 | 2471 | 2472 | 2473 | 2482 | 2483 | 2489 | 2493 | 2494 | 2495 | 2496 |
2497 | ) 2498 | } 2499 | -------------------------------------------------------------------------------- /app/components/HowToUse.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { QuestionMarkIcon } from '@/icons' 3 | import { useState } from 'react' 4 | 5 | export function HowToUse() { 6 | const [open, setOpen] = useState(false) 7 | 8 | return ( 9 | <> 10 | 16 | 17 |
{ 24 | if (e.target === e.currentTarget) { 25 | setOpen(false) 26 | } 27 | }} 28 | > 29 |
{ 31 | e.stopPropagation() 32 | }} 33 | className={ 34 | 'px-6 py-4 bg-white border border-zinc-300 rounded-2xl pointer-events-auto w-full max-w-lg max-h-[80vh] overflow-y-auto min-h-96 transition-transform duration-200 ' + 35 | (open ? 'scale-100' : 'scale-50') 36 | } 37 | > 38 |
39 |
    40 |
  1. 41 | Built by @sameerasw forking from @christianvm. 42 |
  2. 43 | 44 |
45 |
46 |
47 |
48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /app/components/MobileUnderConstruction.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import fallbackFolder from '../../public/fallback-folder.png' 3 | 4 | export function MobileUnderConstruction() { 5 | return ( 6 |
7 | macOS folder icon 8 | 9 |
10 |

FolderArt mobile coming soon

11 | 12 |

In the meantime, please use a Desktop Browser.

13 | 14 |

15 | Stay tuned for updates at the{' '} 16 | 22 | official repository. 23 | 24 |

25 |
26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sameerasw/folder-studio/285823e2cc5c09024454a92a9a246cda501e554b/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .loader { 6 | width: 60px; 7 | aspect-ratio: 4; 8 | background: radial-gradient(circle closest-side, #000 90%, #0000) 0 / 9 | calc(100% / 3) 100% space; 10 | clip-path: inset(0 100% 0 0); 11 | animation: l1 1s steps(4) infinite; 12 | } 13 | @keyframes l1 { 14 | to { 15 | clip-path: inset(0 -34% 0 0); 16 | } 17 | } 18 | 19 | main{ 20 | transition: background-color 1s, color 1s; 21 | } 22 | 23 | .dark{ 24 | background-color: #363636; 25 | color: #fff; 26 | } 27 | 28 | .editor{ 29 | background-color: #fff; 30 | color: #000; 31 | } 32 | 33 | select{ 34 | background-color: #fff; 35 | color: #000; 36 | } 37 | 38 | .editor{ 39 | box-shadow: 0 0 20px 0 #00000066; 40 | left: 0; 41 | animation: slideInFromLeft 1s cubic-bezier(0.175, 0.485, 0.32, 1.275) 42 | } 43 | 44 | /* create custom animation to make the .editor slide in from left side */ 45 | @keyframes slideInFromLeft { 46 | 0% { 47 | transform: translateX(-100%); 48 | opacity: 0; 49 | } 50 | 100% { 51 | transform: translateX(0); 52 | opacity: 1; 53 | } 54 | } 55 | 56 | @keyframes scaleIn { 57 | 0% { 58 | scale: 0; 59 | opacity: 0; 60 | } 61 | 100% { 62 | scale: 1; 63 | opacity: 1; 64 | } 65 | } 66 | 67 | .icon-preview{ 68 | /* shadow of the png */ 69 | filter: drop-shadow(0 0 20px #00000033); 70 | animation: scaleIn 1s cubic-bezier(0.175, 0.485, 0.32, 1.275); 71 | transition: color 1s; 72 | } 73 | 74 | Image{ 75 | transition: all 1s ease-in-out; 76 | } 77 | 78 | ol a{ 79 | color: #099; 80 | text-decoration: underline; 81 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Analytics } from '@vercel/analytics/react' 3 | import { GeistSans } from 'geist/font/sans' 4 | import { GeistMono } from 'geist/font/mono' 5 | import './globals.css' 6 | 7 | export const metadata: Metadata = { 8 | title: 'Folder Icon Maker - Folder Studio by @sameerasw', 9 | description: 'Create custom icons for files or folders for Windows', 10 | metadataBase: new URL('https://folder-studio.netlify.app'), 11 | } 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: Readonly<{ 16 | children: React.ReactNode 17 | }>) { 18 | return ( 19 | 20 | 21 | {children} 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { FolderEditor } from '@/app/components/FolderEditor' 2 | import { MobileUnderConstruction } from '@/app/components/MobileUnderConstruction' 3 | 4 | export default function Home() { 5 | return ( 6 |
7 | 8 | 9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | type ButtonVariant = 'contained' | 'outlined' 2 | type ColorScheme = 'black' 3 | 4 | type ButtonProps = React.ComponentPropsWithoutRef<'button'> & { 5 | variant?: ButtonVariant 6 | colorScheme?: ColorScheme 7 | } 8 | 9 | export function Button({ 10 | className = '', 11 | colorScheme = 'black', 12 | variant = 'contained', 13 | ...props 14 | }: ButtonProps) { 15 | const variantCn = variants[colorScheme][variant] 16 | 17 | return ( 18 | 24 | ) 25 | } 26 | 27 | const variants: Record> = { 28 | black: { 29 | contained: 'border-zinc-950 text-white bg-zinc-950 hover:bg-zinc-800 ', 30 | outlined: 'border-zinc-200 hover:bg-zinc-50', 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /consts/index.ts: -------------------------------------------------------------------------------- 1 | export const base = process.cwd() 2 | -------------------------------------------------------------------------------- /hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useUpdatePreview' -------------------------------------------------------------------------------- /hooks/useUpdatePreview.ts: -------------------------------------------------------------------------------- 1 | import { Config, generatePreview } from '@/utils/icons' 2 | import { useEffect, useRef, useState } from 'react' 3 | 4 | export function useUpdatePreview(config: Config) { 5 | const canvasRef = useRef(null) 6 | const [loading, setLoading] = useState(true) 7 | 8 | useEffect(() => { 9 | const canvas = canvasRef.current 10 | if (!canvas) return 11 | 12 | const ctx = canvas.getContext('2d', { willReadFrequently: true }) 13 | if (!ctx) return 14 | 15 | generatePreview(canvas, ctx, config).then(() => setLoading(false)) 16 | }, [config]) 17 | 18 | return [canvasRef, loading] as const 19 | } 20 | -------------------------------------------------------------------------------- /icons/icons8-brightness.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | interface IconProps extends React.SVGAttributes { 4 | children?: never 5 | color?: string 6 | } 7 | 8 | export const ReloadIcon = React.forwardRef( 9 | ({ color = 'currentColor', ...props }, forwardedRef) => { 10 | return ( 11 | 20 | 26 | 27 | ) 28 | } 29 | ) 30 | 31 | ReloadIcon.displayName = 'ReloadIcon' 32 | 33 | export const FolderIcon = React.forwardRef( 34 | ({ color = 'currentColor', ...props }, forwardedRef) => { 35 | return ( 36 | 49 | 50 | 51 | ) 52 | } 53 | ) 54 | FolderIcon.displayName = 'FolderIcon' 55 | 56 | export const DownloadIcon = React.forwardRef( 57 | ({ color = 'currentColor', ...props }, forwardedRef) => { 58 | return ( 59 | 73 | 74 | 75 | 76 | 77 | ) 78 | } 79 | ) 80 | 81 | DownloadIcon.displayName = 'DownloadIcon' 82 | 83 | export const QuestionMarkIcon = React.forwardRef( 84 | ({ color = 'currentColor', ...props }, forwardedRef) => { 85 | return ( 86 | 95 | 101 | 102 | ) 103 | } 104 | ) 105 | 106 | QuestionMarkIcon.displayName = 'QuestionMarkIcon' 107 | 108 | // document.documentElement.classList.add('dark') -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | serverComponentsExternalPackages: ['@napi-rs/canvas'], 5 | }, 6 | } 7 | 8 | export default nextConfig 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "folderart", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -H 192.168.8.151", 7 | "build": "next build", 8 | "start": "next start -H 192.168.8.151", 9 | "lint": "next lint && tsc", 10 | "test": "node --test --watch", 11 | "format": "prettier --ignore-path .gitignore --write \"**/*.+(js|json|ts|tsx)\"" 12 | }, 13 | "dependencies": { 14 | "@vercel/analytics": "^1.2.2", 15 | "geist": "^1.2.2", 16 | "next": "14.1.0", 17 | "react": "^18", 18 | "react-dom": "^18", 19 | "sharp": "^0.33.2" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^20", 23 | "@types/react": "^18", 24 | "@types/react-dom": "^18", 25 | "autoprefixer": "^10.0.1", 26 | "eslint": "^8", 27 | "eslint-config-next": "14.1.0", 28 | "postcss": "^8", 29 | "prettier": "^3.2.5", 30 | "tailwindcss": "^3.3.0", 31 | "typescript": "^5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/fallback-folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sameerasw/folder-studio/285823e2cc5c09024454a92a9a246cda501e554b/public/fallback-folder.png -------------------------------------------------------------------------------- /public/icons/apple.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/figma.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/git.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/js.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/tailwind.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/ts.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/vscode.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/resources/folders/dark/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sameerasw/folder-studio/285823e2cc5c09024454a92a9a246cda501e554b/public/resources/folders/dark/icon_512x512@2x.png -------------------------------------------------------------------------------- /public/resources/folders/light/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sameerasw/folder-studio/285823e2cc5c09024454a92a9a246cda501e554b/public/resources/folders/light/icon_512x512@2x.png -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | fontFamily: { 17 | sans: ['var(--font-geist-sans)'], 18 | // mono: ['var(--font-geist-mono)'], 19 | }, 20 | }, 21 | }, 22 | plugins: [], 23 | } 24 | export default config 25 | -------------------------------------------------------------------------------- /tests/format-icon.test.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sameerasw/folder-studio/285823e2cc5c09024454a92a9a246cda501e554b/tests/format-icon.test.js -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "tests/hola_test.js", "tests/format-icon.test.js"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /utils/icons/client.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, Config, Context } from './types' 2 | import { drawFolderArt, drawIcon, getFolderPath, getIconPath } from './common' 3 | import { Resolution, Size } from '@/utils/icons/consts' 4 | import { getIconDimensions, getIconPosition } from './format-icon' 5 | import { loadImage } from '@/utils/load-image' 6 | 7 | async function loadIconImg(icon: Config['icon']): Promise { 8 | const isDefaultIcon = typeof icon === 'string' 9 | 10 | if (isDefaultIcon) { 11 | return await loadImage(getIconPath(icon)) 12 | } else if (icon instanceof File) { 13 | return await loadImage(URL.createObjectURL(icon)) 14 | } else { 15 | return null 16 | } 17 | } 18 | 19 | async function createIcon( 20 | iconImg: HTMLImageElement, 21 | width: number, 22 | height: number, 23 | config: Config 24 | ): Promise { 25 | const canvas = document.createElement('canvas') 26 | const ctx = canvas.getContext('2d') 27 | canvas.width = width 28 | canvas.height = height 29 | 30 | if (!ctx) { 31 | throw new Error() 32 | } 33 | 34 | drawIcon(canvas, ctx, iconImg, width, height, config) 35 | return loadImage(canvas.toDataURL('image/png')) 36 | } 37 | 38 | export async function generatePreview(canvas: Canvas, ctx: Context, config: Config) { 39 | const resolution = Resolution.Retina512 40 | const iconImg: HTMLImageElement | null = await loadIconImg(config.icon) 41 | if (!iconImg) return 42 | 43 | const { width, height } = getIconDimensions(iconImg.width, iconImg.height, resolution) 44 | const { x, y } = getIconPosition(width, height, resolution) 45 | const icon = await createIcon(iconImg, width, height, config) 46 | const folder = await createIcon(await loadImage(getFolderPath(false)), width, height, config) 47 | const overlay = await loadImage(getFolderPath(true)) 48 | // const folder = await loadImage(getFolderPath(resolution, config.theme)) 49 | 50 | // add color to the folder before drawing the icon 51 | 52 | const size = Size[resolution] 53 | canvas.width = size 54 | canvas.height = size 55 | drawFolderArt(ctx, folder, icon, overlay, x, y, width, height, resolution) 56 | } 57 | -------------------------------------------------------------------------------- /utils/icons/common.ts: -------------------------------------------------------------------------------- 1 | import { base } from '@/consts' 2 | import { Canvas, Config, Context} from './types' 3 | import { 4 | ICON_SHADOW_COLOR, 5 | ICON_SHADOW_SIZE, 6 | IconColor, 7 | Resolution, 8 | Size, 9 | } from './consts' 10 | 11 | export function getIconPath(icon: string) { 12 | const publicPath = `/icons/${icon}.svg` 13 | const serverSide = typeof window === 'undefined' 14 | 15 | if (serverSide) { 16 | return `${base}/public/${publicPath}` 17 | } else { 18 | return publicPath 19 | } 20 | } 21 | 22 | export function getFolderPath(overlay: boolean) { 23 | let publicPath: string; 24 | if (overlay) { 25 | publicPath = `resources/folders/light/icon_512x512@2x.png` 26 | } else { 27 | publicPath = `resources/folders/dark/icon_512x512@2x.png` 28 | } 29 | const serverSide = typeof window === 'undefined' 30 | 31 | if (serverSide) { 32 | return `${base}/public/${publicPath}` 33 | } else { 34 | return publicPath 35 | } 36 | } 37 | 38 | export function drawIcon( 39 | canvas: Canvas, 40 | ctx: Context, 41 | icon: Image, 42 | width: number, 43 | height: number, 44 | config: Config 45 | ) { 46 | ctx!.drawImage(icon, 0, 0, width, height) 47 | const iconImgData = ctx!.getImageData(0, 0, canvas.width, canvas.height) 48 | const data = iconImgData.data 49 | 50 | if (config.adjustColor) { 51 | for (var i = 0; i < data.length; i += 4) { 52 | data[i] = IconColor[config.theme].red 53 | data[i + 1] = IconColor[config.theme].green 54 | data[i + 2] = IconColor[config.theme].blue 55 | 56 | if (data[i + 3] > 100) { 57 | data[i + 3] = 255 58 | } 59 | } 60 | } 61 | 62 | ctx.putImageData(iconImgData, 0, 0) 63 | } 64 | 65 | export function drawFolderArt( 66 | ctx: Context, 67 | folder: Image, 68 | icon: Image, 69 | overlay: Image, 70 | x: number, 71 | y: number, 72 | width: number, 73 | height: number, 74 | resolution: Resolution 75 | ) { 76 | const size = Size[resolution] 77 | ctx.drawImage(folder, 0, 0, size, size) 78 | ctx.drawImage(overlay, 0, 0, size, size) 79 | 80 | ctx.shadowColor = ICON_SHADOW_COLOR 81 | ctx.shadowOffsetY = ICON_SHADOW_SIZE 82 | ctx.shadowBlur = ICON_SHADOW_SIZE 83 | ctx.globalCompositeOperation = 'source-over' 84 | ctx.drawImage(icon, x, y, width, height) 85 | } 86 | -------------------------------------------------------------------------------- /utils/icons/consts.ts: -------------------------------------------------------------------------------- 1 | import { IconConstraints, Theme } from './types' 2 | 3 | export enum Resolution { 4 | NonRetina16 = 0, 5 | Retina16 = 1, 6 | NonRetina32 = 2, 7 | Retina32 = 3, 8 | NonRetina128 = 4, 9 | Retina128 = 5, 10 | NonRetina256 = 6, 11 | Retina256 = 7, 12 | NonRetina512 = 8, 13 | Retina512 = 9, 14 | } 15 | 16 | 17 | export const Size: Record = { 18 | [Resolution.NonRetina16]: 16, 19 | [Resolution.Retina16]: 32, 20 | [Resolution.NonRetina32]: 32, 21 | [Resolution.Retina32]: 64, 22 | [Resolution.NonRetina128]: 128, 23 | [Resolution.Retina128]: 256, 24 | [Resolution.NonRetina256]: 256, 25 | [Resolution.Retina256]: 512, 26 | [Resolution.NonRetina512]: 512, 27 | [Resolution.Retina512]: 1024, 28 | } 29 | 30 | export const BaseConfig: IconConstraints = { 31 | maxWidth: 768, 32 | maxHeight: 384, 33 | preferredSize: 384, 34 | folderAreaHeight: 604, 35 | startY: 258, 36 | } 37 | 38 | export let IconColor: Record< 39 | Theme, 40 | { red: number; green: number; blue: number } 41 | > = { 42 | dark: { 43 | red: 240, 44 | green: 175, 45 | blue: 25, 46 | } 47 | } 48 | 49 | function updateIconColor(red: number, green: number, blue: number) { 50 | IconColor.dark.red = red 51 | IconColor.dark.green = green 52 | IconColor.dark.blue = blue 53 | } 54 | 55 | export const ICON_SHADOW_SIZE = 8 56 | export const ICON_SHADOW_COLOR = '#44444444' 57 | 58 | export { updateIconColor } -------------------------------------------------------------------------------- /utils/icons/create-constraints.ts: -------------------------------------------------------------------------------- 1 | import type { IconConstraints } from './types' 2 | import { BaseConfig, Resolution, Size } from './consts' 3 | 4 | export function createConstraints(resolution: Resolution): IconConstraints { 5 | if (resolution === Resolution.Retina512) { 6 | return BaseConfig 7 | } 8 | 9 | const factor = Size[resolution] / Size[Resolution.Retina512] 10 | 11 | let resizedConfig = {} as IconConstraints 12 | 13 | Object.entries(BaseConfig).forEach(([key, value]) => { 14 | resizedConfig[key as keyof IconConstraints] = value * factor 15 | }) 16 | 17 | return resizedConfig 18 | } 19 | -------------------------------------------------------------------------------- /utils/icons/create-icns.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | import { sh } from '@/utils/sh' 3 | import { promises as fs } from 'fs' 4 | 5 | const base = process.cwd() 6 | 7 | export async function createIcns() { 8 | const theme = 'light' 9 | const source = `${base}/resources/folders/${theme}` 10 | const basename = `folder-icon-${theme}` 11 | const iconset = `${base}/${basename}.iconset` 12 | 13 | await fs.cp(source, iconset, { recursive: true }) 14 | await sh(`iconutil -c icns ${iconset}`) 15 | 16 | const file = await fs.readFile(`${base}/${basename}.icns`) 17 | return file.toJSON() 18 | } 19 | -------------------------------------------------------------------------------- /utils/icons/format-icon.ts: -------------------------------------------------------------------------------- 1 | import { createConstraints } from './create-constraints' 2 | import { Resolution, Size } from './consts' 3 | 4 | export function getIconPosition( 5 | width: number, 6 | height: number, 7 | resolution: Resolution 8 | ) { 9 | const config = createConstraints(resolution) 10 | return { 11 | x: Size[resolution] / 2 - width / 2, 12 | y: config.startY + config.folderAreaHeight / 2 - height / 2, 13 | } 14 | } 15 | 16 | export function getIconDimensions( 17 | originalWidth: number, 18 | originalHeight: number, 19 | resolution: Resolution 20 | ) { 21 | const config = createConstraints(resolution) 22 | const aspectRatio = originalWidth / originalHeight 23 | let iconWidth: number 24 | let iconHeight: number 25 | 26 | if (aspectRatio === 1) { 27 | iconHeight = iconWidth = config.preferredSize 28 | } else if (aspectRatio > 1) { 29 | iconWidth = config.maxWidth 30 | 31 | if ((iconHeight = iconWidth / aspectRatio) > config.maxHeight) { 32 | iconHeight = config.maxHeight 33 | iconWidth = iconHeight * aspectRatio 34 | } 35 | } else { 36 | iconHeight = config.maxHeight 37 | 38 | if ((iconWidth = iconHeight * aspectRatio) > config.maxWidth) { 39 | iconWidth = config.maxWidth 40 | iconHeight = iconWidth / aspectRatio 41 | } 42 | } 43 | 44 | if (iconWidth > config.maxWidth) { 45 | throw new Error(`WIDTH: ${iconWidth} > ${config.maxWidth}`) 46 | } 47 | 48 | if (iconHeight > config.maxHeight) { 49 | throw new Error(`HEIGHT: ${iconHeight} > ${config.maxHeight}`) 50 | } 51 | 52 | return { width: iconWidth, height: iconHeight } 53 | } 54 | -------------------------------------------------------------------------------- /utils/icons/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | export * from './client' -------------------------------------------------------------------------------- /utils/icons/types.ts: -------------------------------------------------------------------------------- 1 | export type Theme = 'dark' 2 | 3 | export type IconConstraints = { 4 | maxWidth: number 5 | maxHeight: number 6 | preferredSize: number 7 | startY: number 8 | folderAreaHeight: number 9 | } 10 | 11 | export type Config = { 12 | color: string 13 | theme: Theme 14 | adjustColor: number 15 | icon?: File | string 16 | } 17 | 18 | 19 | export type Canvas = { 20 | width: number 21 | height: number 22 | toDataURL(type?: string | undefined, quality?: any): string 23 | } 24 | 25 | export type Context = { 26 | drawImage: (image: any, x: number, y: number, w: number, h: number) => void 27 | shadowColor: string 28 | shadowOffsetY: number 29 | shadowBlur: number 30 | globalCompositeOperation: GlobalCompositeOperation 31 | getImageData: ( 32 | sx: number, 33 | sy: number, 34 | sw: number, 35 | sh: number, 36 | settings?: ImageDataSettings | undefined 37 | ) => ImageData 38 | putImageData: (imagedata: ImageData, dx: number, dy: number) => void 39 | } 40 | 41 | export type Color = { 42 | red: number 43 | green: number 44 | blue: number 45 | } 46 | -------------------------------------------------------------------------------- /utils/load-image.ts: -------------------------------------------------------------------------------- 1 | export function loadImage(src: string): Promise { 2 | return new Promise((resolve, reject) => { 3 | const img = new Image() 4 | img.src = src 5 | img.onload = () => { 6 | resolve(img) 7 | } 8 | img.onerror = (e) => { 9 | reject(e) 10 | } 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /utils/sh.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process' 2 | 3 | export async function sh( 4 | cmd: string 5 | ): Promise<{ stdout: string; stderr: string }> { 6 | return new Promise(function (resolve, reject) { 7 | exec(cmd, (err, stdout, stderr) => { 8 | if (err) { 9 | reject(err) 10 | } else { 11 | resolve({ stdout, stderr }) 12 | } 13 | }) 14 | }) 15 | } 16 | --------------------------------------------------------------------------------