├── .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 | 
4 |
5 |
6 |
7 |
8 |
9 | Create custom folder icons for Windows.
10 |
11 | Live Demo »
12 |
13 |
14 |
15 |
16 | 
17 | 
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 |
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 |
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 |
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 |
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 |
27 | )
28 | }
29 | )
30 |
31 | ReloadIcon.displayName = 'ReloadIcon'
32 |
33 | export const FolderIcon = React.forwardRef(
34 | ({ color = 'currentColor', ...props }, forwardedRef) => {
35 | return (
36 |
51 | )
52 | }
53 | )
54 | FolderIcon.displayName = 'FolderIcon'
55 |
56 | export const DownloadIcon = React.forwardRef(
57 | ({ color = 'currentColor', ...props }, forwardedRef) => {
58 | return (
59 |
77 | )
78 | }
79 | )
80 |
81 | DownloadIcon.displayName = 'DownloadIcon'
82 |
83 | export const QuestionMarkIcon = React.forwardRef(
84 | ({ color = 'currentColor', ...props }, forwardedRef) => {
85 | return (
86 |
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 |
--------------------------------------------------------------------------------