feat: initial features

This commit is contained in:
Khairul Hidayat 2024-04-13 16:04:32 +07:00
commit e27b047f65
19 changed files with 557 additions and 0 deletions

1
.bun-version Normal file
View File

@ -0,0 +1 @@
v1.1.3

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
# Static assets/page directory
SERVE_STATIC=

179
.gitignore vendored Normal file
View File

@ -0,0 +1,179 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
storage/*
!.gitkeep
public/

42
README.md Normal file
View File

@ -0,0 +1,42 @@
# Cebol
URL Shortener
## Setup
To install dependencies:
```bash
bun install
```
### Development:
```bash
bun dev
```
### Production:
Build:
```bash
bun run build
```
Start:
```bash
bun run start
```
or with pm2
```
bun install -g pm2
pm2 start bun --name cebol -- run start
```
---
This project was created using `bun init` in bun v1.0.33. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

BIN
bun.lockb Executable file

Binary file not shown.

20
index.ts Normal file
View File

@ -0,0 +1,20 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import router from "./routers/index";
import { initDb } from "./lib/database";
initDb();
const app = new Hono();
if (process.env.SERVE_STATIC) {
app.use(
serveStatic({
root: process.env.SERVE_STATIC,
})
);
}
app.route("/", router);
export default app;

18
lib/database.ts Normal file
View File

@ -0,0 +1,18 @@
import { Database } from "bun:sqlite";
const dbPath = process.cwd() + "/storage/database.sqlite";
const db = new Database(dbPath);
export const initDb = () => {
db.exec(
`CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alias TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
clicks INTEGER DEFAULT 0,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
)`
);
};
export default db;

5
lib/utils.ts Normal file
View File

@ -0,0 +1,5 @@
import crypto from "node:crypto";
export const randomChars = (length = 8) => {
return crypto.randomBytes(length / 2).toString("hex");
};

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "cebol",
"module": "index.ts",
"type": "module",
"scripts": {
"dev": "bun run --watch index.ts",
"build": "bun build index.ts --outdir ./dist --minify --target=bun",
"start": "bun run dist/index.js"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"hono": "^4.2.3"
}
}

63
routers/cebol.tsx Normal file
View File

@ -0,0 +1,63 @@
import { Hono } from "hono";
import HomePage from "../views/pages/home";
import LinksSection from "../views/sections/links";
import db from "../lib/database";
import { randomChars } from "../lib/utils";
import type { Link } from "../types/link";
const router = new Hono();
router.get("/", (c) => {
const links = db
.query("SELECT * FROM links ORDER BY id DESC")
.all() as Link[];
return c.html(<HomePage links={links} />);
});
router.post("/", async (c) => {
let message = "Link created successfully!";
let isSuccess = true;
const body = (await c.req.parseBody()) as any;
const alias: string = body.alias?.length > 0 ? body.alias : randomChars(4);
const url: string = !body.url.startsWith("http")
? `https://${body.url}`
: body.url;
const aliasUrl = `rul.sh/${alias}`;
try {
const stmt = db.query(
"INSERT INTO links (alias, url) VALUES (?, ?) RETURNING id"
);
const result = stmt.get(alias, url) as any;
if (!result?.id) {
}
} catch (err) {
message = (err as Error).message;
isSuccess = false;
if (message.includes("UNIQUE constraint failed: links.alias")) {
message = "Alias is used!";
}
}
return c.html(
<article>
<p>{message}</p>
{isSuccess && <input readonly value={aliasUrl} onclick="this.select()" />}
</article>
);
});
router.delete("/:id", (c) => {
const id = c.req.param("id");
const stmt = db.query("DELETE FROM links WHERE id = ?");
stmt.run(id);
const links = db
.query("SELECT * FROM links ORDER BY id DESC")
.all() as Link[];
return c.html(<LinksSection links={links} />);
});
export default router;

29
routers/index.tsx Normal file
View File

@ -0,0 +1,29 @@
import { Hono } from "hono";
import cebol from "./cebol";
import db from "../lib/database";
import type { Link } from "../types/link";
import NotFoundPage from "../views/pages/not-found";
const router = new Hono();
router.route("/cebol", cebol);
router.get("/:alias", (c) => {
const alias = c.req.param("alias");
const link = db
.query("SELECT * FROM links WHERE alias = ? LIMIT 1")
.get(alias) as Link;
if (!link) {
return c.html(<NotFoundPage />, 404);
}
db.query("UPDATE links SET clicks = clicks + 1 WHERE id = ?").run(link.id);
return c.redirect(link.url);
});
router.get("*", (c) => {
return c.redirect("/cebol");
});
export default router;

0
storage/.gitkeep Normal file
View File

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

7
types/link.ts Normal file
View File

@ -0,0 +1,7 @@
export type Link = {
id: string;
alias: string;
url: string;
clicks: number;
createdAt: Date;
};

35
views/layouts/layout.tsx Normal file
View File

@ -0,0 +1,35 @@
import type { FC, PropsWithChildren } from "hono/jsx";
type Props = PropsWithChildren<{
title?: string;
}>;
const Layout: FC<Props> = ({ children, title }) => {
return (
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>{title || "Cebol"}</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
/>
<script src="https://unpkg.com/htmx.org@1.9.11" defer />
</head>
<body>
<header class="container" style="margin-top: 2em;">
<h1>Cebol</h1>
<p>URL Shortener</p>
</header>
{children}
</body>
</html>
);
};
export default Layout;

22
views/pages/home.tsx Normal file
View File

@ -0,0 +1,22 @@
import type { FC } from "hono/jsx";
import Layout from "../layouts/layout";
import LinksSection from "../sections/links";
import CreateLinkSection from "../sections/create-link";
import type { Link } from "../../types/link";
type Props = {
links: Link[];
};
const HomePage: FC<Props> = ({ links }) => {
return (
<Layout title="Cebol - URL Shortener">
<main class="container">
<CreateLinkSection />
<LinksSection links={links} />
</main>
</Layout>
);
};
export default HomePage;

15
views/pages/not-found.tsx Normal file
View File

@ -0,0 +1,15 @@
import type { FC } from "hono/jsx";
import Layout from "../layouts/layout";
const NotFoundPage: FC = () => {
return (
<Layout title="Link Not Found!">
<main class="container" style="margin-top: 2em;">
<h3>Link Not Found!</h3>
<p>The requested link was not found.</p>
</main>
</Layout>
);
};
export default NotFoundPage;

View File

@ -0,0 +1,25 @@
import type { FC } from "hono/jsx";
const CreateLinkSection: FC = () => {
return (
<section>
<h3>Create New Link</h3>
<div id="create-link-res"></div>
<form
hx-post="/cebol"
hx-target="#create-link-res"
hx-disabled-elt="button[type=submit]"
>
<div class="grid">
<input type="text" name="alias" placeholder="url alias (optional)" />
<input type="text" name="url" placeholder="https://" required />
</div>
<button type="submit">Create</button>
</form>
</section>
);
};
export default CreateLinkSection;

47
views/sections/links.tsx Normal file
View File

@ -0,0 +1,47 @@
import type { FC } from "hono/jsx";
import type { Link } from "../../types/link";
type Props = {
links: Link[];
};
const LinksSection: FC<Props> = ({ links }) => {
return (
<section id="links">
<h3>Links</h3>
<div class="overflow-auto">
<table>
<tbody>
{links.map((link) => (
<tr>
<td>
<a href={"/" + link.alias} target="_blank">
{link.alias}
</a>
</td>
<td style="width: 35%;">{link.url}</td>
<td style="width: 10%;">{link.clicks}</td>
<td style="width: 20%;">{link.createdAt}</td>
<td style="width: 100px;">
<a
href="#"
style="color: red; margin-left: 1em;"
hx-delete={`/cebol/${link.id}`}
hx-confirm="Are you sure?"
hx-target="#links"
hx-swap="outerHTML"
>
Delete
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
};
export default LinksSection;