diff --git a/.github/workflows/cloudflare-deploy.yaml b/.github/workflows/cloudflare-deploy.yaml
new file mode 100644
index 0000000..6326dd7
--- /dev/null
+++ b/.github/workflows/cloudflare-deploy.yaml
@@ -0,0 +1,46 @@
+name: Deploy to Cloudflare Workers (Hono)
+
+on:
+ workflow_dispatch:
+
+concurrency:
+ group: cloudflare-deploy
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: pnpm-lock.yaml
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ # Build production bundle (Vite) before deploying (mirrors pnpm cf:deploy)
+ - name: Build (Vite)
+ run: pnpm build
+
+ - name: Deploy to Cloudflare Workers
+ uses: cloudflare/wrangler-action@v3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ wranglerVersion: "4.40.3"
+ command: deploy --name shortin-v3 --minify
diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml
new file mode 100644
index 0000000..3a14819
--- /dev/null
+++ b/.github/workflows/pull-request.yaml
@@ -0,0 +1,38 @@
+name: Pull Request (Build and Unit Tests)
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: pnpm-lock.yaml
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Build
+ run: pnpm build
+
+ - name: Run unit tests
+ run: pnpm test:ci
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index c363919..9cd0108 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,6 @@ lerna-debug.log*
# misc
.DS_Store
+
+# test coverage
+coverage/
\ No newline at end of file
diff --git a/README.md b/README.md
index eba2b1e..2e70d4a 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,273 @@
-```txt
-npm install
-npm run dev
+# Shortin - URL Shortener
+
+A minimalist, responsive, and accessible URL shortener service built with **Hono JS** and **Tailwind CSS**, deployed on **Cloudflare Workers**.
+
+
+
+
+
+## ✨ Features
+
+- 🔗 **URL Shortening** - Shorten long URLs instantly with auto-generated or custom short codes
+- 📋 **Copy to Clipboard** - One-click copy for generated short URLs
+- 📊 **Visit Statistics** - Track how many times your shortened links have been clicked
+- ♿ **Accessible (a11y)** - WCAG 2.1 compliant with screen reader support, keyboard navigation, and reduced motion support
+- 📱 **Responsive Design** - Beautiful UI that works on all devices
+- ⚡ **Edge Deployed** - Runs on Cloudflare Workers for ultra-fast global performance
+- 🔄 **Smart Redirects** - 2-second loading screen with countdown before redirect
+
+## 🛠️ Tech Stack
+
+| Technology | Purpose |
+|------------|---------|
+| [Hono](https://hono.dev) | Ultra-fast web framework |
+| [Tailwind CSS v4](https://tailwindcss.com) | Utility-first CSS framework |
+| [Vite](https://vitejs.dev) | Build tool and dev server |
+| [Cloudflare Workers](https://workers.cloudflare.com) | Edge deployment platform |
+| [Vitest](https://vitest.dev) | Unit testing framework |
+| [Plus Jakarta Sans](https://fonts.google.com/specimen/Plus+Jakarta+Sans) | Typography |
+
+## 🚀 Getting Started
+
+### Prerequisites
+
+- [Node.js](https://nodejs.org/) 18+
+- [pnpm](https://pnpm.io/) (recommended) or npm
+
+### Installation
+
+```bash
+# Clone the repository
+git clone https://github.com/yehezkielgunawan/shortin-v3-rev.git
+cd shortin-v3-rev
+
+# Install dependencies
+pnpm install
+```
+
+### Development
+
+```bash
+# Start development server on port 3000
+pnpm dev
+```
+
+The app will be available at `http://localhost:3000`
+
+### Testing
+
+```bash
+# Run tests in watch mode
+pnpm test
+
+# Run tests once (CI mode)
+pnpm test:ci
+
+# Run tests with coverage report
+pnpm test:coverage
+```
+
+### Build & Deploy
+
+```bash
+# Build for production
+pnpm build
+
+# Preview production build locally
+pnpm cf:preview
+
+# Deploy to Cloudflare Workers
+pnpm cf:deploy
+```
+
+### Type Generation
+
+Generate/synchronize types based on your Worker configuration:
+
+```bash
+pnpm cf-typegen
+```
+
+## 📖 Usage
+
+### Shortening a URL
+
+1. Enter your long URL in the input field
+2. (Optional) Add a custom short code
+3. Click "Shorten URL"
+4. Copy the generated short URL
+
+### Accessing a Shortened URL
+
+Visit `https://shortin-api.yehezgun.com/{shortCode}` to be redirected to the original URL.
+
+## 🔌 API Reference
+
+The app proxies requests to the backend API through `/api/*` routes.
+
+### Create Short URL
+
+```http
+POST /api/shorten
+Content-Type: application/json
+
+{
+ "url": "https://www.example.com/some/long/url",
+ "shortCodeInput": "custom-code" // optional
+}
+```
+
+**Response (201)**
+```json
+{
+ "id": "id_1620000000000_1234",
+ "url": "https://www.example.com/some/long/url",
+ "shortCode": "custom-code",
+ "createdAt": "2024-01-01T00:00:00.000Z",
+ "updatedAt": "2024-01-01T00:00:00.000Z",
+ "count": 0
+}
+```
+
+### Get Original URL
+
+```http
+GET /api/{shortCode}
+```
+
+**Response (200)**
+```json
+{
+ "url": "https://www.example.com/some/long/url"
+}
+```
+
+### Get Visit Statistics
+
+```http
+GET /api/{shortCode}/stats
+```
+
+**Response (200)**
+```json
+{
+ "count": 42
+}
+```
+
+### Update Destination URL
+
+```http
+PUT /api/{shortCode}
+Content-Type: application/json
+
+{
+ "url": "https://www.example.com/new/url"
+}
```
-```txt
-npm run deploy
+**Response (200)**
+```json
+{
+ "message": "Short code updated successfully"
+}
```
-[For generating/synchronizing types based on your Worker configuration run](https://developers.cloudflare.com/workers/wrangler/commands/#types):
+### Delete Short URL
-```txt
-npm run cf-typegen
+```http
+DELETE /api/{shortCode}
```
-Pass the `CloudflareBindings` as generics when instantiation `Hono`:
+**Response (200)**
+```json
+{
+ "message": "Short code deleted successfully"
+}
+```
+
+### Error Responses
+
+| Status | Response |
+|--------|----------|
+| 400 | `{ "error": "URL is required" }` |
+| 400 | `{ "error": "Short code already in use" }` |
+| 404 | `{ "error": "Short code not found" }` |
+| 500 | `{ "error": "Failed to shorten URL" }` |
-```ts
-// src/index.ts
-const app = new Hono<{ Bindings: CloudflareBindings }>()
+## 📁 Project Structure
+
+```
+shortin-v3-rev/
+├── src/
+│ ├── client/ # Client-side hydration scripts
+│ │ ├── main.tsx # Main page hydration
+│ │ └── redirect.tsx # Redirect page hydration
+│ ├── components/ # JSX Components
+│ │ ├── ShortenForm.tsx
+│ │ └── RedirectPage.tsx
+│ ├── lib/ # Shared utilities
+│ │ └── formReducer.ts
+│ ├── test/ # Test files
+│ │ ├── setup.ts
+│ │ ├── index.test.ts
+│ │ └── formReducer.test.ts
+│ ├── index.tsx # Main Hono app & routes
+│ ├── renderer.tsx # HTML renderer
+│ └── style.css # Global styles
+├── public/ # Static assets
+├── dist/ # Build output
+├── vite.config.ts # Vite configuration
+├── vitest.config.ts # Vitest configuration
+├── wrangler.jsonc # Cloudflare Workers config
+└── package.json
```
+
+## ♿ Accessibility Features
+
+This app is built with accessibility in mind:
+
+- **Semantic HTML** - Proper use of ``, ``, ``, `
);
}
return (
-
+
-
-
+
-
Redirecting...
-
+
+ {/* Screen reader announcement */}
+
+ Redirecting you to your destination in {countdown} seconds
+
+
+
+ Redirecting...
+
+
Please wait while we take you to your destination.
+
+ {/* Visual countdown */}
+
+ Redirecting in {countdown}s
+
-
+
);
}
diff --git a/src/components/ShortenForm.tsx b/src/components/ShortenForm.tsx
index ad55f7e..ebcd419 100644
--- a/src/components/ShortenForm.tsx
+++ b/src/components/ShortenForm.tsx
@@ -1,87 +1,8 @@
import { useReducer } from "hono/jsx";
-
-// Define the state shape
-interface State {
- url: string;
- shortCodeInput: string;
- loading: boolean;
- result: any | null;
- error: string;
- warning: string;
- copied: boolean;
-}
-
-// Define action types
-type Action =
- | { type: "SET_URL"; payload: string }
- | { type: "SET_SHORT_CODE"; payload: string }
- | { type: "SUBMIT_START" }
- | { type: "SUBMIT_SUCCESS"; payload: { result: any; warning?: string } }
- | { type: "SUBMIT_ERROR"; payload: string }
- | { type: "COPY_SUCCESS" }
- | { type: "COPY_RESET" }
- | { type: "RESET_MESSAGES" };
-
-// Initial state
-const initialState: State = {
- url: "",
- shortCodeInput: "",
- loading: false,
- result: null,
- error: "",
- warning: "",
- copied: false,
-};
-
-// Reducer function
-function formReducer(state: State, action: Action): State {
- switch (action.type) {
- case "SET_URL":
- return { ...state, url: action.payload };
-
- case "SET_SHORT_CODE":
- return { ...state, shortCodeInput: action.payload };
-
- case "SUBMIT_START":
- return {
- ...state,
- loading: true,
- result: null,
- error: "",
- warning: "",
- };
-
- case "SUBMIT_SUCCESS":
- return {
- ...state,
- loading: false,
- result: action.payload.result,
- warning: action.payload.warning || "",
- };
-
- case "SUBMIT_ERROR":
- return {
- ...state,
- loading: false,
- error: action.payload,
- };
-
- case "COPY_SUCCESS":
- return { ...state, copied: true };
-
- case "COPY_RESET":
- return { ...state, copied: false };
-
- case "RESET_MESSAGES":
- return { ...state, error: "", warning: "" };
-
- default:
- return state;
- }
-}
+import { formReducer, initialFormState } from "@/lib/formReducer";
export default function ShortenForm() {
- const [state, dispatch] = useReducer(formReducer, initialState);
+ const [state, dispatch] = useReducer(formReducer, initialFormState);
const handleSubmit = async (e: Event) => {
e.preventDefault();
@@ -119,7 +40,7 @@ export default function ShortenForm() {
const handleCopy = async () => {
if (!state.result) return;
- const shortUrl = window.location.origin + state.result.shortCode;
+ const shortUrl = `${window.location.origin}/${state.result.shortCode}`;
try {
await navigator.clipboard.writeText(shortUrl);
dispatch({ type: "COPY_SUCCESS" });
@@ -130,20 +51,24 @@ export default function ShortenForm() {
};
const shortUrl = state.result
- ? window.location.origin + state.result.shortCode
+ ? `${window.location.origin}/${state.result.shortCode}`
: "";
return (
-
+ {/* Live region for status announcements */}
+
+ {state.loading && "Shortening your URL, please wait."}
+ {state.result && `Success! Your shortened URL is ${shortUrl}`}
+ {state.copied && "URL copied to clipboard"}
+
+
{state.result && (
-
-
Success!
+
+
+ ✓
+ URL Shortened Successfully
+
+
-
- Created: {new Date(state.result.createdAt).toLocaleString()} |
+
+ URL details:
+ Created:
+ |
+ ,
Clicks: {state.result.count}
-
-
+
+
)}
{state.warning && (
-
+
- ⚠️
- {state.warning}
+ ⚠️
+
+ Warning:
+ {state.warning}
+
)}
{state.error && (
-
+
- ❌
- {state.error}
+ ❌
+
+ Error:
+ {state.error}
+
)}
diff --git a/src/index.tsx b/src/index.tsx
index e190da2..41f200a 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -13,22 +13,31 @@ app.use(renderer);
// Main page
app.get("/", (c) => {
return c.render(
-
+
-
-
- URL Shortener
-
-
- Shorten your long URLs instantly
-
-
-
-
+
+
+ URL Shortener
+ Shorten your long URLs instantly
+
+
+
+
+
+
- ,
+
);
});
@@ -41,7 +50,7 @@ app.get("/:code", async (c) => {
- >,
+ >
);
});
diff --git a/src/lib/formReducer.ts b/src/lib/formReducer.ts
new file mode 100644
index 0000000..9991985
--- /dev/null
+++ b/src/lib/formReducer.ts
@@ -0,0 +1,87 @@
+// Define the state shape
+export interface FormState {
+ url: string;
+ shortCodeInput: string;
+ loading: boolean;
+ result: {
+ id: string;
+ url: string;
+ shortCode: string;
+ createdAt: string;
+ updatedAt: string;
+ count: number;
+ warning?: string;
+ } | null;
+ error: string;
+ warning: string;
+ copied: boolean;
+}
+
+// Define action types
+export type FormAction =
+ | { type: "SET_URL"; payload: string }
+ | { type: "SET_SHORT_CODE"; payload: string }
+ | { type: "SUBMIT_START" }
+ | { type: "SUBMIT_SUCCESS"; payload: { result: FormState["result"]; warning?: string } }
+ | { type: "SUBMIT_ERROR"; payload: string }
+ | { type: "COPY_SUCCESS" }
+ | { type: "COPY_RESET" }
+ | { type: "RESET_MESSAGES" };
+
+// Initial state
+export const initialFormState: FormState = {
+ url: "",
+ shortCodeInput: "",
+ loading: false,
+ result: null,
+ error: "",
+ warning: "",
+ copied: false,
+};
+
+// Reducer function
+export function formReducer(state: FormState, action: FormAction): FormState {
+ switch (action.type) {
+ case "SET_URL":
+ return { ...state, url: action.payload };
+
+ case "SET_SHORT_CODE":
+ return { ...state, shortCodeInput: action.payload };
+
+ case "SUBMIT_START":
+ return {
+ ...state,
+ loading: true,
+ result: null,
+ error: "",
+ warning: "",
+ };
+
+ case "SUBMIT_SUCCESS":
+ return {
+ ...state,
+ loading: false,
+ result: action.payload.result,
+ warning: action.payload.warning || "",
+ };
+
+ case "SUBMIT_ERROR":
+ return {
+ ...state,
+ loading: false,
+ error: action.payload,
+ };
+
+ case "COPY_SUCCESS":
+ return { ...state, copied: true };
+
+ case "COPY_RESET":
+ return { ...state, copied: false };
+
+ case "RESET_MESSAGES":
+ return { ...state, error: "", warning: "" };
+
+ default:
+ return state;
+ }
+}
diff --git a/src/renderer.tsx b/src/renderer.tsx
index edd545d..38758a4 100644
--- a/src/renderer.tsx
+++ b/src/renderer.tsx
@@ -7,12 +7,24 @@ export const renderer = jsxRenderer(({ children }) => {
+
Shortin - URL Shortener
-
+
+
+
- {children}
+
+ {/* Skip to main content link for keyboard navigation */}
+
+ Skip to main content
+
+ {children}
+