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**. + +![License](https://img.shields.io/badge/license-MIT-blue.svg) +![Hono](https://img.shields.io/badge/hono-4.x-orange.svg) +![Tailwind CSS](https://img.shields.io/badge/tailwindcss-4.x-06B6D4.svg) + +## ✨ 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 */} +
-
+
); } 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 (
-
+
@@ -154,6 +79,9 @@ export default function ShortenForm() { } class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition" /> +

+ Enter the full URL you want to shorten, including https:// +

@@ -166,8 +94,10 @@ export default function ShortenForm() { dispatch({ @@ -177,7 +107,7 @@ export default function ShortenForm() { } class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition" /> -

+

Only letters, numbers, hyphens, and underscores

@@ -185,50 +115,95 @@ export default function ShortenForm() {
+ {/* 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.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

+
+ +
+
+ +
+

A simple, fast, and accessible URL shortener.

+