Jelajahi Sumber

Merge pull request #792 from thedevs-network/develop

Add themes, and custom alphabet and trust proxy configuration
Pouria Ezzati 1 tahun lalu
induk
melakukan
1cc7a80408

+ 9 - 0
.example.env

@@ -26,6 +26,15 @@ DB_POOL_MAX=10
 # Optional - Generated link length
 LINK_LENGTH=6
 
+# Optional - Alphabet used to generate custom addresses
+# Default value omits o, O, 0, i, I, l, 1, and j to avoid confusion when reading the URL
+LINK_CUSTOM_ALPHABET=abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789
+
+# Optional - Tells the app that it's running behind a proxy server
+# and that it should get the IP address from that proxy server
+# if you're not using a proxy server then set this to false, otherwise users can override their IP address
+TRUST_PROXY=true
+
 # Optional - Redis host and port
 REDIS_ENABLED=false
 REDIS_HOST=127.0.0.1

+ 73 - 3
README.md

@@ -20,6 +20,7 @@
 - [Docker](#docker)
 - [API](#api)
 - [Configuration](#configuration)
+- [Themes and customizations](#themes-and-customizations)
 - [Browser extensions](#browser-extensions)
 - [Videos](#videos)
 - [Integrations](#integrations)
@@ -93,8 +94,10 @@ All variables are optional except `JWT_SECRET` which is required on production.
 | `SITE_NAME` |  Name of the website | `Kutt` | `Your Site` |
 | `DEFAULT_DOMAIN` |  The domain address that this app runs on | `localhost:3000` | `yoursite.com` |
 | `LINK_LENGTH` | The length of of shortened address | `6` | `5` |
+| `LINK_CUSTOM_ALPHABET` | Alphabet used to generate custom addresses. Default value omits o, O, 0, i, I, l, 1, and j to avoid confusion when reading the URL. | (abcd..789) | `abcABC^&*()@` |
 | `DISALLOW_REGISTRATION` | Disable registration. Note that if `MAIL_ENABLED` is set to false, then the registration would still be disabled since it relies on emails to sign up users. | `true` | `false` |
 | `DISALLOW_ANONYMOUS_LINKS` | Disable anonymous link creation | `true` | `false` |
+| `TRUST_PROXY` | If the app is running behind a proxy server like NGINX or Cloudflare and that it should get the IP address from that proxy server. If you're not using a proxy server then set this to false, otherwise users can override their IP address. | `true` | `false` |
 | `DB_CLIENT` |  Which database client to use. Supported clients: `pg` or `pg-native` for Postgres, `mysql2` for MySQL or MariaDB, `sqlite3` and `better-sqlite3` for SQLite. NOTE: `pg-native` and `better-sqlite3` are not installed by default, use `npm` to install them before use. | `sqlite3` | `pg` |
 | `DB_HOST` | Database connection host. Only if you use Postgres or MySQL. | `localhost` | `your-db-host.com` |
 | `DB_PORT` | Database port. Only if you use Postgres or MySQL. | `5432` (Postgres) | `3306` (MySQL) |
@@ -118,10 +121,77 @@ All variables are optional except `JWT_SECRET` which is required on production.
 | `MAIL_PORT` | Email server port | `587` | `465` (SSL) | 
 | `MAIL_USER` | Email server user | - | `myuser` | 
 | `MAIL_PASSWORD` | Email server password for the user | - | `mypassword` | 
-| `MAIL_FROM` | Email address to send the user from | - | `some.address@yoursite.com` | 
+| `MAIL_FROM` | Email address to send the user from | - | `example@yoursite.com` | 
 | `MAIL_SECURE` | Whether use SSL for the email server connection | `false` | `true` | 
-| `REPORT_EMAIL` | The email address that will receive submitted reports | - | `some.address@yoursite.com` | 
-| `CONTACT_EMAIL` | The support email address to show on the app | - | `some.address@yoursite.com` | 
+| `REPORT_EMAIL` | The email address that will receive submitted reports | - | `example@yoursite.com` | 
+| `CONTACT_EMAIL` | The support email address to show on the app | - | `example@yoursite.com` | 
+
+## Themes and customizations
+
+You can add styles, change images, or render custom HTML. Place your content inside the [`/custom`](./custom) folder according to below instructions.
+
+#### How it works:
+
+The structure of the custom folder is like this:
+
+```
+custom/
+├─ css/
+│  ├─ custom1.css
+│  ├─ custom2.css
+│  ├─ ...
+├─ images/
+│  ├─ logo.png
+│  ├─ favicon.ico
+│  ├─ ...
+├─ views/
+│  ├─ partials/
+│  │  ├─ footer.hbs
+│  ├─ 404.hbs
+│  ├─ ...
+```
+
+- **css**: Put your CSS style files here. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/css))
+  - You can put as many style files as you want: `custom1.css`, `custom2.css`, etc.
+  - If you name your style file `styles.css`, it will replace Kutt's original `styles.css` file.
+  - Each file will be accessible by `<your-site.com>/css/<file>.css`
+- **images**: Put your images here. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/images))
+  - Name them just like the files inside the [`/static/images/`](./static/images) folder to replace Kutt's original images.
+  - Each image will be accessible by `<your-site.com>/images/<image>.<image-format>`
+- **views**: Custom HTML templates to render. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/views))
+  - It should follow the same file naming and folder structure as [`/server/views`](./server/views)
+  - Although we try to keep the original file names unchanged, be aware that new changes on Kutt might break your custom views.
+ 
+#### Example theme: Crimson
+
+This is an example and official theme. Crimson includes custom styles, images, and views.
+
+[Get Crimson theme →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson)
+
+[View list of themes and customizations →](https://github.com/thedevs-network/kutt-customizations)
+
+
+| Homepage | Admin page | Login/signup |
+| -------- | ---------- | ------------ |
+| ![crimson-homepage](https://github.com/user-attachments/assets/b74fab78-5e80-4f57-8425-f0cc73e9c68d) | ![crimson-admin](https://github.com/user-attachments/assets/a75d2430-8074-4ce4-93ec-d8bdfd75d917) | ![crimson-login-signup ](https://github.com/user-attachments/assets/b915eb77-3d66-4407-8e5d-b556f80ff453)
+
+#### Usage with Docker:
+
+If you're building the image locally, then the `/custom` folder should already be included in your app.
+
+If you're pulling the official image, make sure `/kutt/custom` volume is mounted or you have access to it. [View Docker compose example →](https://github.com/thedevs-network/kutt/blob/main/docker-compose.yml#L7)
+
+Then, move your files to that volume. You can do it with this Docker command:
+
+```sh
+docker cp <path-to-custom-folder> <kutt-container-name>:/kutt
+```
+
+For example:
+
+```sh
+docker cp custom kutt-server-1:/kutt
+```
 
 ## Browser extensions
 

+ 3 - 0
custom/.gitkeep

@@ -0,0 +1,3 @@
+# keep this folder in git
+# put supported customization files for styles and such
+# if you're using docker make sure to mount this folder

+ 4 - 1
docker-compose.mariadb.yml

@@ -2,6 +2,8 @@ services:
   server:
     build:
       context: .
+    volumes:
+      - custom:/kutt/custom
     environment:
         DB_CLIENT: mysql2
         DB_HOST: mariadb
@@ -39,4 +41,5 @@ services:
     expose:
       - 6379
 volumes:
-  db_data_mariadb:
+  db_data_mariadb:
+  custom:

+ 5 - 1
docker-compose.postgres.yml

@@ -2,9 +2,12 @@ services:
   server:
     build:
       context: .
+    volumes:
+      - custom:/kutt/custom
     environment:
         DB_CLIENT: pg
         DB_HOST: postgres
+        DB_PORT: 5432
         REDIS_ENABLED: true
         REDIS_HOST: redis
         REDIS_PORT: 6379
@@ -37,4 +40,5 @@ services:
     expose:
       - 6379
 volumes:
-  db_data_pg:
+  db_data_pg:
+  custom:

+ 4 - 2
docker-compose.sqlite-redis.yml

@@ -3,7 +3,8 @@ services:
     build:
       context: .
     volumes:
-       - db-data:/var/lib/kutt
+      - db_data_sqlite:/var/lib/kutt
+      - custom:/kutt/custom
     environment:
       DB_FILENAME: "/var/lib/kutt/data.sqlite"
       REDIS_ENABLED: true
@@ -20,4 +21,5 @@ services:
     expose:
       - 6379
 volumes:
-  db-data:
+  db_data_sqlite:
+  custom:

+ 3 - 1
docker-compose.yml

@@ -4,9 +4,11 @@ services:
       context: .
     volumes:
        - db_data_sqlite:/var/lib/kutt
+       - custom:/kutt/custom
     environment:
       DB_FILENAME: "/var/lib/kutt/data.sqlite"
     ports:
       - 3000:3000
 volumes:
-  db_data_sqlite:
+  db_data_sqlite:
+  custom:

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "kutt",
-  "version": "3.0.4",
+  "version": "3.1.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "kutt",
-      "version": "3.0.4",
+      "version": "3.1.0",
       "license": "MIT",
       "dependencies": {
         "bcryptjs": "2.4.3",

+ 2 - 2
package.json

@@ -1,10 +1,10 @@
 {
   "name": "kutt",
-  "version": "3.0.4",
+  "version": "3.1.0",
   "description": "Modern URL shortener.",
   "main": "./server/server.js",
   "scripts": {
-    "dev": "cross-env NODE_ENV=development node --watch-path=./server server/server.js",
+    "dev": "cross-env NODE_ENV=development node --watch-path=./server --watch-path=./custom server/server.js",
     "start": "cross-env NODE_ENV=production node server/server.js",
     "migrate": "knex migrate:latest",
     "migrate:make": "knex migrate:make",

+ 7 - 0
server/env.js

@@ -10,11 +10,18 @@ const supportedDBClients = [
   "mysql2"
 ];
 
+// make sure custom alphabet is not empty
+if (process.env.LINK_CUSTOM_ALPHABET === "") {
+  delete process.env.LINK_CUSTOM_ALPHABET;
+}
+
 const env = cleanEnv(process.env, {
   PORT: num({ default: 3000 }),
   SITE_NAME: str({ example: "Kutt", default: "Kutt" }),
   DEFAULT_DOMAIN: str({ example: "kutt.it", default: "localhost:3000" }),
   LINK_LENGTH: num({ default: 6 }),
+  LINK_CUSTOM_ALPHABET: str({ default: "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789" }),
+  TRUST_PROXY: bool({ default: true }),
   DB_CLIENT: str({ choices: supportedDBClients, default: "sqlite3" }),
   DB_FILENAME: str({ default: "db/data" }),
   DB_HOST: str({ default: "localhost" }),

+ 1 - 1
server/handlers/links.handler.js

@@ -338,7 +338,7 @@ async function editAdmin(req, res) {
     res.render("partials/admin/links/edit", {
       swap_oob: true,
       success: "Link has been updated.",
-      ...utils.sanitize.linkAdmin({ ...updatedLink }),
+      ...utils.sanitize.link_admin({ ...updatedLink }),
     });
     return;
   }

+ 1 - 0
server/handlers/locals.handler.js

@@ -29,6 +29,7 @@ function config(req, res, next) {
   res.locals.disallow_registration = env.DISALLOW_REGISTRATION;
   res.locals.mail_enabled = env.MAIL_ENABLED;
   res.locals.report_email = env.REPORT_EMAIL;
+  res.locals.custom_styles = utils.getCustomCSSFileNames();
   next();
 }
 

+ 2 - 2
server/handlers/validators.handler.js

@@ -45,7 +45,7 @@ const createLink = [
     .trim()
     .isLength({ min: 1, max: 64 })
     .withMessage("Custom URL length must be between 1 and 64.")
-    .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
+    .custom(value => utils.customAddressRegex.test(value) || utils.customAlphabetRegex.test(value))
     .withMessage("Custom URL is not valid.")
     .custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value))
     .withMessage("You can't use this custom URL."),
@@ -120,7 +120,7 @@ const editLink = [
     .trim()
     .isLength({ min: 1, max: 64 })
     .withMessage("Custom URL length must be between 1 and 64.")
-    .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
+    .custom(value => utils.customAddressRegex.test(value) || utils.customAlphabetRegex.test(value))
     .withMessage("Custom URL is not valid")
     .custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value))
     .withMessage("You can't use this custom URL."),

+ 13 - 6
server/server.js

@@ -29,17 +29,20 @@ require("./passport");
 // create express app
 const app = express();
 
-// this tells the express app that the app is running behind a proxy server
+// this tells the express app that it's running behind a proxy server
 // and thus it should get the IP address from the proxy server
-// IMPORTANT: users might be able to override their IP address and this
-// might allow users to bypass the rate limit or lead to incorrect link stats
-// read the Kutt documentation to learn how prevent users from changing their real IP address
-app.set("trust proxy", true);
+if (env.TRUST_PROXY) {
+  app.set("trust proxy", true);
+}
 
 app.use(helmet({ contentSecurityPolicy: false }));
 app.use(cookieParser());
 app.use(express.json());
 app.use(express.urlencoded({ extended: true }));
+
+// serve static
+app.use("/images", express.static("custom/images"));
+app.use("/css", express.static("custom/css", { extensions: ["css"] }));
 app.use(express.static("static"));
 
 app.use(passport.initialize());
@@ -47,8 +50,12 @@ app.use(locals.isHTML);
 app.use(locals.config);
 
 // template engine / serve html
+
 app.set("view engine", "hbs");
-app.set("views", path.join(__dirname, "views"));
+app.set("views", [
+  path.join(__dirname, "../custom/views"),
+  path.join(__dirname, "views"),
+]);
 utils.registerHandlebarsHelpers();
 
 // if is custom domain, redirect to the set homepage

+ 37 - 5
server/utils/utils.js

@@ -1,7 +1,8 @@
 const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears, format } = require("date-fns");
 const { customAlphabet } = require("nanoid");
 const JWT = require("jsonwebtoken");
-const path = require("path");
+const path = require("node:path");
+const fs = require("node:fs");
 const hbs = require("hbs");
 const ms = require("ms");
 
@@ -10,10 +11,7 @@ const knexUtils = require("./knex");
 const knex = require("../knex");
 const env = require("../env");
 
-const nanoid = customAlphabet(
-  "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
-  env.LINK_LENGTH
-);
+const nanoid = customAlphabet(env.LINK_CUSTOM_ALPHABET, env.LINK_LENGTH);
 
 class CustomError extends Error {
   constructor(message, statusCode, data) {
@@ -26,6 +24,12 @@ class CustomError extends Error {
 
 const urlRegex = /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i;
 
+const charsNeedEscapeInRegExp = ".$*+?()[]{}|^-";
+const customAlphabetEscaped = env.LINK_CUSTOM_ALPHABET
+  .split("").map(c => charsNeedEscapeInRegExp.includes(c) ? "\\" + c : c).join("");
+const customAlphabetRegex = new RegExp(`^[${customAlphabetEscaped}_-]+$`);
+const customAddressRegex = new RegExp("^[a-zA-Z0-9-_]+$");
+
 function isAdmin(user) {
   return user.role === ROLES.ADMIN;
 }
@@ -360,14 +364,42 @@ function registerHandlebarsHelpers() {
       return val;
   });
   hbs.registerPartials(path.join(__dirname, "../views/partials"), function (err) {});
+  const customPartialsPath = path.join(__dirname, "../../custom/views/partials");
+  const customPartialsExist = fs.existsSync(customPartialsPath);
+  if (customPartialsExist) {
+    hbs.registerPartials(customPartialsPath, function (err) {});
+  }
+}
+
+// grab custom styles file name from the custom/css folder
+const custom_css_file_names = [];
+const customCSSPath = path.join(__dirname, "../../custom/css");
+const customCSSExists = fs.existsSync(customCSSPath);
+if (customCSSExists) {
+  fs.readdir(customCSSPath, function(error, files) {
+    if (error) {
+      console.warn("Could not read the custom CSS folder:", error);
+    } else {
+      files.forEach(function(file_name) {
+        custom_css_file_names.push(file_name);
+      });
+    }
+  })
+}
+
+function getCustomCSSFileNames() {
+  return custom_css_file_names;
 }
 
 module.exports = {
   addProtocol,
+  customAddressRegex,
+  customAlphabetRegex,
   CustomError,
   dateToUTC,
   deleteCurrentToken,
   generateId,
+  getCustomCSSFileNames,
   getDifferenceFunction,
   getInitStats,
   getShortURL,

+ 3 - 0
server/views/layout.hbs

@@ -24,6 +24,9 @@
   <meta name="description" content="{{site_name}} is a free and open source URL shortener with custom domains and stats." />
   <title>{{site_name}} | {{title}}</title>
   <link rel="stylesheet" href="/css/styles.css">
+  {{#each custom_styles}}
+    <link rel="stylesheet" href="/css/{{this}}">
+  {{/each}}
   {{{block "stylesheets"}}}
 </head>
 <body>

+ 1 - 1
server/views/partials/admin/links/edit.hbs

@@ -25,7 +25,7 @@
           {{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
         </label>
         <label class="{{#if errors.address}}error{{/if}}">
-          {{domain}}/
+          <span id="edit-link-domain-{{id}}" hx-preserve="true">{{domain}}/</span>
           <input 
             id="edit-address-{{id}}"
             name="address" 

+ 1 - 2
server/views/partials/footer.hbs

@@ -1,7 +1,6 @@
 <footer>
   <p>
-    Made with love by <a href="https://thedevs.network" title="The Devs" target="_blank" rel="noopener noreferrer">The Devs</a>. <span>|</span> 
-    <a href="https://github.com/thedevs-network/kutt" title="GitHub" target="_blank" rel="noopener noreferrer">GitHub</a> <span>|</span> 
+    Powered by <a href="https://github.com/thedevs-network/kutt" title="The Devs" target="_blank" rel="noopener noreferrer">Kutt</a> <span>|</span> 
     <a href="/terms" title="Terms of Service">Terms of Service</a>
     {{#if report_email}} 
      <span>|</span>

+ 1 - 1
server/views/partials/header.hbs

@@ -1,7 +1,7 @@
 <header>
   <div class="logo-wrapper">
     <a class="logo nav" href="/" title="Homepage">
-      <img src="/images/logo.svg" alt="kutt" width="18" height="24" />
+      <img src="/images/logo.png" alt="kutt" width="18" height="24" />
       {{site_name}}
     </a>
     <ul class="logo-links">

+ 1 - 1
server/views/partials/links/edit.hbs

@@ -25,7 +25,7 @@
           {{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
         </label>
         <label class="{{#if errors.address}}error{{/if}}">
-          {{domain}}/
+          <span id="edit-link-domain-{{id}}" hx-preserve="true">{{domain}}/</span>
           <input 
             id="edit-address-{{id}}"
             name="address" 

+ 50 - 38
static/css/styles.css

@@ -8,26 +8,39 @@
 :root {
   --bg-color: hsl(206, 12%, 95%);
   --text-color: hsl(200, 35%, 25%);
-  --color-primary: #2196f3;
-  --outline-color: #14e0ff;
+  --color-primary: hsl(207, 90%, 54%);
+  --outline-color: hsl(188, 100%, 54%);
   --button-bg: linear-gradient(to right, #e0e0e0, #bdbdbd);
   --button-bg-box-shadow-color: rgba(160, 160, 160, 0.5);
-  --button-bg-primary: linear-gradient(to right, #42a5f5, #2979ff);
-  --button-bg-primary-box-shadow-color: rgba(66, 165, 245, 0.5);
-  --button-bg-secondary: linear-gradient(to right, #7e57c2, #6200ea);
-  --button-bg-secondary-box-shadow-color: rgba(81, 45, 168, 0.5);
-  --button-bg-danger: linear-gradient(to right, #ee3b3b, #e11c1c);
-  --button-bg-danger-box-shadow-color: rgba(168, 45, 45, 0.5);
-  --button-bg-success: linear-gradient(to right, #31b647, #26be3f);
-  --button-bg-success-box-shadow-color: rgba(25, 221, 51, 50%);
-  --features-bg: hsl(230, 15%, 92%);
-  --extensions-bg: hsl(230, 15%, 20%);
-  --send-icon-hover-color: #673ab7;
+  --button-bg-primary: linear-gradient(to right, hsl(207, 90%, 61%), hsl(218, 100%, 58%));
+  --button-bg-primary-box-shadow-color: hsla(207, 90%, 61%, 0.5);
+  --button-bg-secondary: linear-gradient(to right, hsl(262, 47%, 55%), hsl(265, 100%, 46%));
+  --button-bg-secondary-box-shadow-color: hsla(258, 58%, 42%, 0.5);
+  --button-bg-danger: linear-gradient(to right, hsl(0, 84%, 58%), hsl(0, 78%, 50%));
+  --button-bg-danger-box-shadow-color: hsla(0, 58%, 42%, 0.5);
+  --button-bg-success: linear-gradient(to right, hsl(130, 58%, 45%), hsl(130, 67%, 45%));
+  --button-bg-success-box-shadow-color: hsla(128, 80%, 48%, 0.5);
+  --button-action-shadow-color: hsla(200, 15%, 60%, 0.12);
+  --underline-color: hsl(200, 35%, 65%);
+  --secondary-text-color: hsl(200, 14%, 60%);
+  --send-icon-hover-color: hsl(262, 52%, 47%);
   --send-spinner-icon-color: hsl(200, 15%, 70%);
   --success-icon-color: hsl(144, 40%, 57%);
-  --error-icon-color: #f24f4f;
+  --error-icon-color: hsl(0, 86%, 63%);
   --copy-icon-color: hsl(144, 40%, 57%);
   --copy-icon-bg-color: hsl(144, 100%, 96%);
+  --copy-icon-shadow-color: hsla(200, 15%, 60%, 0.12);
+  --focus-outline-color: hsla(207, 90%, 61%, 0.5);
+  --checkbox-bg-color: hsl(262, 47%, 63%);
+  --input-shadow-color: hsla(200, 15%, 70%, 0.2);
+  --input-hover-shadow-color: hsla(200, 15%, 70%, 0.4);
+  --input-label-color: hsl(200, 35%, 25%);
+  --table-bg-color: hsl(200, 12%, 95%);
+  --table-shadow-color: hsla(200, 20%, 70%, 0.3);
+  --table-tr-border-color: hsl(200, 14%, 94%);
+  --table-tr-hover-bg-color: hsl(200, 14%, 98%);
+  --table-head-tr-border-color: hsl(200, 14%, 90%);
+  --table-status-gray-bg-color: hsl(200, 12%, 95%);
   --keyframe-slidey-offset: 0;
 }
 
@@ -233,7 +246,7 @@ button.action {
   padding: 5px;
   width: 24px;
   height: 24px;
-  box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
+  box-shadow: 0 2px 1px var(--button-action-shadow-color);
 }
 
 a.button.action:disabled,
@@ -441,7 +454,7 @@ input[type="password"] {
   border-radius: 100px;
   border-bottom: 5px solid #f5f5f5;
   border-bottom-width: 5px;
-  box-shadow: 0 10px 35px hsla(200, 15%, 70%, 0.2);
+  box-shadow: 0 10px 35px var(--input-shadow-color);
   transition: all 0.5s ease-out;
 }
 
@@ -450,7 +463,7 @@ input[type="text"]:focus,
 input[type="email"]:focus,
 input[type="password"]:focus {
   outline: none;
-  box-shadow: 0 20px 35px hsla(200, 15%, 70%, 0.4);
+  box-shadow: 0 20px 35px var(--input-hover-shadow-color);
 }
 
 input[type="text"]::placeholder,
@@ -478,7 +491,7 @@ select {
   letter-spacing: 0.05em;
   color: #444;
   background-color: white;
-  box-shadow: 0 10px 35px hsla(200, 15%, 70%, 0.2);
+  box-shadow: 0 10px 35px var(--input-shadow-color);
   border: none;
   border-radius: 100px;
   border-bottom: 5px solid #f5f5f5;
@@ -493,7 +506,7 @@ select {
 
 select:focus {
   outline: none;
-  box-shadow: 0 20px 35px hsla(200, 15%, 70%, 0.4);
+  box-shadow: 0 20px 35px var(--input-hover-shadow-color)
 }
 
 .error select {
@@ -518,7 +531,7 @@ input[type="checkbox"] {
 }
 
 input[type="checkbox"]:focus {
-  outline: 3px solid rgba(65, 164, 245, 0.5);
+  outline: 3px solid var(--focus-outline-color);
 }
 
 input[type="checkbox"]::after {
@@ -530,7 +543,7 @@ input[type="checkbox"]::after {
   height: 80%;
   display: block;
   border-radius: 2px;
-  background-color: #9575cd;
+  background-color: var(--checkbox-bg-color);
   box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
   cursor: pointer;
   opacity: 0;
@@ -568,7 +581,7 @@ select:has(option[value=""]:checked) {
 
 label {
   display: flex;
-  color: rgb(41, 71, 86);
+  color: var(--input-label-color);
   font-size: 1rem;
   flex-direction: column;
   align-items: flex-start;
@@ -607,7 +620,7 @@ table {
   flex-direction: column;
   background-color: white;
   border-radius: 12px;
-  box-shadow: 0 6px 15px hsla(200, 20%, 70%, 0.3);
+  box-shadow: 0 6px 15px var(--table-shadow-color);
   text-align: center;
   overflow: auto;
 }
@@ -638,7 +651,7 @@ table tfoot {
 
 table tr {
   padding: 0 0.5rem;
-  border-bottom: 1px solid hsl(200, 14%, 94%);
+  border-bottom: 1px solid var(--table-tr-border-color);
 }
 
 table th,
@@ -665,18 +678,18 @@ table tbody + tfoot {
 }
 
 table thead {
-  background-color: hsl(200, 12%, 95%);
+  background-color: var(--table-bg-color);
   border-top-right-radius: 12px;
   border-top-left-radius: 12px;
   font-weight: bold;
 }
 
 table thead tr {
-  border-bottom: 1px solid hsl(200, 14%, 90%);
+  border-bottom: 1px solid var(--table-head-tr-border-color);
 }
 
 table tfoot {
-  background-color: hsl(200, 12%, 95%);
+  background-color: var(--table-bg-color);
   border-bottom-right-radius: 12px;
   border-bottom-left-radius: 12px;
 }
@@ -1159,7 +1172,7 @@ main #shorturl h1 {
 
 main #shorturl h1.link {
   cursor: pointer;
-  border-bottom-color: hsl(200, 35%, 65%);
+  border-bottom-color: var(--underline-color);
   transition: opacity 0.3s ease-in-out;
   --keyframe-slidey-offset: -10px;
   animation: fadein 0.2s ease-in-out, slidey 0.2s ease-in-out;
@@ -1189,7 +1202,7 @@ main #shorturl h1.link:hover {
   border-radius: 100%;
   background-color: var(--copy-icon-bg-color);
   transition: transform 0.4s ease-out;
-  box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
+  box-shadow: 0 2px 1px var(--copy-icon-shadow-color);
   cursor: pointer;
   --keyframe-slidey-offset: -10px;
   animation: slidey 0.2s ease-in-out;
@@ -1204,7 +1217,7 @@ main #shorturl h1.link:hover {
 }
 
 .clipboard button:focus {
-  outline: 3px solid rgba(65, 164, 245, 0.5);
+  outline: 3px solid var(--focus-outline-color);
 }
 
 .clipboard svg {
@@ -1469,7 +1482,7 @@ table .short-link-wrapper { display: flex; align-items: center; }
   margin-top: 0;
 }
 
-#main-table-wrapper table .status.gray { background-color: hsl(200, 12%, 95%); }
+#main-table-wrapper table .status.gray { background-color: var(--table-status-gray-bg-color); }
 #main-table-wrapper table .status.green { background-color: hsl(102.4, 100%, 93.3%); }
 #main-table-wrapper table .status.red { background-color: hsl(0, 100%, 96.7%); }
 
@@ -1547,7 +1560,7 @@ table .short-link-wrapper { display: flex; align-items: center; }
 }
 
 #main-table-wrapper table tbody tr:hover {
-  background-color: hsl(200, 14%, 98%);
+  background-color: var(--table-tr-hover-bg-color);
 }
 
 #main-table-wrapper table tbody td.right-fade:after {
@@ -1561,10 +1574,9 @@ table .short-link-wrapper { display: flex; align-items: center; }
 }
 
 #main-table-wrapper table tbody tr:hover td.right-fade:after {
-  background: linear-gradient(to left, hsl(200, 14%, 98%), rgba(255, 255, 255, 0.001));
+  background: linear-gradient(to left, var(--table-tr-hover-bg-color), rgba(255, 255, 255, 0.001));
 }
 
-
 #main-table-wrapper table .clipboard { margin-right: 0.5rem; }
 #main-table-wrapper table .clipboard svg.check { width: 24px; }
 
@@ -1858,7 +1870,7 @@ form#delete-account.htmx-request .spinner { display: block; }
   align-items: stretch;
   background-color: white;
   border-radius: 12px;
-  box-shadow: 0 6px 15px hsla(200, 20%, 70%, 0.3);
+  box-shadow: 0 6px 15px var(--table-shadow-color);
   overflow: hidden;
   padding: 0;
 }
@@ -1867,7 +1879,7 @@ form#delete-account.htmx-request .spinner { display: block; }
   width: 100%;
   display: flex;
   align-items: center;
-  background-color: hsl(200, 12%, 95%);
+  background-color: var(--table-bg-color);
   justify-content: space-between;
   padding: 0.75rem 1.5rem;
 }
@@ -1894,12 +1906,12 @@ form#delete-account.htmx-request .spinner { display: block; }
 
 .stats-period span.total-in-period {
   font-weight: bold;
-  border-bottom: 1px dotted hsl(200, 35%, 65%);
+  border-bottom: 1px dotted var(--underline-color);
 }
 
 p.last-update {
   font-size: 14px;
-  color: hsl(200, 14%, 60%);
+  color: var(--secondary-text-color);
   margin: 0.75rem 0 0;
 }
 

TEMPAT SAMPAH
static/images/callout.png


TEMPAT SAMPAH
static/images/icons/icon-128x128.png


TEMPAT SAMPAH
static/images/icons/icon-144x144.png


TEMPAT SAMPAH
static/images/icons/icon-152x152.png


TEMPAT SAMPAH
static/images/icons/icon-192x192.png


TEMPAT SAMPAH
static/images/icons/icon-384x384.png


TEMPAT SAMPAH
static/images/icons/icon-512x512.png


TEMPAT SAMPAH
static/images/icons/icon-72x72.png


TEMPAT SAMPAH
static/images/icons/icon-96x96.png


File diff ditekan karena terlalu besar
+ 0 - 10
static/images/kutt.svg


TEMPAT SAMPAH
static/images/logo.png


File diff ditekan karena terlalu besar
+ 0 - 0
static/images/logo.svg


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini