Repository: codewec/dashlit
Branch: main
Commit: feddf5b3d916
Files: 54
Total size: 51.5 KB
Directory structure:
gitextract_nb5g7m7j/
├── .env.example
├── .github/
│ └── workflows/
│ ├── docs.yml
│ ├── push-main.yml
│ └── release.yml
├── .gitignore
├── .npmrc
├── .prettierrc
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── README.md
├── docker-compose.yml
├── docs/
│ ├── .gitignore
│ ├── .vitepress/
│ │ ├── config.ts
│ │ └── utils/
│ │ └── index.ts
│ ├── CNAME
│ ├── changelog.md
│ ├── contributing.md
│ ├── guide/
│ │ ├── getting-started.md
│ │ └── what-is.md
│ ├── index.md
│ └── license.md
├── package.json
├── src/
│ ├── app.css
│ ├── app.d.ts
│ ├── app.html
│ ├── hooks.server.ts
│ ├── lib/
│ │ ├── components/
│ │ │ ├── actionButtons.svelte
│ │ │ ├── dashboard.svelte
│ │ │ ├── emptyGroup.svelte
│ │ │ ├── emptyItem.svelte
│ │ │ ├── header.svelte
│ │ │ ├── modalDelete.svelte
│ │ │ ├── modalFormGroup.svelte
│ │ │ └── modalFormItem.svelte
│ │ ├── factory.ts
│ │ ├── helpers.ts
│ │ ├── index.ts
│ │ ├── server/
│ │ │ └── helper.ts
│ │ ├── styles/
│ │ │ └── dnd.css
│ │ └── types.ts
│ └── routes/
│ ├── (auth)/
│ │ ├── login/
│ │ │ ├── +page.server.ts
│ │ │ └── +page.svelte
│ │ └── logout/
│ │ └── +page.server.ts
│ ├── +error.svelte
│ ├── +layout.server.ts
│ ├── +layout.svelte
│ ├── +page.server.ts
│ ├── +page.svelte
│ └── custom.css/
│ └── +server.ts
├── static/
│ └── site.webmanifest
├── svelte.config.js
├── tsconfig.json
└── vite.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .env.example
================================================
PASSWORD="password"
SECRET_KEY="6ft0ryZAeb3DdFIeEwi4uv5zI69GE2ez"
================================================
FILE: .github/workflows/docs.yml
================================================
name: Deploy Documentation site to GitHub Pages
on:
push:
paths:
- 'docs/**'
- '.github/workflows/docs.yml'
- '*.md'
branches:
- main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 10
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Install dependencies
run: pnpm install
- name: Build with VitePress
run: |
pnpm run docs:build
touch docs/.vitepress/dist/.nojekyll
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
name: github-pages
path: docs/.vitepress/dist
deploy:
name: Deploy
needs: build
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
================================================
FILE: .github/workflows/push-main.yml
================================================
name: Push Main
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
on:
push:
paths-ignore:
- 'docs/**'
- '.md'
branches:
- main
pull_request:
paths-ignore:
- 'docs/**'
- '*.md'
branches:
- main
jobs:
build-and-push-image:
name: Push Docker image to GitHub Packages
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
================================================
FILE: .github/workflows/release.yml
================================================
name: Release Docker Image
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
on:
push:
tags:
- 'v*.*.*'
jobs:
build-and-push-image:
name: Push Docker image to GitHub Packages
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
================================================
FILE: .gitignore
================================================
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
/data
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
!.env.ci
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
================================================
FILE: .npmrc
================================================
engine-strict=true
================================================
FILE: .prettierrc
================================================
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
================================================
FILE: CHANGELOG.md
================================================
# Changelog
## v0.0.6
[compare changes](https://github.com/codewec/dashlit/compare/v0.0.5...v0.0.6)
### 🚀 Enhancements
- Icon custom color ([5e4fc05](https://github.com/codewec/dashlit/commit/5e4fc05))
- Add select url target ([b886d5d](https://github.com/codewec/dashlit/commit/b886d5d))
- Show URL options ([a8e5e10](https://github.com/codewec/dashlit/commit/a8e5e10))
- Check valid ORIGIN ([2e6cf56](https://github.com/codewec/dashlit/commit/2e6cf56))
### 🩹 Fixes
- Changelogen params ([6ae906e](https://github.com/codewec/dashlit/commit/6ae906e))
### ❤️ Contributors
- Wec <codeforwec@gmail.com>
## v0.0.5
[compare changes](https://github.com/codewec/dashlit/compare/0.0.4...v0.0.5)
### 🩹 Fixes
- GH workflow ([2b23083](https://github.com/codewec/dashlit/commit/2b23083))
### ❤️ Contributors
- Wec <codeforwec@gmail.com>
================================================
FILE: Dockerfile
================================================
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN mv .env.example .env
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm install --frozen-lockfile --force
RUN npm run build
# second stage
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json .
EXPOSE 3000
CMD ["sh","-c","node build"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 codewec
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
<h1 align="center">DashLit</h1>
<p align="center">
<i>DashLit is a simple, self-hosted Startpage solution. It’s incredibly easy to set up and use, and its built-in editors let you quickly create your own application hub – even with a convenient drag-and-drop interface. You don’t even need to edit any files!</i>
<br/><br/>
<img width="130" alt="DashLit" src="https://raw.githubusercontent.com/codewec/dashlit/main/static/favicon.svg"/>
<br/> <br/>
<img src="https://img.shields.io/github/v/release/codewec/dashlit?logo=hackthebox&color=609966&logoColor=fff" alt="Current Version"/>
<img src="https://img.shields.io/github/last-commit/codewec/dashlit?logo=github&color=609966&logoColor=fff" alt="Last commit"/>
<a href="https://github.com/codewec/dashlit/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-609966?logo=opensourceinitiative&logoColor=fff" alt="License MIT"/></a>
<a href="https://dashlit.cwec.dev/" target="_blank"><img src="https://img.shields.io/badge/doc-609966"/></a>
<a href="https://demo.dashlit.cwec.dev/" target="_blank"><img src="https://img.shields.io/badge/live-demo-609966"/></a>
<br/><br/>
<img src="https://raw.githubusercontent.com/codewec/dashlit/main/docs/public/main_page.png" alt="DashLit" width="100%"/>
</p>
## 🚀 Getting started
<!-- #region docker-configuration -->
### Docker
This Docker image is published on the GitHub container registry - `ghcr.io/codewec/dashlit`.
#### Minimal configuration without password
```yaml
services:
app:
container_name: dashlit-app
image: ghcr.io/codewec/dashlit:latest
restart: unless-stopped
environment:
ORIGIN: '${ORIGIN:-http://localhost:3000}' # please provide URL if different
ports:
- '3000:3000'
volumes:
- ./data:/app/data
```
#### Full configuration with password
```yaml
services:
app:
container_name: dashlit-app
image: ghcr.io/codewec/dashlit:latest
environment:
ORIGIN: '${ORIGIN:-http://localhost:3000}' # please provide URL if different
NODE_ENV: '${NODE_ENV:-production}' # optional for production environment
HOST_HEADER: '${HOST_HEADER:-HOST}' # optional for nginx reverse proxy
ADDRESS_HEADER: '${ADDRESS_HEADER:-X-Real-IP}' # optional for nginx reverse proxy
PROTOCOL_HEADER: '${PROTOCOL_HEADER:-X-Forwarded-Proto}' # optional for nginx reverse proxy
PASSWORD: '${PASSWORD:-password}'
SECRET_KEY: '${SECRET_KEY:-any-secret-string-for-jwt-auth}' # optional key for JWT authentication
restart: unless-stopped
ports:
- '3000:3000'
volumes:
- ./data:/app/data
```
<!-- #endregion docker-configuration -->
================================================
FILE: docker-compose.yml
================================================
services:
app:
container_name: dashlit-app
image: ghcr.io/codewec/dashlit:latest
restart: unless-stopped
environment:
ORIGIN: '${ORIGIN:-http://localhost:3000}' # please provide URL if different
ports:
- '3000:3000'
volumes:
- ./data:/app/data
================================================
FILE: docs/.gitignore
================================================
.vitepress/cache
.vitepress/dist
================================================
FILE: docs/.vitepress/config.ts
================================================
import { defineConfig } from 'vitepress';
import { getVersion } from './utils';
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: 'DashLit',
description: 'DashLit - A simple solution for self-hosting your home page.',
head: [['link', { rel: 'icon', href: '/favicon.png' }]],
themeConfig: {
search: {
provider: 'local'
},
logo: {
src: '/logo.png',
innerWidth: 50,
height: 50
},
nav: [
{ text: 'Home', link: '/' },
{ text: 'Getting Started', link: '/guide/getting-started' },
{ text: 'Demo', link: 'https://demo.dashlit.cwec.dev' },
{
text: getVersion(),
items: [
{ text: 'Changelog', link: '/changelog' },
{ text: 'Contributing', link: '/contributing' }
]
}
],
socialLinks: [{ icon: 'github', link: 'https://github.com/codewec/dashlit' }],
sidebar: [
{
text: 'Guide',
base: '/guide',
items: [
{ text: 'What is DashLit?', link: '/what-is' },
{ text: 'Getting Started', link: '/getting-started' }
]
},
{ text: 'Contributing', link: '/contributing' },
{ text: 'Changelog', link: '/changelog' },
{ text: 'License', link: '/license' }
],
editLink: {
pattern: 'https://github.com/codewec/dashlit/edit/main/docs/:path',
text: 'Edit this page on GitHub'
},
lastUpdated: {
text: 'Last updated',
formatOptions: {
dateStyle: 'short',
timeStyle: 'medium'
}
},
footer: {
message: 'Released under the MIT License.',
copyright: 'Copyright © 2025 CodeWec'
}
}
});
================================================
FILE: docs/.vitepress/utils/index.ts
================================================
import currentPackage from '../../../package.json';
export function getVersion() {
return currentPackage.version || '0.0.0';
}
================================================
FILE: docs/CNAME
================================================
dashlit.cwec.dev
================================================
FILE: docs/changelog.md
================================================
---
search: false
---
<!--@include: ../CHANGELOG.md-->
================================================
FILE: docs/contributing.md
================================================
# Contributing
First of all, thank you for deciding to contribute to the project. There are several ways to help the project develop.
## Bug fix
Please note that occasional bugs or issues may arise when using this application – this is standard practice. Given your knowledge of JavaScript, you may be able to resolve the problem yourself. Alternatively, please [report the issue](https://github.com/codewec/dashlit/issues/new?assignees=codewec&labels=bug&projects=&template=bug.yml&title=%5BBUG%5D+%3Ctitle%3E) and I’ll be happy to assist you.
## Star on Github
The simplest thing you can do is leave us a star on [Github](https://github.com/codewec/dashlit) – it only takes a few seconds, and I really appreciate it!
================================================
FILE: docs/guide/getting-started.md
================================================
# 🚀 Getting started
<!--@include: ../../README.md#docker-configuration-->
================================================
FILE: docs/guide/what-is.md
================================================
# What is DashLit?
`DashLit` is a flexible tool for creating homepages, especially useful for those managing their own server and services. It helps you collect and organize all your links in one place.
`DashLit` simplifies creating and managing your own online services. It boasts a user-friendly drag-and-drop interface, eliminating the need for complex configuration files like YAML. All service management is handled directly through the intuitive web interface.
Plus, `DashLit` offers secure authentication, making it ideal for deploying your services publicly online with confidence.

================================================
FILE: docs/index.md
================================================
---
layout: home
hero:
name: "DashLit"
text: "Personal home page"
tagline: A simple solution for self-hosting your home page.
image:
src: /logo.png
alt: DashLit
actions:
- theme: brand
text: Get Started
link: /guide/getting-started
- theme: alt
text: Live Demo
link: https://demo.dashlit.cwec.dev
- theme: alt
text: View on GitHub
link: https://github.com/codewec/dashlit
features:
- title: Privacy
icon: 🔐
details: Offers secure authentication.
- title: Themes
icon: 🌗
details: Enjoy a light or dark theme – your choice!
- title: Grouping
icon: 🗂
details: Create custom service groups.
- title: Easy setup
icon: 👌
details: Does not use manual configuration files.
- title: Drag and drop
icon: ✨
details: Quickly organize links with a simple drag-and-drop interface.
- title: Docker
icon: 🐳
details: Optimized docker images for popular platforms.
- title: Free
icon: 🚀
details: Dashlit is completely free and open source.
- title: PWA
icon: 📲
details: Installable application.
---
## Screenshot

================================================
FILE: docs/license.md
================================================
# License
<!--@include: ../LICENSE-->
================================================
FILE: package.json
================================================
{
"name": "dashlit",
"private": true,
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"release": "vite build && changelogen --hideAuthorEmail --release --push"
},
"devDependencies": {
"@iconify/svelte": "^5.0.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.0.1",
"changelogen": "^0.6.1",
"flowbite": "^3.1.2",
"flowbite-svelte": "^1.6.4",
"flowbite-svelte-icons": "^2.2.0",
"postcss": "^8.5.4",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.12",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.2.6",
"vitepress": "^1.6.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"esbuild"
]
},
"dependencies": {
"@thisux/sveltednd": "^0.0.20",
"dotenv": "^16.6.0",
"jose": "^6.0.11",
"svelte-5-french-toast": "^2.0.4"
}
}
================================================
FILE: src/app.css
================================================
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin 'flowbite/plugin';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-primary-50: #fff5f2;
--color-primary-100: #fff1ee;
--color-primary-200: #ffe4de;
--color-primary-300: #ffd5cc;
--color-primary-400: #ffbcad;
--color-primary-500: #fe795d;
--color-primary-600: #ef562f;
--color-primary-700: #eb4f27;
--color-primary-800: #cc4522;
--color-primary-900: #a5371b;
--color-secondary-50: #f0f9ff;
--color-secondary-100: #e0f2fe;
--color-secondary-200: #bae6fd;
--color-secondary-300: #7dd3fc;
--color-secondary-400: #38bdf8;
--color-secondary-500: #0ea5e9;
--color-secondary-600: #0284c7;
--color-secondary-700: #0369a1;
--color-secondary-800: #075985;
--color-secondary-900: #0c4a6e;
}
@source "../node_modules/flowbite-svelte/dist";
@source "../node_modules/flowbite-svelte-icons/dist";
@layer base {
/* disable chrome cancel button */
input[type='search']::-webkit-search-cancel-button {
display: none;
}
}
.toaster {
.wrapper {
.base {
@apply bg-gray-100 dark:bg-slate-900 dark:text-gray-400;
}
}
}
================================================
FILE: src/app.d.ts
================================================
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Locals {
userAuthenticated: boolean;
}
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
================================================
FILE: src/app.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
<link rel="shortcut icon" href="%sveltekit.assets%/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="DashLit" />
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<link rel="stylesheet" href="%sveltekit.assets%/custom.css" />
</head>
<body
data-sveltekit-preload-data="hover"
class="bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-200"
>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
================================================
FILE: src/hooks.server.ts
================================================
import type { Handle } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import * as jose from 'jose';
import { hashString } from '$lib/helpers';
import { cookie_token_key } from '$lib';
import { getSecretKey } from '$lib/server/helper';
export const handle: Handle = async ({ event, resolve }) => {
if (!env.PASSWORD || env.PASSWORD?.length === 0) {
event.locals.userAuthenticated = true;
return resolve(event);
}
const token = event.cookies.get(cookie_token_key);
if (!token) {
return resolve(event);
}
const secret = await getSecretKey(env.PASSWORD);
await jose
.jwtVerify(token, new TextEncoder().encode(secret))
.then(() => {
event.locals.userAuthenticated = true;
})
.catch(() => {
event.cookies.delete(cookie_token_key, { path: '/' });
});
return resolve(event);
};
================================================
FILE: src/lib/components/actionButtons.svelte
================================================
<script lang="ts">
import { ActionType, type DeletionEntity } from '$lib/types';
import { Button } from 'flowbite-svelte';
import { EditOutline, CloseCircleOutline } from 'flowbite-svelte-icons';
const {
id,
handleHover,
handleClick
}: {
id: string;
handleHover: (id: string | undefined) => void;
handleClick: (type: ActionType) => void;
} = $props();
</script>
<div class="action-buttons inline-flex gap-1">
<Button
pill={true}
color="light"
onmouseenter={() => {
handleHover(id);
}}
onmouseleave={() => {
handleHover(undefined);
}}
onclick={() => {
handleClick(ActionType.EDIT);
}}
class="edit-button h-4 w-4 cursor-pointer p-3!"
><EditOutline class="h-4 w-4" color="gray" /></Button
>
<Button
pill={true}
color="light"
onmouseenter={() => {
handleHover(id);
}}
onmouseleave={() => {
handleHover(undefined);
}}
onclick={() => {
handleClick(ActionType.DELETE);
}}
class="delete-button h-4 w-4 cursor-pointer p-3!"
><CloseCircleOutline class="h-4 w-4" color="gray" /></Button
>
</div>
================================================
FILE: src/lib/components/dashboard.svelte
================================================
<script lang="ts">
import { ActionType, ShowUrlType, type Group, type Item } from '$lib/types';
import Icon from '@iconify/svelte';
import { droppable, draggable, type DragDropState } from '@thisux/sveltednd';
import { flip } from 'svelte/animate';
import { fade } from 'svelte/transition';
import ActionButtons from './actionButtons.svelte';
import { getIds, hasField, isUrlString } from '$lib/helpers';
import EmptyItem from './emptyItem.svelte';
import EmptyGroup from './emptyGroup.svelte';
import { newGroup, newItem } from '$lib/factory';
import { on } from 'svelte/events';
const {
editMode,
groups,
handleClickItem,
handleClickItemAction,
handleClickGroupAction
}: {
editMode: boolean;
groups: Group[];
handleClickItem: (item: Item) => void;
handleClickItemAction: (type: ActionType, groupId: string, item: Item) => void;
handleClickGroupAction: (type: ActionType, group: Group) => void;
} = $props();
let hoveredOnActionsEnitytId = $state<string | undefined>(undefined); // if hover on actions buttons on edit mode
let hoveredItemId = $state<string | undefined>(undefined); // if hover on item (not group) on not edit mode
let disableGroupsDrag = $state(true);
let disableItemDrag = $state(true);
$effect(() => {
disableGroupsDrag = !editMode;
disableItemDrag = !editMode;
});
const getHoverDescription = (groupId: string, item: Item) => {
if (!hoveredItemId) {
return item.description;
}
const ids = getIds(hoveredItemId);
if (!ids) {
return item.description;
}
if (ids.groupId === groupId && ids.itemId === item.id) {
if (item.showUrl === ShowUrlType.HOVER) {
return item.url;
}
}
return item.description;
};
const getDescription = (groupId: string, item: Item) => {
switch (item.showUrl) {
case ShowUrlType.NEVER:
return item.description;
case ShowUrlType.ALWAYS:
return item.description ? item.description : item.url;
case ShowUrlType.HOVER:
return getHoverDescription(groupId, item);
case ShowUrlType.DESC_EMPTY:
return item.description ? item.description : item.url;
default:
return item.description ? item.description : item.url;
}
};
const getUrl = (item: Item) => {
switch (item.showUrl) {
case ShowUrlType.NEVER:
return undefined;
case ShowUrlType.ALWAYS:
return item.description ? item.url : undefined;
case ShowUrlType.HOVER:
return undefined;
case ShowUrlType.DESC_EMPTY:
return undefined;
default:
return undefined;
}
};
const isDisabledGroupDrag = (id: string) => {
if (hoveredOnActionsEnitytId) {
const ids = getIds(hoveredOnActionsEnitytId);
if (ids && ids.groupId == id) {
return true;
}
}
return disableGroupsDrag;
};
const isDisabledItemDrag = (id: string) => {
if (id == hoveredOnActionsEnitytId) {
return true;
}
return disableItemDrag;
};
const isDisabledGroupDrop = (group: Group) => {
if (group.items.length === 0) {
return false;
}
return disableGroupsDrag;
};
const onDropInGroup = (state: DragDropState<Item | Group>) => {
const { draggedItem, sourceContainer, targetContainer } = state;
if (hasField(draggedItem, 'url')) {
state.targetContainer = `${targetContainer}-0`;
onDropInItem(state as DragDropState<Item>);
} else {
if (!targetContainer) {
console.log('Target container not found');
return;
}
const sourceIndex = groups.findIndex((t) => t.id === sourceContainer);
const targetIndex = groups.findIndex((t) => t.id === targetContainer);
if (sourceIndex === undefined || targetIndex === undefined) {
console.log('Source or target index not found');
return;
}
groups.splice(sourceIndex, 1);
groups.splice(targetIndex, 0, draggedItem);
}
};
function onDropInItem(state: DragDropState<Item>) {
const { draggedItem, sourceContainer, targetContainer } = state;
if (!targetContainer) {
console.log('Target container not found');
return;
}
const sourceIds = getIds(sourceContainer);
if (!sourceIds) {
console.log('Source IDs not found');
return;
}
const targetIds = getIds(targetContainer);
if (!targetIds) {
console.log('Target IDs not found');
return;
}
const sourceGroup = groups.find((g) => g.id === sourceIds.groupId);
const sourceIndex = sourceGroup?.items.findIndex((t) => t.id === sourceIds.itemId);
const targetGroup = groups.find((g) => g.id === targetIds.groupId);
const targetIndex = targetGroup?.items.findIndex((t) => t.id === targetIds.itemId);
if (sourceIndex === undefined || targetIndex === undefined) {
console.log('Source or target index not found');
return;
}
sourceGroup?.items.splice(sourceIndex, 1);
targetGroup?.items.splice(targetIndex, 0, draggedItem);
}
</script>
<div class="group-container grid grid-cols-1 gap-4">
{#if editMode}
<EmptyGroup handleClick={() => handleClickGroupAction(ActionType.CREATE, newGroup())} />
{/if}
{#each groups as group (`g_${group.id}`)}
<div
class:edit-mode={editMode}
class="group rounded-md bg-gray-50 p-4 shadow-sm ring-1 ring-gray-200 dark:bg-slate-900 dark:ring-slate-800"
use:draggable={{
container: group.id,
dragData: group,
disabled: isDisabledGroupDrag(group.id),
callbacks: {
onDragStart: () => (disableItemDrag = true),
onDragEnd: () => (disableItemDrag = false)
}
}}
use:droppable={{
dragData: group,
container: `${group.id}`,
disabled: isDisabledGroupDrop(group),
callbacks: {
onDrop: onDropInGroup
}
}}
>
<div class="mb-4 flex items-center justify-between">
<div class="inline-flex gap-2">
<h2 class="title font-semibold text-gray-900 capitalize dark:text-gray-200">
{group.title}
</h2>
{#if group.description}
<p class="description text-xs text-gray-500">{group.description}</p>
{/if}
</div>
{#if editMode}
<ActionButtons
id={`${group.id}-0`}
handleHover={(id) => {
hoveredOnActionsEnitytId = id;
}}
handleClick={(action) => handleClickGroupAction(action, group)}
/>
{/if}
</div>
<div
class="item-container grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
>
{#each group.items as item (`i_${item.id}`)}
<div
tabindex="0"
role="button"
onkeyup={(e) => {
if (e.key === 'Enter') {
handleClickItem(item);
}
}}
onmouseenter={() => {
if (editMode) {
hoveredItemId = undefined;
return;
}
hoveredItemId = `${group.id}-${item.id}`;
}}
onmouseleave={() => {
hoveredItemId = undefined;
}}
onclick={() => {
handleClickItem(item);
}}
use:draggable={{
container: `${group.id}-${item.id}`,
dragData: item,
disabled: isDisabledItemDrag(`${group.id}-${item.id}`),
callbacks: {
onDragStart: () => (disableGroupsDrag = true),
onDragEnd: () => (disableGroupsDrag = false)
}
}}
use:droppable={{
dragData: item,
disabled: disableItemDrag,
container: `${group.id}-${item.id}`,
callbacks: {
onDrop: onDropInItem
}
}}
animate:flip={{ duration: 200 }}
in:fade={{ duration: 150 }}
out:fade={{ duration: 150 }}
class="item svelte-dnd-touch-feedback"
>
<div class="flex items-center gap-2">
{#if item.icon}
<div class="h-14 w-19">
{#if isUrlString(item.icon)}
<img
src={item.icon}
alt={item.title}
class="h-full w-full rounded-full object-cover"
/>
{:else}
<Icon
color={item.iconColor ?? 'gray'}
icon={item.icon}
width={56}
height={56}
/>
{/if}
</div>
{/if}
<div class="w-full truncate">
<h3 class="title font-medium text-gray-900 dark:text-gray-100">
{item.title}
</h3>
{#if getDescription(group.id, item)}
<p class="description text-sm text-gray-500">
{getDescription(group.id, item)}
</p>
{/if}
{#if getUrl(item)}
<p class="url text-[10px] text-gray-400 dark:text-gray-500">
{getUrl(item)}
</p>
{/if}
</div>
</div>
<div class="absolute top-1 right-1">
{#if editMode}
<ActionButtons
id={`${group.id}-${item.id}`}
handleHover={(id) => {
hoveredOnActionsEnitytId = id;
}}
handleClick={(action) => handleClickItemAction(action, group.id, item)}
/>
{/if}
</div>
</div>
{/each}
{#if editMode}
<EmptyItem
id={`${group.id}-0`}
handleHover={(id) => {
hoveredOnActionsEnitytId = id;
}}
handleClick={() => handleClickItemAction(ActionType.CREATE, group.id, newItem())}
/>
{/if}
</div>
</div>
{/each}
</div>
<style lang="postcss">
@reference "$lib/../app.css";
:global(.dragging) {
@apply !opacity-50 !shadow-lg !ring-2 !ring-blue-400;
}
:global(.drag-over) {
@apply !bg-blue-50 !ring-2 !ring-blue-400 dark:!bg-slate-800 dark:ring-blue-600;
}
.item {
@apply relative rounded-lg bg-white p-3 shadow-sm ring-1 ring-gray-200 transition-all duration-200 dark:bg-black dark:ring-gray-800;
}
.item:not(.edit-mode) {
@apply cursor-pointer hover:shadow-md hover:ring-2 hover:ring-blue-300 dark:hover:ring-blue-900;
}
.edit-mode {
@apply cursor-move hover:shadow-md hover:ring-2 hover:ring-blue-200 dark:hover:ring-blue-900;
.item {
@apply cursor-move hover:shadow-md hover:ring-2 hover:ring-blue-200 dark:hover:ring-blue-900;
}
}
</style>
================================================
FILE: src/lib/components/emptyGroup.svelte
================================================
<script lang="ts">
import Button from 'flowbite-svelte/Button.svelte';
const { handleClick }: { handleClick: () => void } = $props();
</script>
<Button
onclick={handleClick}
color="light"
class="group-empty w-full cursor-pointer rounded-lg border border-dashed border-gray-300 bg-transparent p-3 text-center text-sm text-gray-500 ring-0 focus-within:ring-0 hover:border-gray-500"
>Add group</Button
>
================================================
FILE: src/lib/components/emptyItem.svelte
================================================
<script lang="ts">
import Button from 'flowbite-svelte/Button.svelte';
const {
id,
handleHover,
handleClick
}: {
id: string;
handleHover: (id: string | undefined) => void;
handleClick: () => void;
} = $props();
</script>
<Button
onmouseenter={() => {
handleHover(id);
}}
onmouseleave={() => {
handleHover(undefined);
}}
onclick={handleClick}
color="light"
class="item-empty cursor-pointer rounded-lg border border-dashed border-gray-300 bg-transparent p-3 text-center text-sm text-gray-500 ring-0 focus-within:ring-0 hover:border-gray-500"
>Add item</Button
>
================================================
FILE: src/lib/components/header.svelte
================================================
<script lang="ts">
import { ButtonGroup, GradientButton, Button, DarkMode } from 'flowbite-svelte';
let {
editMode,
canLogout,
handleSave,
handleEdit
}: { editMode: boolean; canLogout: boolean; handleSave: () => void; handleEdit: () => void } =
$props();
</script>
<div class="header mb-8 flex justify-between gap-2">
<h1 class="title text-2xl font-bold">DashLit</h1>
<div class="inline-flex items-center">
<DarkMode size="sm" class="theme-switcher cursor-pointer p-2" />
<ButtonGroup size="sm" class="*:ring-0!">
{#if editMode}
<GradientButton
role="button"
color="purpleToBlue"
class="cursor-pointer hover:text-white"
onclick={() => handleSave()}>Save</GradientButton
>
{:else}
<Button role="button" class="cursor-pointer" onclick={() => handleEdit()}>Edit</Button>
{/if}
{#if canLogout}
<Button data-sveltekit-preload-data="off" href="/logout">Logout</Button>
{/if}
</ButtonGroup>
</div>
</div>
================================================
FILE: src/lib/components/modalDelete.svelte
================================================
<script lang="ts">
import type { DeletionEntity } from '$lib/types';
import { Button } from 'flowbite-svelte';
import { ExclamationCircleOutline } from 'flowbite-svelte-icons';
import Modal from 'flowbite-svelte/Modal.svelte';
import { slide } from 'svelte/transition';
const {
entity,
handleClose,
handleConfirm
}: {
entity: DeletionEntity | undefined;
handleClose: () => void;
handleConfirm: () => void;
} = $props();
</script>
<Modal open={entity !== undefined} onclose={() => handleClose()} transition={slide} size="xs">
{#if entity}
<div class="text-center">
<ExclamationCircleOutline class="mx-auto mb-4 h-12 w-12 text-gray-400 dark:text-gray-200" />
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
Are you sure you want to delete "{entity.element.title}"?
</h3>
<Button color="red" onclick={() => handleConfirm()} class="me-2">Yes, I'm sure</Button>
<Button color="alternative" onclick={() => handleClose()}>No, cancel</Button>
</div>
{/if}
</Modal>
================================================
FILE: src/lib/components/modalFormGroup.svelte
================================================
<script lang="ts">
import type { Group } from '$lib/types';
import { Button, Input, Label, Modal } from 'flowbite-svelte';
import { slide } from 'svelte/transition';
const {
isOpen,
group,
handleClose
}: { isOpen: boolean; group: Group; handleClose: (group: Group | undefined) => void } = $props();
const form = $derived(group);
</script>
<Modal open={isOpen} onclose={() => handleClose(undefined)} transition={slide} size="xs">
<form
class="flex flex-col space-y-6 pt-4"
onsubmit={(e: SubmitEvent) => {
e.preventDefault();
handleClose(form);
}}
>
<Label class="space-y-2">
<span>Title</span>
<Input bind:value={form.title} type="text" name="title" placeholder="Title" required />
</Label>
<Label class="space-y-2">
<span>Description</span>
<Input
bind:value={form.description}
type="text"
name="description"
placeholder="Description"
/>
</Label>
<Button type="submit" class="w-full">Save</Button>
</form>
</Modal>
================================================
FILE: src/lib/components/modalFormItem.svelte
================================================
<script lang="ts">
import { ShowUrlType, type Item } from '$lib/types';
import {
Button,
ButtonGroup,
Checkbox,
Helper,
Input,
InputAddon,
Label,
Modal,
Select
} from 'flowbite-svelte';
import { slide } from 'svelte/transition';
const {
isOpen,
item,
handleClose
}: { isOpen: boolean; item: Item; handleClose: (item: Item | undefined) => void } = $props();
const urlTargetOptions = [
{ value: '_blank', name: 'New tab' },
{ value: '_self', name: 'Current tab' }
];
const showUrlOptions = [
{ value: ShowUrlType.NEVER, name: 'Never' },
{ value: ShowUrlType.ALWAYS, name: 'Always' },
{ value: ShowUrlType.DESC_EMPTY, name: 'If description is empty' },
{ value: ShowUrlType.HOVER, name: 'On hover' }
];
const form = $derived(item);
$effect(() => {
if (!form.target) {
form.target = '_blank';
}
if (!form.showUrl) {
form.showUrl = ShowUrlType.DESC_EMPTY;
}
});
</script>
<Modal open={isOpen} onclose={() => handleClose(undefined)} transition={slide} size="xs">
<form
class="flex flex-col space-y-6 pt-4"
onsubmit={(e: SubmitEvent) => {
e.preventDefault();
handleClose(form);
}}
>
<Label class="space-y-2">
<span>Title</span>
<Input bind:value={form.title} type="text" name="title" placeholder="Title" required />
</Label>
<Label class="space-y-2">
<span>Description</span>
<Input
bind:value={form.description}
type="text"
name="description"
placeholder="Description"
/>
</Label>
<div>
<Label for="url">Url</Label>
<ButtonGroup class="inline-flex w-full items-stretch">
<Input bind:value={form.url} type="text" name="url" placeholder="Url" required />
<Select
selectClass="min-w-30 !rounded-tl-none !rounded-bl-none border-l-0"
bind:value={form.target}
items={urlTargetOptions}
placeholder="Target"
/>
</ButtonGroup>
</div>
<Label class="space-y-2">
<span>Show Url</span>
<Select bind:value={form.showUrl} items={showUrlOptions} placeholder="When show url" />
</Label>
<div>
<Label for="icon">Icon</Label>
<ButtonGroup class="inline-flex w-full items-stretch">
<Input bind:value={form.icon} type="text" name="icon" placeholder="URL or Icon name" />
<span class="color-picker">
<Input
bind:value={form.iconColor}
defaultValue="#808080"
class="h-full w-20 !rounded-tl-none !rounded-bl-none border-l-0"
type="color"
name="iconColor"
placeholder="URL or Icon name"
/>
</span>
</ButtonGroup>
<Helper class="text-sm">
URL or Icon name from <a
target="_blank"
href="https://icon-sets.iconify.design/"
class="text-primary-600 dark:text-primary-500 font-medium hover:underline">Iconify</a
>. The color is applied only to the <b>Iconify</b> icon.
</Helper>
</div>
<Button type="submit" class="w-full">Save</Button>
</form>
</Modal>
================================================
FILE: src/lib/factory.ts
================================================
import { generateRandomString } from './helpers';
import type { Group, Item } from './types';
export const newGroup = (): Group => {
return {
id: generateRandomString(10),
title: '',
items: []
};
};
export const newItem = (): Item => {
return {
id: generateRandomString(10),
title: '',
description: '',
url: ''
};
};
================================================
FILE: src/lib/helpers.ts
================================================
import type { Ids } from './types';
export const hasField = <T>(data: any, key: string): data is T => {
return key in data;
};
export const getIds = (str: string): Ids | undefined => {
const parts = str.split('-');
if (parts.length !== 2) {
return undefined;
}
return {
groupId: parts[0],
itemId: parts[1]
};
};
export const hashString = async (message: string): Promise<string> => {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hashHex;
};
export const generateRandomString = (length: number) => {
return Math.random()
.toString(36)
.substring(2, 2 + length);
};
export const isUrlString = (str: string): boolean => {
if (!str) {
return false;
}
const urlRegex = /^(ftp|http|https):\/\/[^ "]+$/;
return urlRegex.test(str);
};
================================================
FILE: src/lib/index.ts
================================================
export const page_title = 'Dashlit';
export const default_dashboard = 'dashboard.json';
export const data_path = '/app/data';
export const cookie_token_key = 'token';
================================================
FILE: src/lib/server/helper.ts
================================================
import { env } from '$env/dynamic/private';
import { hashString } from '$lib/helpers';
import currentPackage from '../../../package.json';
import { default_dashboard, data_path } from '$lib';
export const getSecretKey = async (password: string) => {
return (env.SECRET_KEY?.length ?? 0 > 0) ? env.SECRET_KEY : await hashString(password);
};
export const getVersion = () => {
return currentPackage.version || '0.0.0';
};
export const dataPath = () => {
return env.DATA_PATH ?? data_path;
};
export const filePath = () => {
return `${dataPath()}/${default_dashboard}`;
};
================================================
FILE: src/lib/styles/dnd.css
================================================
/* Base draggable styles */
.svelte-dnd-draggable {
touch-action: none; /* Prevents touch scrolling while dragging */
user-select: none; /* Prevents text selection during drag */
}
/* Active dragging state */
.svelte-dnd-dragging {
opacity: 0.5;
cursor: grabbing;
}
/* Draggable hover state */
.svelte-dnd-draggable:hover {
cursor: grab;
}
/* Droppable area styles */
.svelte-dnd-droppable {
position: relative;
}
/* Active drop target */
.svelte-dnd-drop-target {
outline: 2px dashed #4caf50;
}
/* Invalid drop target */
.svelte-dnd-invalid-target {
outline: 2px dashed #f44336;
}
/* Drop preview/placeholder */
.svelte-dnd-placeholder {
border: 2px dashed #9e9e9e;
}
/* Media queries for responsive design */
@media (max-width: 600px) {
.svelte-dnd-draggable {
width: 100%;
touch-action: none; /* Prevents scrolling during drag */
}
.svelte-dnd-droppable {
padding: 10px;
}
}
================================================
FILE: src/lib/types.ts
================================================
export enum ActionType {
CREATE = 'create',
DELETE = 'delete',
EDIT = 'edit'
}
export enum ShowUrlType {
NEVER = 'never',
ALWAYS = 'always',
DESC_EMPTY = 'empty_desc',
HOVER = 'hover'
}
export interface Item {
id: string;
title: string;
url: string;
showUrl?: ShowUrlType;
target?: string;
description?: string;
icon?: string;
iconColor?: string;
}
export interface Group {
id: string;
title: string;
description?: string;
items: Item[];
}
export interface Dashboard {
version: string;
groups: Group[];
}
export interface Ids {
groupId: string;
itemId: string;
}
export type DeletionEntity = {
ids: Ids;
element: Group | Item;
};
export type EditableItem = {
groupId: string;
item: Item;
};
================================================
FILE: src/routes/(auth)/login/+page.server.ts
================================================
import type { PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import * as jose from 'jose';
import { cookie_token_key } from '$lib';
import { getSecretKey } from '$lib/server/helper';
export const load: PageServerLoad = async (event) => {
if (event.locals.userAuthenticated) {
return redirect(302, '/');
}
return {};
};
export const actions = {
login: async ({ request, cookies }) => {
const form = await request.formData();
let password = String(form.get('password'));
if (!password) {
return fail(400, { error: 'Password is required' });
}
if (password !== env.PASSWORD) {
return fail(401, { error: 'Invalid password' });
}
const secretKey = await getSecretKey(password);
const sign = new TextEncoder().encode(secretKey);
const token = await new jose.SignJWT()
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('4weeks')
.sign(sign);
cookies.set(cookie_token_key, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 30
});
redirect(302, '/');
}
};
================================================
FILE: src/routes/(auth)/login/+page.svelte
================================================
<script lang="ts">
import { enhance } from '$app/forms';
import toast from 'svelte-5-french-toast';
import { page_title } from '$lib';
import { Card, Button, Label, Input, Helper, DarkMode } from 'flowbite-svelte';
</script>
<svelte:head>
<title>{page_title}</title>
</svelte:head>
<div
class="flex h-screen items-center justify-center bg-gradient-to-r from-blue-50 via-indigo-50 to-sky-50 p-4 dark:from-slate-950 dark:via-gray-950 dark:to-zinc-950"
>
<DarkMode class="absolute top-4 right-4 cursor-pointer" />
<Card class="p-4 sm:p-6 md:p-8">
<form
method="POST"
use:enhance={() => {
const toastId = toast.loading('Checking...');
return async ({ result, update }) => {
await update();
if (result.type === 'failure') {
const message = (result.data?.error as string) ?? 'An error occurred';
toast.error(message, { id: toastId });
} else {
toast.success('Welcome back!', { icon: '👋', id: toastId });
}
};
}}
action="?/login"
class="flex flex-col space-y-6"
>
<Label class="space-y-2">
<span>Your password</span>
<Input type="password" name="password" placeholder="•••••" required />
</Label>
<Button type="submit" class="w-full">Login</Button>
</form>
</Card>
</div>
================================================
FILE: src/routes/(auth)/logout/+page.server.ts
================================================
import { cookie_token_key } from '$lib';
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => {
event.cookies.delete(cookie_token_key, { path: '/' });
event.locals.userAuthenticated = false;
return redirect(302, '/login');
};
================================================
FILE: src/routes/+error.svelte
================================================
<script>
import { page } from '$app/state';
import { DarkMode } from 'flowbite-svelte';
</script>
<svelte:head>
<title>{page.status} - error</title>
</svelte:head>
<DarkMode class="absolute top-4 right-4 cursor-pointer" />
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform text-center">
<h1 class="mb-4 text-xl font-extrabold">
{page.status} - {page.error?.message ?? 'Ошибка'}
</h1>
<a
href="/"
onclick={(e) => {
e.preventDefault();
window.history.back();
}}
class="text-blue-500 hover:underline">Go to Back</a
>
</div>
================================================
FILE: src/routes/+layout.server.ts
================================================
import type { LayoutServerLoad } from './$types';
import { env } from '$env/dynamic/private';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
envOrigin: env.ORIGIN ?? '',
userAuthenticated: locals.userAuthenticated
};
};
================================================
FILE: src/routes/+layout.svelte
================================================
<script lang="ts">
import { Toaster } from 'svelte-5-french-toast';
import { page } from '$app/state';
import '../app.css';
let { children, data } = $props();
</script>
<Toaster />
{#if data.envOrigin !== page.url.origin}
<div class="w-full bg-red-600 p-2 text-center text-white">
<p>
Invalid ORIGIN found. Please specify ORIGIN={page.url.origin}. Check the
<a
target="_blank"
class="underline"
href="https://dashlit.cwec.dev/guide/getting-started.html">Docs</a
>.
</p>
</div>
{/if}
{@render children()}
================================================
FILE: src/routes/+page.server.ts
================================================
import type { PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import fs from 'node:fs/promises';
import { env } from '$env/dynamic/private';
import type { Dashboard } from '$lib/types';
import { dataPath, filePath, getVersion } from '$lib/server/helper';
export const load: PageServerLoad = async (event) => {
if (!event.locals.userAuthenticated) {
return redirect(302, '/login');
}
const data = await fs.readFile(filePath(), { encoding: 'utf8' }).catch(() => {
console.log(`File ${filePath()} not found`);
return '{}';
});
const dashboard: Dashboard = JSON.parse(data);
return {
groups: dashboard.groups,
canLogout: (env.PASSWORD?.length ?? 0) > 0,
isDemoMode: env.DEMO_MODE === 'true'
};
};
export const actions = {
default: async ({ locals, request }) => {
if (!locals.userAuthenticated) {
return redirect(302, '/login');
}
const json = await request.json();
await fs.mkdir(dataPath(), { recursive: true }).catch(console.error);
await fs
.writeFile(filePath(), JSON.stringify({ version: getVersion(), groups: json }))
.catch((error) => {
console.log(`Cant write ${filePath()}`);
return fail(500);
});
return { status: 'ok' };
}
};
================================================
FILE: src/routes/+page.svelte
================================================
<script lang="ts">
import '$lib/styles/dnd.css';
import {
type Item,
type Group,
type DeletionEntity,
ActionType,
type EditableItem
} from '$lib/types';
import Header from '$lib/components/header.svelte';
import Dashboard from '$lib/components/dashboard.svelte';
import ModalDelete from '$lib/components/modalDelete.svelte';
import ModalFormItem from '$lib/components/modalFormItem.svelte';
import ModalFormGroup from '$lib/components/modalFormGroup.svelte';
import { newGroup, newItem } from '$lib/factory';
import { page_title } from '$lib';
import toast from 'svelte-5-french-toast';
let { data } = $props();
let groups = $state(data.groups ?? []);
let editMode = $state(false);
let deletionEntity = $state<DeletionEntity | undefined>(undefined);
let editableGroup = $state<Group | undefined>(undefined);
let editableItem = $state<EditableItem | undefined>(undefined);
$effect(() => {
if (groups.length === 0) {
editMode = true;
}
});
// dashboard
const handleSaveDashboard = async () => {
if (data.isDemoMode) {
toast.success('Demo mode is enabled. Changes are not saved.');
editMode = false;
return;
}
const toastId = toast.loading('Saving...');
await fetch('', {
method: 'POST',
body: JSON.stringify(groups)
})
.then(() => {
editMode = false;
toast.success('Saved!', { icon: '✅', id: toastId });
})
.catch(() => {
toast.error('Error saving dashboard', { icon: '⚠️', id: toastId });
});
};
const handleDeleteEntity = () => {
if (deletionEntity === undefined) {
return;
}
if (deletionEntity.ids.groupId.length === 0 && deletionEntity.ids.itemId.length === 0) {
deletionEntity = undefined;
return;
}
const isGroup = deletionEntity.ids.itemId.length === 0;
if (isGroup) {
const groupIndex = groups.findIndex((s) => s.id === deletionEntity?.ids.groupId);
if (groupIndex === undefined) {
console.log('Group index not found');
return;
}
groups.splice(groupIndex, 1);
} else {
const group = groups.find((g) => g.id === deletionEntity?.ids.groupId);
const itemIndex = group?.items.findIndex((i) => i.id === deletionEntity?.ids.itemId);
if (itemIndex === undefined) {
console.log('Item index not found');
return;
}
group?.items.splice(itemIndex, 1);
}
deletionEntity = undefined;
};
const handleSaveGroup = (group: Group) => {
let g = groups.find((g) => g.id === group.id);
if (g) {
g.title = group.title;
g.description = group.description;
} else {
groups.unshift(group);
}
editableGroup = undefined;
};
const handleSaveItem = (groupId: string, item: Item) => {
let group = groups.find((g) => g.id === groupId);
const i = group?.items.find((i) => i.id === item.id);
if (i) {
i.title = item.title;
i.description = item.description;
i.url = item.url;
i.icon = item.icon;
i.iconColor = item.iconColor;
i.target = item.target;
i.showUrl = item.showUrl;
} else {
group?.items.push(item);
}
editableItem = undefined;
};
// groups
const handleActionGroup = (type: ActionType, group: Group) => {
switch (type) {
case ActionType.CREATE:
editableGroup = group;
break;
case ActionType.EDIT:
editableGroup = { ...group };
break;
case ActionType.DELETE:
deletionEntity = {
ids: { groupId: group.id, itemId: '' },
element: { ...group }
};
break;
}
};
// items
const handleActionItem = (type: ActionType, groupId: string, item: Item) => {
switch (type) {
case ActionType.CREATE:
editableItem = {
groupId: groupId,
item: item
};
break;
case ActionType.EDIT:
editableItem = {
groupId: groupId,
item: { ...item }
};
break;
case ActionType.DELETE:
deletionEntity = {
ids: { groupId: groupId, itemId: item.id },
element: { ...item }
};
break;
}
};
const handleClickItem = (item: Item) => {
if (editMode) {
return;
}
window.open(item.url, item.target ?? '_blank');
};
</script>
<svelte:head>
<title>{page_title}</title>
</svelte:head>
<div class="dashboard p-4">
<Header
{editMode}
canLogout={data.canLogout}
handleSave={handleSaveDashboard}
handleEdit={() => (editMode = !editMode)}
/>
<Dashboard
{editMode}
{groups}
{handleClickItem}
handleClickItemAction={handleActionItem}
handleClickGroupAction={handleActionGroup}
/>
</div>
<ModalDelete
entity={deletionEntity}
handleClose={() => (deletionEntity = undefined)}
handleConfirm={handleDeleteEntity}
/>
<ModalFormGroup
isOpen={editableGroup !== undefined}
group={editableGroup ?? newGroup()}
handleClose={(group) => {
if (group) {
handleSaveGroup(group);
} else {
editableGroup = undefined;
}
}}
/>
<ModalFormItem
isOpen={editableItem !== undefined}
item={editableItem?.item ?? newItem()}
handleClose={(item) => {
if (item && editableItem) {
handleSaveItem(editableItem.groupId, item);
} else {
editableItem = undefined;
}
}}
/>
================================================
FILE: src/routes/custom.css/+server.ts
================================================
import { dataPath, filePath } from '$lib/server/helper';
import { type RequestHandler } from '@sveltejs/kit';
import fs from 'node:fs/promises';
export const GET: RequestHandler = async ({ url }) => {
const fileName = `${dataPath()}/custom.css`;
const data = await fs.readFile(fileName, { encoding: 'utf8' }).catch(() => {
console.log(`File ${fileName} not found`);
return '';
});
return new Response(String(data), {
headers: {
'Content-type': 'text/css'
}
});
};
================================================
FILE: static/site.webmanifest
================================================
{
"name": "DashLit",
"short_name": "DashLit",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
================================================
FILE: svelte.config.js
================================================
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
precompress: true,
envPrefix: ''
})
}
};
export default config;
================================================
FILE: tsconfig.json
================================================
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}
================================================
FILE: vite.config.ts
================================================
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
allowedHosts: true
}
});
gitextract_nb5g7m7j/ ├── .env.example ├── .github/ │ └── workflows/ │ ├── docs.yml │ ├── push-main.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── docs/ │ ├── .gitignore │ ├── .vitepress/ │ │ ├── config.ts │ │ └── utils/ │ │ └── index.ts │ ├── CNAME │ ├── changelog.md │ ├── contributing.md │ ├── guide/ │ │ ├── getting-started.md │ │ └── what-is.md │ ├── index.md │ └── license.md ├── package.json ├── src/ │ ├── app.css │ ├── app.d.ts │ ├── app.html │ ├── hooks.server.ts │ ├── lib/ │ │ ├── components/ │ │ │ ├── actionButtons.svelte │ │ │ ├── dashboard.svelte │ │ │ ├── emptyGroup.svelte │ │ │ ├── emptyItem.svelte │ │ │ ├── header.svelte │ │ │ ├── modalDelete.svelte │ │ │ ├── modalFormGroup.svelte │ │ │ └── modalFormItem.svelte │ │ ├── factory.ts │ │ ├── helpers.ts │ │ ├── index.ts │ │ ├── server/ │ │ │ └── helper.ts │ │ ├── styles/ │ │ │ └── dnd.css │ │ └── types.ts │ └── routes/ │ ├── (auth)/ │ │ ├── login/ │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ └── logout/ │ │ └── +page.server.ts │ ├── +error.svelte │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +page.server.ts │ ├── +page.svelte │ └── custom.css/ │ └── +server.ts ├── static/ │ └── site.webmanifest ├── svelte.config.js ├── tsconfig.json └── vite.config.ts
SYMBOL INDEX (10 symbols across 3 files)
FILE: docs/.vitepress/utils/index.ts
function getVersion (line 3) | function getVersion() {
FILE: src/app.d.ts
type Locals (line 5) | interface Locals {
FILE: src/lib/types.ts
type ActionType (line 1) | enum ActionType {
type ShowUrlType (line 7) | enum ShowUrlType {
type Item (line 14) | interface Item {
type Group (line 25) | interface Group {
type Dashboard (line 32) | interface Dashboard {
type Ids (line 37) | interface Ids {
type DeletionEntity (line 42) | type DeletionEntity = {
type EditableItem (line 47) | type EditableItem = {
Condensed preview — 54 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (61K chars).
[
{
"path": ".env.example",
"chars": 66,
"preview": "PASSWORD=\"password\"\nSECRET_KEY=\"6ft0ryZAeb3DdFIeEwi4uv5zI69GE2ez\"\n"
},
{
"path": ".github/workflows/docs.yml",
"chars": 1428,
"preview": "name: Deploy Documentation site to GitHub Pages\n\non:\n push:\n paths:\n - 'docs/**'\n - '.github/workflows/doc"
},
{
"path": ".github/workflows/push-main.yml",
"chars": 1319,
"preview": "name: Push Main\n\nenv:\n REGISTRY: ghcr.io\n IMAGE_NAME: ${{ github.repository }}\n\non:\n push:\n paths-ignore:\n - "
},
{
"path": ".github/workflows/release.yml",
"chars": 1186,
"preview": "name: Release Docker Image\n\nenv:\n REGISTRY: ghcr.io\n IMAGE_NAME: ${{ github.repository }}\n\non:\n push:\n tags:\n "
},
{
"path": ".gitignore",
"chars": 225,
"preview": "node_modules\n\n# Output\n.output\n.vercel\n.netlify\n.wrangler\n/.svelte-kit\n/build\n/data\n\n# OS\n.DS_Store\nThumbs.db\n\n# Env\n.en"
},
{
"path": ".npmrc",
"chars": 19,
"preview": "engine-strict=true\n"
},
{
"path": ".prettierrc",
"chars": 256,
"preview": "{\n\t\"useTabs\": true,\n\t\"singleQuote\": true,\n\t\"trailingComma\": \"none\",\n\t\"printWidth\": 100,\n\t\"plugins\": [\"prettier-plugin-sv"
},
{
"path": "CHANGELOG.md",
"chars": 841,
"preview": "# Changelog\n\n\n## v0.0.6\n\n[compare changes](https://github.com/codewec/dashlit/compare/v0.0.5...v0.0.6)\n\n### 🚀 Enhancemen"
},
{
"path": "Dockerfile",
"chars": 421,
"preview": "FROM node:22 AS builder\nWORKDIR /app\n\nCOPY . .\n\nRUN mv .env.example .env\nRUN corepack enable && corepack prepare pnpm@la"
},
{
"path": "LICENSE",
"chars": 1064,
"preview": "MIT License\n\nCopyright (c) 2025 codewec\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof"
},
{
"path": "README.md",
"chars": 2688,
"preview": "<h1 align=\"center\">DashLit</h1>\n<p align=\"center\">\n <i>DashLit is a simple, self-hosted Startpage solution. It’s incr"
},
{
"path": "docker-compose.yml",
"chars": 288,
"preview": "services:\n app:\n container_name: dashlit-app\n image: ghcr.io/codewec/dashlit:latest\n restart: unless-stopped\n "
},
{
"path": "docs/.gitignore",
"chars": 33,
"preview": ".vitepress/cache\n.vitepress/dist\n"
},
{
"path": "docs/.vitepress/config.ts",
"chars": 1539,
"preview": "import { defineConfig } from 'vitepress';\nimport { getVersion } from './utils';\n\n// https://vitepress.dev/reference/site"
},
{
"path": "docs/.vitepress/utils/index.ts",
"chars": 129,
"preview": "import currentPackage from '../../../package.json';\n\nexport function getVersion() {\n\treturn currentPackage.version || '0"
},
{
"path": "docs/CNAME",
"chars": 17,
"preview": "dashlit.cwec.dev\n"
},
{
"path": "docs/changelog.md",
"chars": 56,
"preview": "---\nsearch: false\n---\n\n<!--@include: ../CHANGELOG.md-->\n"
},
{
"path": "docs/contributing.md",
"chars": 724,
"preview": "# Contributing\n\nFirst of all, thank you for deciding to contribute to the project. There are several ways to help the pr"
},
{
"path": "docs/guide/getting-started.md",
"chars": 75,
"preview": "# 🚀 Getting started\n\n<!--@include: ../../README.md#docker-configuration-->\n"
},
{
"path": "docs/guide/what-is.md",
"chars": 621,
"preview": "# What is DashLit?\n\n`DashLit` is a flexible tool for creating homepages, especially useful for those managing their own "
},
{
"path": "docs/index.md",
"chars": 1182,
"preview": "---\nlayout: home\n\nhero:\n name: \"DashLit\"\n text: \"Personal home page\"\n tagline: A simple solution for self-hosting you"
},
{
"path": "docs/license.md",
"chars": 39,
"preview": "# License\n\n<!--@include: ../LICENSE-->\n"
},
{
"path": "package.json",
"chars": 1494,
"preview": "{\n\t\"name\": \"dashlit\",\n\t\"private\": true,\n\t\"version\": \"0.0.6\",\n\t\"type\": \"module\",\n\t\"scripts\": {\n\t\t\"dev\": \"vite dev\",\n\t\t\"bu"
},
{
"path": "src/app.css",
"chars": 1113,
"preview": "@import 'tailwindcss';\n@plugin '@tailwindcss/forms';\n@plugin 'flowbite/plugin';\n\n@custom-variant dark (&:where(.dark, .d"
},
{
"path": "src/app.d.ts",
"chars": 330,
"preview": "// See https://svelte.dev/docs/kit/types#app.d.ts\n// for information about these interfaces\ndeclare global {\n\tnamespace "
},
{
"path": "src/app.html",
"chars": 902,
"preview": "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<link rel=\"icon\" type=\"image/png\" href=\"%sveltekit"
},
{
"path": "src/hooks.server.ts",
"chars": 823,
"preview": "import type { Handle } from '@sveltejs/kit';\nimport { env } from '$env/dynamic/private';\nimport * as jose from 'jose';\ni"
},
{
"path": "src/lib/components/actionButtons.svelte",
"chars": 1067,
"preview": "<script lang=\"ts\">\n\timport { ActionType, type DeletionEntity } from '$lib/types';\n\timport { Button } from 'flowbite-svel"
},
{
"path": "src/lib/components/dashboard.svelte",
"chars": 9742,
"preview": "<script lang=\"ts\">\n\timport { ActionType, ShowUrlType, type Group, type Item } from '$lib/types';\n\timport Icon from '@ico"
},
{
"path": "src/lib/components/emptyGroup.svelte",
"chars": 409,
"preview": "<script lang=\"ts\">\n\timport Button from 'flowbite-svelte/Button.svelte';\n\n\tconst { handleClick }: { handleClick: () => vo"
},
{
"path": "src/lib/components/emptyItem.svelte",
"chars": 589,
"preview": "<script lang=\"ts\">\n\timport Button from 'flowbite-svelte/Button.svelte';\n\tconst {\n\t\tid,\n\t\thandleHover,\n\t\thandleClick\n\t}: "
},
{
"path": "src/lib/components/header.svelte",
"chars": 974,
"preview": "<script lang=\"ts\">\n\timport { ButtonGroup, GradientButton, Button, DarkMode } from 'flowbite-svelte';\n\n\tlet {\n\t\teditMode,"
},
{
"path": "src/lib/components/modalDelete.svelte",
"chars": 1027,
"preview": "<script lang=\"ts\">\n\timport type { DeletionEntity } from '$lib/types';\n\timport { Button } from 'flowbite-svelte';\n\timport"
},
{
"path": "src/lib/components/modalFormGroup.svelte",
"chars": 986,
"preview": "<script lang=\"ts\">\n\timport type { Group } from '$lib/types';\n\timport { Button, Input, Label, Modal } from 'flowbite-svel"
},
{
"path": "src/lib/components/modalFormItem.svelte",
"chars": 2897,
"preview": "<script lang=\"ts\">\n\timport { ShowUrlType, type Item } from '$lib/types';\n\timport {\n\t\tButton,\n\t\tButtonGroup,\n\t\tCheckbox,\n"
},
{
"path": "src/lib/factory.ts",
"chars": 337,
"preview": "import { generateRandomString } from './helpers';\nimport type { Group, Item } from './types';\n\nexport const newGroup = ("
},
{
"path": "src/lib/helpers.ts",
"chars": 982,
"preview": "import type { Ids } from './types';\n\nexport const hasField = <T>(data: any, key: string): data is T => {\n\treturn key in "
},
{
"path": "src/lib/index.ts",
"chars": 167,
"preview": "export const page_title = 'Dashlit';\nexport const default_dashboard = 'dashboard.json';\nexport const data_path = '/app/d"
},
{
"path": "src/lib/server/helper.ts",
"chars": 578,
"preview": "import { env } from '$env/dynamic/private';\nimport { hashString } from '$lib/helpers';\nimport currentPackage from '../.."
},
{
"path": "src/lib/styles/dnd.css",
"chars": 962,
"preview": "/* Base draggable styles */\n.svelte-dnd-draggable {\n touch-action: none; /* Prevents touch scrolling while dragging *"
},
{
"path": "src/lib/types.ts",
"chars": 725,
"preview": "export enum ActionType {\n\tCREATE = 'create',\n\tDELETE = 'delete',\n\tEDIT = 'edit'\n}\n\nexport enum ShowUrlType {\n\tNEVER = 'n"
},
{
"path": "src/routes/(auth)/login/+page.server.ts",
"chars": 1194,
"preview": "import type { PageServerLoad } from './$types';\nimport { fail, redirect } from '@sveltejs/kit';\nimport { env } from '$en"
},
{
"path": "src/routes/(auth)/login/+page.svelte",
"chars": 1264,
"preview": "<script lang=\"ts\">\n\timport { enhance } from '$app/forms';\n\timport toast from 'svelte-5-french-toast';\n\timport { page_tit"
},
{
"path": "src/routes/(auth)/logout/+page.server.ts",
"chars": 320,
"preview": "import { cookie_token_key } from '$lib';\nimport type { PageServerLoad } from './$types';\nimport { redirect } from '@svel"
},
{
"path": "src/routes/+error.svelte",
"chars": 578,
"preview": "<script>\n\timport { page } from '$app/state';\n\timport { DarkMode } from 'flowbite-svelte';\n</script>\n\n<svelte:head>\n\t<tit"
},
{
"path": "src/routes/+layout.server.ts",
"chars": 251,
"preview": "import type { LayoutServerLoad } from './$types';\nimport { env } from '$env/dynamic/private';\n\nexport const load: Layout"
},
{
"path": "src/routes/+layout.svelte",
"chars": 536,
"preview": "<script lang=\"ts\">\n\timport { Toaster } from 'svelte-5-french-toast';\n\timport { page } from '$app/state';\n\timport '../app"
},
{
"path": "src/routes/+page.server.ts",
"chars": 1224,
"preview": "import type { PageServerLoad } from './$types';\nimport { fail, redirect } from '@sveltejs/kit';\nimport fs from 'node:fs/"
},
{
"path": "src/routes/+page.svelte",
"chars": 4998,
"preview": "<script lang=\"ts\">\n\timport '$lib/styles/dnd.css';\n\timport {\n\t\ttype Item,\n\t\ttype Group,\n\t\ttype DeletionEntity,\n\t\tActionTy"
},
{
"path": "src/routes/custom.css/+server.ts",
"chars": 482,
"preview": "import { dataPath, filePath } from '$lib/server/helper';\nimport { type RequestHandler } from '@sveltejs/kit';\nimport fs "
},
{
"path": "static/site.webmanifest",
"chars": 435,
"preview": "{\n \"name\": \"DashLit\",\n \"short_name\": \"DashLit\",\n \"icons\": [\n {\n \"src\": \"/web-app-manifest-192x192.png\",\n "
},
{
"path": "svelte.config.js",
"chars": 278,
"preview": "import adapter from '@sveltejs/adapter-node';\nimport { vitePreprocess } from '@sveltejs/vite-plugin-svelte';\n\nconst conf"
},
{
"path": "tsconfig.json",
"chars": 649,
"preview": "{\n\t\"extends\": \"./.svelte-kit/tsconfig.json\",\n\t\"compilerOptions\": {\n\t\t\"allowJs\": true,\n\t\t\"checkJs\": true,\n\t\t\"esModuleInte"
},
{
"path": "vite.config.ts",
"chars": 240,
"preview": "import tailwindcss from '@tailwindcss/vite';\nimport { sveltekit } from '@sveltejs/kit/vite';\nimport { defineConfig } fro"
}
]
About this extraction
This page contains the full source code of the codewec/dashlit GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 54 files (51.5 KB), approximately 16.3k tokens, and a symbol index with 10 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.