├── .gitignore
├── package.json
├── public
├── src
│ ├── svg
│ │ ├── light-sun.svg
│ │ ├── dark-moon.svg
│ │ ├── dark-github.svg
│ │ ├── light-github.svg
│ │ ├── dark-url.svg
│ │ └── light-url.svg
│ ├── js
│ │ └── script.js
│ └── css
│ │ └── style.css
├── 404.html
└── index.html
├── server
├── shortener.js
├── server.js
├── storage.js
└── routes.js
├── LICENSE
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | urls.db
3 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "linky",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "start": "node server/server.js"
8 | },
9 | "dependencies": {
10 | "express": "^4.18.2",
11 | "better-sqlite3": "^9.3.0"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/public/src/svg/light-sun.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/src/svg/dark-moon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/server/shortener.js:
--------------------------------------------------------------------------------
1 | function generateRandomChar() {
2 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
3 | return chars.charAt(Math.floor(Math.random() * chars.length));
4 | }
5 |
6 | function generateRandomString(length) {
7 | let result = '';
8 | for (let i = 0; i < length; i++) {
9 | result += generateRandomChar();
10 | }
11 | return result;
12 | }
13 |
14 | export function createShortUrl(existsCheck) {
15 | let length = 5;
16 | let shortCode = generateRandomString(length);
17 | let attempts = 0;
18 | const maxAttempts = 10;
19 |
20 | while (existsCheck(shortCode)) {
21 | if (attempts >= maxAttempts) {
22 | length++;
23 | attempts = 0;
24 | }
25 | shortCode = generateRandomString(length);
26 | attempts++;
27 | }
28 |
29 | return shortCode;
30 | }
31 |
--------------------------------------------------------------------------------
/public/src/svg/dark-github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/src/svg/light-github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 goncalopolido
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Linky
2 |
3 | A minimalist URL shortening app.
4 |
5 | 
6 |
7 | ## Features
8 |
9 | - Custom URL support: Create memorable short links
10 | - Duplicate prevention: Same URLs get the same short code
11 | - Auto dark/light theme based on system settings
12 | - Fully responsive design for all devices
13 | - SQLite database for reliable storage
14 | - Express backend with efficient routing
15 |
16 | ## Quick Start
17 |
18 | ```bash
19 | # Clone repository
20 | git clone https://github.com/goncalopolido/linky
21 |
22 | # Install dependencies
23 | cd linky && npm install
24 |
25 | # Start the server
26 | npm start
27 |
28 | # Linky is now running on http://localhost:3000
29 | ```
30 |
31 | ## Live Demo
32 | > [!NOTE]
33 | > Due to a high volume of abuse emails, all shortened URLs created in the live demo expire after 10 minutes.
34 |
35 | Try Linky at [linky.polido.pt](https://linky.polido.pt).
36 |
37 | ## Technical Details
38 |
39 | Linky uses:
40 | - Express.js for the backend server
41 | - better-sqlite3 for database operations
42 | - CSS for styling (no frameworks)
43 | - JavaScript for frontend functionality
44 |
--------------------------------------------------------------------------------
/server/server.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import path from 'path';
3 | import {
4 | fileURLToPath
5 | } from 'url';
6 | import {
7 | dirname
8 | } from 'path';
9 | import {
10 | UrlStorage
11 | } from './storage.js';
12 | import {
13 | createShortUrl
14 | } from './shortener.js';
15 | import {
16 | apiRoutes
17 | } from './routes.js';
18 |
19 | const __filename = fileURLToPath(import.meta.url);
20 | const __dirname = dirname(__filename);
21 |
22 | const app = express();
23 | const PORT = process.env.PORT || 3000;
24 |
25 | app.use(express.json());
26 | app.use(express.static(path.join(__dirname, '../public')));
27 |
28 | const urlStorage = new UrlStorage();
29 |
30 | const context = {
31 | urlStorage,
32 | createShortUrl
33 | };
34 |
35 | app.use('/api', apiRoutes(context));
36 |
37 | app.get('/:shortCode', (req, res) => {
38 | const {
39 | shortCode
40 | } = req.params;
41 | const originalUrl = urlStorage.getOriginalUrl(shortCode);
42 |
43 | if (originalUrl) {
44 | return res.redirect(originalUrl);
45 | }
46 |
47 | res.status(404).sendFile(path.join(__dirname, '../public/404.html'));
48 | });
49 |
50 | app.get('/', (req, res) => {
51 | res.sendFile(path.join(__dirname, '../public/index.html'));
52 | });
53 |
54 | app.use((req, res) => {
55 | res.status(404).sendFile(path.join(__dirname, '../public/404.html'));
56 | });
57 |
58 | app.listen(PORT, () => {
59 | console.log(`Server running on port ${PORT}`);
60 | });
61 |
--------------------------------------------------------------------------------
/public/src/svg/dark-url.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/src/svg/light-url.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | 404 Not Found
9 |
10 |
55 |
56 |
57 |
58 |
404
59 |
Not Found
60 |
Looks like the link you're trying to reach doesn’t exist. It might’ve been mistyped or never existed at all.
61 |
62 | Return to Home
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Linky
9 |
10 |
11 |
12 |
13 |
14 |
29 |
30 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/server/storage.js:
--------------------------------------------------------------------------------
1 | import Database from 'better-sqlite3';
2 | import { fileURLToPath } from 'url';
3 | import { dirname, join } from 'path';
4 | import fs from 'fs';
5 |
6 | const __filename = fileURLToPath(import.meta.url);
7 | const __dirname = dirname(__filename);
8 |
9 | export class UrlStorage {
10 | constructor() {
11 | const dbDir = join(process.cwd(), 'database');
12 | if (!fs.existsSync(dbDir)) {
13 | fs.mkdirSync(dbDir, { recursive: true });
14 | }
15 |
16 | const dbPath = join(dbDir, 'urls.db');
17 | this.db = new Database(dbPath);
18 | this.initDatabase();
19 | }
20 |
21 | initDatabase() {
22 | this.db.exec(`
23 | CREATE TABLE IF NOT EXISTS urls (
24 | short_code TEXT PRIMARY KEY,
25 | original_url TEXT NOT NULL,
26 | length INTEGER NOT NULL,
27 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP
28 | );
29 | CREATE INDEX IF NOT EXISTS idx_url_length ON urls(original_url, length);
30 | CREATE INDEX IF NOT EXISTS idx_url ON urls(original_url);
31 | `);
32 | }
33 |
34 | storeUrl(shortCode, originalUrl, length) {
35 | try {
36 | const stmt = this.db.prepare('INSERT INTO urls (short_code, original_url, length) VALUES (?, ?, ?)');
37 | stmt.run(shortCode, originalUrl, length);
38 | return true;
39 | } catch (error) {
40 | console.error('Error storing URL:', error);
41 | return false;
42 | }
43 | }
44 |
45 | getOriginalUrl(shortCode) {
46 | try {
47 | const stmt = this.db.prepare('SELECT original_url FROM urls WHERE short_code = ?');
48 | const result = stmt.get(shortCode);
49 | return result ? result.original_url : null;
50 | } catch (error) {
51 | console.error('Error getting original URL:', error);
52 | return null;
53 | }
54 | }
55 |
56 | getShortCode(originalUrl, length) {
57 | try {
58 | const stmt = this.db.prepare('SELECT short_code FROM urls WHERE original_url = ? AND length = ?');
59 | const result = stmt.get(originalUrl, length);
60 | return result ? result.short_code : null;
61 | } catch (error) {
62 | console.error('Error getting short code:', error);
63 | return null;
64 | }
65 | }
66 |
67 | getShortCodeForUrl(originalUrl) {
68 | try {
69 | const stmt = this.db.prepare('SELECT short_code FROM urls WHERE original_url = ? ORDER BY created_at ASC LIMIT 1');
70 | const result = stmt.get(originalUrl);
71 | return result ? result.short_code : null;
72 | } catch (error) {
73 | console.error('Error getting short code for URL:', error);
74 | return null;
75 | }
76 | }
77 |
78 | shortCodeExists(shortCode) {
79 | try {
80 | const stmt = this.db.prepare('SELECT 1 FROM urls WHERE short_code = ?');
81 | return !!stmt.get(shortCode);
82 | } catch (error) {
83 | console.error('Error checking short code:', error);
84 | return false;
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/server/routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | export function apiRoutes(context) {
4 | const {
5 | urlStorage,
6 | createShortUrl
7 | } = context;
8 | const router = express.Router();
9 |
10 | router.post('/shorten', (req, res) => {
11 | const {
12 | url,
13 | customCode
14 | } = req.body;
15 |
16 | if (!url || typeof url !== 'string') {
17 | return res.status(400).json({
18 | error: 'Invalid URL'
19 | });
20 | }
21 |
22 | try {
23 | const urlObj = new URL(url);
24 |
25 | if (!['http:', 'https:'].includes(urlObj.protocol)) {
26 | return res.status(400).json({
27 | error: 'URL must use HTTP or HTTPS protocol'
28 | });
29 | }
30 |
31 | const hostnameParts = urlObj.hostname.split('.');
32 | if (hostnameParts.length < 2) {
33 | return res.status(400).json({
34 | error: 'Invalid domain name'
35 | });
36 | }
37 |
38 | const tld = hostnameParts[hostnameParts.length - 1];
39 | if (tld.length < 2) {
40 | return res.status(400).json({
41 | error: 'Invalid top-level domain'
42 | });
43 | }
44 |
45 | let shortCode;
46 |
47 | if (customCode) {
48 | if (!/^[a-zA-Z0-9-_]+$/.test(customCode)) {
49 | return res.status(400).json({
50 | error: 'Custom URL can only contain letters, numbers, hyphens, and underscores'
51 | });
52 | }
53 |
54 | if (urlStorage.shortCodeExists(customCode)) {
55 | return res.status(400).json({
56 | error: 'This custom URL is already taken'
57 | });
58 | }
59 |
60 | shortCode = customCode;
61 | } else {
62 | const existingCode = urlStorage.getShortCodeForUrl(url);
63 | if (existingCode) {
64 | return res.json({
65 | shortCode: existingCode,
66 | shortUrl: `https://${req.get('host')}/${existingCode}`,
67 | originalUrl: url,
68 | isExisting: true
69 | });
70 | }
71 |
72 | shortCode = createShortUrl(code => urlStorage.shortCodeExists(code));
73 | }
74 |
75 | const stored = urlStorage.storeUrl(shortCode, url, shortCode.length);
76 | if (!stored) {
77 | return res.status(500).json({
78 | error: 'Failed to store URL'
79 | });
80 | }
81 |
82 | return res.json({
83 | shortCode,
84 | shortUrl: `https://${req.get('host')}/${shortCode}`,
85 | originalUrl: url,
86 | isExisting: false
87 | });
88 | } catch (error) {
89 | return res.status(400).json({
90 | error: 'Invalid URL format'
91 | });
92 | }
93 | });
94 |
95 | return router;
96 | }
97 |
--------------------------------------------------------------------------------
/public/src/js/script.js:
--------------------------------------------------------------------------------
1 | const themeToggle = document.getElementById('themeToggle');
2 | const urlForm = document.getElementById('urlForm');
3 | const urlInput = document.getElementById('urlInput');
4 | const customUrlToggle = document.getElementById('customUrlToggle');
5 | const customUrlInput = document.getElementById('customUrlInput');
6 | const customUrlContainer = document.getElementById('customUrlContainer');
7 | const result = document.getElementById('result');
8 | const error = document.getElementById('error');
9 |
10 | function getSystemTheme() {
11 | return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
12 | }
13 |
14 | function setTheme(theme) {
15 | document.documentElement.setAttribute('data-theme', theme);
16 | }
17 |
18 | setTheme(getSystemTheme());
19 |
20 | themeToggle.addEventListener('click', () => {
21 | const currentTheme = document.documentElement.getAttribute('data-theme') || getSystemTheme();
22 | const newTheme = currentTheme === 'light' ? 'dark' : 'light';
23 | setTheme(newTheme);
24 | });
25 |
26 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
27 | setTheme(getSystemTheme());
28 | });
29 |
30 | customUrlToggle.addEventListener('change', () => {
31 | customUrlContainer.classList.toggle('active', customUrlToggle.checked);
32 | });
33 |
34 | function isValidUrl(string) {
35 | if (!string.match(/^https?:\/\//i)) {
36 | string = 'https://' + string;
37 | }
38 |
39 | try {
40 | const url = new URL(string);
41 | if (url.protocol !== 'http:' && url.protocol !== 'https:') {
42 | return false;
43 | }
44 | const parts = url.hostname.split('.');
45 | if (parts.length < 2) {
46 | return false;
47 | }
48 | const tld = parts[parts.length - 1];
49 | if (tld.length < 2) {
50 | return false;
51 | }
52 | return { isValid: true, url: string };
53 | } catch (_) {
54 | return { isValid: false, url: string };
55 | }
56 | }
57 |
58 | urlForm.addEventListener('submit', async (e) => {
59 | e.preventDefault();
60 | error.classList.add('hidden');
61 | result.classList.add('hidden');
62 |
63 | const submitButton = e.target.querySelector('button[type="submit"]');
64 | submitButton.disabled = true;
65 | submitButton.textContent = 'Shortening...';
66 |
67 | try {
68 | const urlToCheck = urlInput.value.trim();
69 | const validationResult = isValidUrl(urlToCheck);
70 |
71 | if (!validationResult.isValid) {
72 | throw new Error('Please enter a valid URL (e.g., https://example.com, http://example.com or example.com');
73 | }
74 |
75 | const requestBody = {
76 | url: validationResult.url
77 | };
78 |
79 | if (customUrlToggle.checked) {
80 | if (!customUrlInput.value.trim()) {
81 | throw new Error('Please enter a custom URL');
82 | }
83 |
84 | if (!/^[a-zA-Z0-9-_]+$/.test(customUrlInput.value.trim())) {
85 | throw new Error('Custom URL can only contain letters, numbers, hyphens, and underscores');
86 | }
87 |
88 | requestBody.customCode = customUrlInput.value.trim();
89 | }
90 |
91 | const response = await fetch('/api/shorten', {
92 | method: 'POST',
93 | headers: {'Content-Type': 'application/json'},
94 | body: JSON.stringify(requestBody),
95 | });
96 |
97 | const data = await response.json();
98 |
99 | if (!response.ok) {
100 | throw new Error(data.error || 'Failed to shorten URL');
101 | }
102 |
103 | result.innerHTML = `
104 |
105 |
${data.shortUrl}
106 |
115 |
116 | `;
117 |
118 | const copyButton = document.getElementById('copyButton');
119 | const copyIcon = document.getElementById('copyIcon');
120 | const checkIcon = document.getElementById('checkIcon');
121 |
122 | copyButton.addEventListener('click', async () => {
123 | try {
124 | await navigator.clipboard.writeText(data.shortUrl);
125 | copyIcon.classList.add('hidden');
126 | checkIcon.classList.remove('hidden');
127 | setTimeout(() => {
128 | copyIcon.classList.remove('hidden');
129 | checkIcon.classList.add('hidden');
130 | }, 2000);
131 | } catch (err) {
132 | console.error('Failed to copy:', err);
133 | }
134 | });
135 |
136 | result.classList.remove('hidden');
137 | error.classList.add('hidden');
138 | } catch (err) {
139 | result.classList.add('hidden');
140 | error.classList.remove('hidden');
141 | error.textContent = err.message;
142 | } finally {
143 | submitButton.disabled = false;
144 | submitButton.textContent = 'Shorten URL';
145 | }
146 | });
147 |
--------------------------------------------------------------------------------
/public/src/css/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --text: #000;
3 | --background: #fff;
4 | --border: #000;
5 | --error: #dc2626;
6 | }
7 |
8 | @media (prefers-color-scheme: dark) {
9 | :root {
10 | --text: #fff;
11 | --background: #000;
12 | --border: #fff;
13 | --error: #ef4444;
14 | }
15 | }
16 |
17 | [data-theme="dark"] {
18 | --text: #fff;
19 | --background: #000;
20 | --border: #fff;
21 | --error: #ef4444;
22 | }
23 |
24 | [data-theme="light"] {
25 | --text: #000;
26 | --background: #fff;
27 | --border: #000;
28 | --error: #dc2626;
29 | }
30 |
31 | * {
32 | margin: 0;
33 | padding: 0;
34 | box-sizing: border-box;
35 | }
36 |
37 | body {
38 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
39 | line-height: 1.5;
40 | min-height: 100vh;
41 | color: var(--text);
42 | background: var(--background);
43 | display: flex;
44 | flex-direction: column;
45 | transition: color 0.3s, background-color 0.3s;
46 | text-transform: none;
47 | }
48 |
49 | .header-title {
50 | text-decoration: none;
51 | color: var(--text);
52 | transition: opacity 0.2s;
53 | }
54 |
55 | .header-title:hover {
56 | opacity: 0.7;
57 | }
58 |
59 | .header-buttons {
60 | display: flex;
61 | align-items: center;
62 | gap: 1rem;
63 | }
64 |
65 | .github-link {
66 | display: flex;
67 | align-items: center;
68 | justify-content: center;
69 | width: 42px;
70 | height: 42px;
71 | border-radius: 9999px;
72 | transition: background-color 0.2s;
73 | }
74 |
75 | .github-link:hover {
76 | background-color: rgba(128, 128, 128, 0.1);
77 | }
78 |
79 | .github-icon, .theme-icon {
80 | width: 24px;
81 | height: 24px;
82 | display: block;
83 | }
84 |
85 | .github-icon[data-theme="dark"],
86 | .theme-icon[data-theme="dark"] {
87 | display: none;
88 | }
89 |
90 | [data-theme="dark"] .github-icon[data-theme="light"],
91 | [data-theme="dark"] .theme-icon[data-theme="light"] {
92 | display: none;
93 | }
94 |
95 | [data-theme="dark"] .github-icon[data-theme="dark"],
96 | [data-theme="dark"] .theme-icon[data-theme="dark"] {
97 | display: block;
98 | }
99 |
100 | #themeToggle {
101 | display: flex;
102 | align-items: center;
103 | justify-content: center;
104 | width: 42px;
105 | height: 42px;
106 | padding: 0;
107 | border: none;
108 | background: transparent;
109 | border-radius: 9999px;
110 | cursor: pointer;
111 | transition: background-color 0.2s;
112 | }
113 |
114 | #themeToggle:hover {
115 | background-color: rgba(128, 128, 128, 0.1);
116 | }
117 |
118 | h1 {
119 | font-size: 1.5rem;
120 | font-weight: bold;
121 | }
122 |
123 | header {
124 | padding: 1.5rem;
125 | display: flex;
126 | justify-content: space-between;
127 | align-items: center;
128 | }
129 |
130 | main {
131 | flex-grow: 1;
132 | display: flex;
133 | flex-direction: column;
134 | align-items: center;
135 | justify-content: center;
136 | padding: 1.5rem;
137 | }
138 |
139 | form {
140 | width: 100%;
141 | max-width: 28rem;
142 | display: flex;
143 | flex-direction: column;
144 | gap: 1.5rem;
145 | }
146 |
147 | .input-group {
148 | display: flex;
149 | flex-direction: column;
150 | gap: 0.5rem;
151 | }
152 |
153 | label {
154 | font-size: 0.875rem;
155 | font-weight: 500;
156 | }
157 |
158 | input[type="text"] {
159 | padding: 0.75rem;
160 | border: 1px solid var(--border);
161 | border-radius: 0.375rem;
162 | background: var(--background);
163 | color: var(--text);
164 | width: 100%;
165 | transition: all 0.2s;
166 | }
167 |
168 | input[type="text"]:focus {
169 | outline: none;
170 | box-shadow: 0 0 0 2px var(--border);
171 | }
172 |
173 | .length-selector {
174 | display: flex;
175 | flex-direction: column;
176 | gap: 1rem;
177 | }
178 |
179 | .length-header {
180 | display: flex;
181 | justify-content: space-between;
182 | align-items: center;
183 | }
184 |
185 | .random-toggle {
186 | display: flex;
187 | align-items: center;
188 | gap: 0.5rem;
189 | }
190 |
191 | .range-container {
192 | opacity: 1;
193 | transition: opacity 0.2s;
194 | }
195 |
196 | .range-container.disabled {
197 | opacity: 0.5;
198 | pointer-events: none;
199 | }
200 |
201 | .range-labels {
202 | display: flex;
203 | justify-content: space-between;
204 | padding: 0 0.5rem;
205 | font-size: 0.75rem;
206 | }
207 |
208 | input[type="range"] {
209 | -webkit-appearance: none;
210 | width: 100%;
211 | height: 2px;
212 | background: var(--border);
213 | outline: none;
214 | margin: 1rem 0;
215 | }
216 |
217 | input[type="range"]::-webkit-slider-thumb {
218 | -webkit-appearance: none;
219 | width: 16px;
220 | height: 16px;
221 | border-radius: 50%;
222 | background: var(--border);
223 | cursor: pointer;
224 | transition: transform 0.2s;
225 | }
226 |
227 | input[type="range"]::-moz-range-thumb {
228 | width: 16px;
229 | height: 16px;
230 | border-radius: 50%;
231 | background: var(--border);
232 | cursor: pointer;
233 | transition: transform 0.2s;
234 | border: none;
235 | }
236 |
237 | input[type="range"]::-webkit-slider-thumb:hover,
238 | input[type="range"]::-moz-range-thumb:hover {
239 | transform: scale(1.1);
240 | }
241 |
242 | button {
243 | padding: 0.75rem;
244 | border: 1px solid var(--border);
245 | border-radius: 0.375rem;
246 | background: var(--text);
247 | color: var(--background);
248 | cursor: pointer;
249 | transition: all 0.2s;
250 | }
251 |
252 | button:hover {
253 | opacity: 0.9;
254 | }
255 |
256 | button:disabled {
257 | opacity: 0.7;
258 | cursor: not-allowed;
259 | }
260 |
261 | .result {
262 | margin-top: 2rem;
263 | width: 100%;
264 | max-width: 28rem;
265 | border: 1px solid var(--border);
266 | border-radius: 0.375rem;
267 | padding: 1.25rem;
268 | animation: fadeIn 0.3s ease-out forwards;
269 | text-align: center;
270 | }
271 |
272 | .short-url-container {
273 | display: flex;
274 | align-items: center;
275 | justify-content: center;
276 | gap: 0.5rem;
277 | }
278 |
279 | .result a {
280 | color: var(--text);
281 | text-decoration: underline;
282 | transition: opacity 0.2s;
283 | font-size: 1.125rem;
284 | }
285 |
286 | .result a:hover {
287 | opacity: 0.8;
288 | }
289 |
290 | #copyButton {
291 | padding: 0.25rem;
292 | border-radius: 9999px;
293 | background: transparent;
294 | border: none;
295 | color: var(--text);
296 | }
297 |
298 | #copyButton:hover {
299 | background: rgba(128, 128, 128, 0.1);
300 | }
301 |
302 | .error {
303 | margin-top: 1rem;
304 | color: var(--error);
305 | font-size: 0.875rem;
306 | animation: fadeIn 0.3s ease-out forwards;
307 | padding: 0.75rem;
308 | border: 1px solid var(--error);
309 | border-radius: 0.375rem;
310 | background-color: rgba(220, 38, 38, 0.1);
311 | }
312 |
313 | footer {
314 | padding: 1rem 1.5rem;
315 | text-align: center;
316 | font-size: 0.875rem;
317 | }
318 |
319 | .hidden {
320 | display: none;
321 | }
322 |
323 | .custom-url-toggle {
324 | display: flex;
325 | align-items: center;
326 | gap: 0.5rem;
327 | margin-bottom: 0.5rem;
328 | }
329 |
330 | .custom-url-input {
331 | display: none;
332 | }
333 |
334 | .custom-url-input.active {
335 | display: block;
336 | margin-top: 0.5rem;
337 | }
338 |
339 | @keyframes fadeIn {
340 | from {
341 | opacity: 0;
342 | transform: translateY(10px);
343 | }
344 | to {
345 | opacity: 1;
346 | transform: translateY(0);
347 | }
348 | }
349 |
--------------------------------------------------------------------------------