diff --git a/Readme.md b/Readme.md index 59650a7..033eb8c 100644 --- a/Readme.md +++ b/Readme.md @@ -31,7 +31,7 @@ The project follows a clean architecture pattern with clear separation of concer 1. Clone the repository: ```bash -git clone +git clone git@github.com:eulixir/lnk.git cd lnk ``` @@ -49,6 +49,12 @@ This will start: - Redis on port `6379` - Cassandra on port `9042` +> **⚠️ Important**: The Cassandra setup can take a significant amount of time (30-60 seconds or more) to fully initialize and be ready to accept connections. Wait for Cassandra to be healthy before starting the backend application. You can check readiness with: +> ```bash +> docker exec lnk-cassandra nodetool status +> ``` +> When Cassandra is ready, you should see the node status as `UN` (Up Normal). + 4. Create a `.env` file in the project root with the following configuration: ```env @@ -235,8 +241,121 @@ CREATE TABLE urls ( This design ensures fast lookups when retrieving URLs by their short code. +## Frontend + +The frontend is a modern Next.js application that provides a user-friendly interface for the URL shortener service. + +### Frontend Features + +- 🎨 **Modern UI**: Built with Next.js 16 and React 19 +- 🎯 **Type-Safe API Client**: Auto-generated TypeScript client from Swagger/OpenAPI +- 🎨 **Beautiful Components**: Uses shadcn/ui component library +- 📱 **Responsive Design**: Mobile-friendly interface +- ⚡ **Fast Performance**: Optimized with React Compiler + +### Frontend Prerequisites + +- Node.js 18+ or Bun +- Backend API running (see Backend section) + +### Frontend Installation + +1. Navigate to the frontend directory: +```bash +cd frontend +``` + +2. Install dependencies: +```bash +# Using npm +npm install + +# Or using Bun +bun install +``` + +3. Generate API client from Swagger documentation: +```bash +npm run generate:api +# Or +bun run generate:api +``` + +**Note**: Make sure the backend is running and Swagger documentation is available at `http://localhost:8080/swagger/doc.json` before generating the API client. + +### Running the Frontend + +#### Development Mode + +```bash +npm run dev +# Or +bun run dev +``` + +The frontend will start on `http://localhost:3000` (default Next.js port). + +#### Production Build + +```bash +npm run build +npm run start +# Or +bun run build +bun run start +``` + +### Frontend Scripts + +- `npm run dev` / `bun run dev`: Start development server +- `npm run build` / `bun run build`: Build for production +- `npm run start` / `bun run start`: Start production server +- `npm run lint` / `bun run lint`: Run linter (Biome) +- `npm run lint:fix` / `bun run lint:fix`: Fix linting issues +- `npm run format` / `bun run format`: Format code +- `npm run generate:api` / `bun run generate:api`: Generate API client from Swagger + +### Frontend Technologies + +- **Next.js 16**: React framework with App Router +- **React 19**: UI library +- **TypeScript**: Type safety +- **Tailwind CSS**: Utility-first CSS framework +- **shadcn/ui**: High-quality component library +- **Orval**: OpenAPI client generator +- **Biome**: Fast linter and formatter +- **React Hook Form**: Form management +- **Sonner**: Toast notifications +- **Lucide React**: Icon library + +### Frontend Project Structure + +``` +frontend/ +├── src/ +│ ├── app/ # Next.js App Router pages +│ │ ├── [shortUrl]/ # Dynamic route for URL redirection +│ │ └── page.tsx # Home page +│ ├── api/ # API client and configuration +│ │ ├── lnk.ts # Auto-generated API client +│ │ └── undici-instance.ts # Custom fetch instance +│ ├── components/ # React components +│ │ ├── ui/ # shadcn/ui components +│ │ ├── url-dialog.tsx +│ │ ├── url-input.tsx +│ │ └── url-shortener.tsx +│ ├── hooks/ # Custom React hooks +│ ├── lib/ # Utility functions +│ └── types/ # TypeScript type definitions +├── public/ # Static assets +├── orval.config.ts # API client generation config +├── next.config.ts # Next.js configuration +└── package.json # Dependencies and scripts +``` + ## Technologies Used +### Backend - **Go 1.24**: Programming language - **Gin**: HTTP web framework - **Cassandra (gocql)**: Database for URL storage @@ -246,6 +365,14 @@ This design ensures fast lookups when retrieving URLs by their short code. - **Docker Compose**: Local development environment - **Testify**: Testing framework +### Frontend +- **Next.js 16**: React framework +- **React 19**: UI library +- **TypeScript**: Type safety +- **Tailwind CSS**: Styling +- **shadcn/ui**: Component library +- **Orval**: API client generator + ## Configuration The application uses environment variables for configuration. All configuration options can be set in a `.env` file or as environment variables. diff --git a/frontend/src/api/lnk.ts b/frontend/src/api/lnk.ts index fd04586..155a0a6 100644 --- a/frontend/src/api/lnk.ts +++ b/frontend/src/api/lnk.ts @@ -5,7 +5,7 @@ * A URL shortener service API * OpenAPI spec version: 1.0 */ -import { customInstance } from './undici-instance'; +import { customInstance } from "./undici-instance"; export interface HandlersCreateURLRequest { url: string; } @@ -19,140 +19,127 @@ export interface HandlersErrorResponse { error?: string; } -export type GetHealth200 = {[key: string]: string}; +export type GetHealth200 = { [key: string]: string }; -export type GetShortUrl308 = {[key: string]: string}; +export type GetShortUrl308 = { [key: string]: string }; -export type GetShortUrl500 = {[key: string]: string}; +export type GetShortUrl500 = { [key: string]: string }; /** * Check if the API is running * @summary Health check endpoint */ export type getHealthResponse200 = { - data: GetHealth200 - status: 200 -} - -export type getHealthResponseSuccess = (getHealthResponse200) & { + data: GetHealth200; + status: 200; +}; + +export type getHealthResponseSuccess = getHealthResponse200 & { headers: Headers; }; -; -export type getHealthResponse = (getHealthResponseSuccess) +export type getHealthResponse = getHealthResponseSuccess; export const getGetHealthUrl = () => { + return `/health`; +}; - - - - return `/health` -} - -export const getHealth = async ( options?: RequestInit): Promise => { - - return customInstance(getGetHealthUrl(), - { +export const getHealth = async ( + options?: RequestInit, +): Promise => { + return customInstance(getGetHealthUrl(), { ...options, - method: 'GET' - - - } -);} - - + method: "GET", + }); +}; /** * Create a short URL from a long URL * @summary Create a short URL */ export type postShortenResponse200 = { - data: HandlersCreateURLResponse - status: 200 -} + data: HandlersCreateURLResponse; + status: 200; +}; export type postShortenResponse400 = { - data: HandlersErrorResponse - status: 400 -} + data: HandlersErrorResponse; + status: 400; +}; export type postShortenResponse500 = { - data: HandlersErrorResponse - status: 500 -} - -export type postShortenResponseSuccess = (postShortenResponse200) & { + data: HandlersErrorResponse; + status: 500; +}; + +export type postShortenResponseSuccess = postShortenResponse200 & { headers: Headers; }; -export type postShortenResponseError = (postShortenResponse400 | postShortenResponse500) & { +export type postShortenResponseError = ( + | postShortenResponse400 + | postShortenResponse500 +) & { headers: Headers; }; -export type postShortenResponse = (postShortenResponseSuccess | postShortenResponseError) +export type postShortenResponse = + | postShortenResponseSuccess + | postShortenResponseError; export const getPostShortenUrl = () => { + return `/shorten`; +}; - - - - return `/shorten` -} - -export const postShorten = async (handlersCreateURLRequest: HandlersCreateURLRequest, options?: RequestInit): Promise => { - - return customInstance(getPostShortenUrl(), - { +export const postShorten = async ( + handlersCreateURLRequest: HandlersCreateURLRequest, + options?: RequestInit, +): Promise => { + return customInstance(getPostShortenUrl(), { ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - handlersCreateURLRequest,) - } -);} - - + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(handlersCreateURLRequest), + }); +}; /** * Get the original URL from a short URL * @summary Get original URL by short URL */ export type getShortUrlResponse308 = { - data: GetShortUrl308 - status: 308 -} + data: GetShortUrl308; + status: 308; +}; export type getShortUrlResponse404 = { - data: HandlersErrorResponse - status: 404 -} + data: HandlersErrorResponse; + status: 404; +}; export type getShortUrlResponse500 = { - data: GetShortUrl500 - status: 500 -} - -; -export type getShortUrlResponseError = (getShortUrlResponse308 | getShortUrlResponse404 | getShortUrlResponse500) & { + data: GetShortUrl500; + status: 500; +}; +export type getShortUrlResponseError = ( + | getShortUrlResponse308 + | getShortUrlResponse404 + | getShortUrlResponse500 +) & { headers: Headers; }; -export type getShortUrlResponse = (getShortUrlResponseError) - -export const getGetShortUrlUrl = (shortUrl: string,) => { - +export type getShortUrlResponse = getShortUrlResponseError; - - - return `/${shortUrl}` -} +export const getGetShortUrlUrl = (shortUrl: string) => { + return `/${shortUrl}`; +}; -export const getShortUrl = async (shortUrl: string, options?: RequestInit): Promise => { - - return customInstance(getGetShortUrlUrl(shortUrl), - { +export const getShortUrl = async ( + shortUrl: string, + options?: RequestInit, +): Promise => { + return customInstance(getGetShortUrlUrl(shortUrl), { ...options, - method: 'GET' - - - } -);} + method: "GET", + }); +}; diff --git a/frontend/src/api/undici-instance.ts b/frontend/src/api/undici-instance.ts index be94789..d0540ea 100644 --- a/frontend/src/api/undici-instance.ts +++ b/frontend/src/api/undici-instance.ts @@ -50,4 +50,3 @@ export const customInstance = async ( headers: response.headers, }; }; - diff --git a/frontend/src/app/[shortUrl]/route.ts b/frontend/src/app/[shortUrl]/route.ts index 2d84982..32c44a6 100644 --- a/frontend/src/app/[shortUrl]/route.ts +++ b/frontend/src/app/[shortUrl]/route.ts @@ -3,22 +3,22 @@ import { getShortUrl } from "@/api/lnk"; export async function GET( _request: Request, - context: { params: Promise<{ shortUrl: string }> | { shortUrl: string } } + context: { params: Promise<{ shortUrl: string }> | { shortUrl: string } }, ) { const params = context.params; const resolvedParams = params instanceof Promise ? await params : params; const shortUrl = resolvedParams?.shortUrl; - + if (!shortUrl) { return NextResponse.json( { error: "Short URL parameter is required" }, - { status: 400 } + { status: 400 }, ); } try { const response = await getShortUrl(shortUrl); - + console.log("response", response); if (response.status === 308) { @@ -26,9 +26,10 @@ export async function GET( if (location) { return NextResponse.redirect(location, 308); } - + const data = response.data as { [key: string]: string }; - const originalUrl = data.original_url || data.url || Object.values(data)[0]; + const originalUrl = + data.original_url || data.url || Object.values(data)[0]; if (originalUrl) { return NextResponse.redirect(originalUrl, 308); } @@ -37,20 +38,19 @@ export async function GET( if (response.status === 404) { return NextResponse.json( { error: "Short URL not found" }, - { status: 404 } + { status: 404 }, ); } return NextResponse.json( { error: "Failed to redirect", status: response.status }, - { status: response.status } + { status: response.status }, ); } catch (error) { console.error("Error fetching short URL:", error); return NextResponse.json( { error: "Internal server error" }, - { status: 500 } + { status: 500 }, ); } } - diff --git a/frontend/src/components/url-dialog.tsx b/frontend/src/components/url-dialog.tsx index 215bd9b..63fba01 100644 --- a/frontend/src/components/url-dialog.tsx +++ b/frontend/src/components/url-dialog.tsx @@ -1,6 +1,6 @@ -import { CheckCircle2, Copy, QrCode } from "lucide-react"; -import Image from "next/image"; +import { CheckCircle2, Copy } from "lucide-react"; import { useState } from "react"; +import { toast } from "sonner"; import { Button } from "./ui/button"; import { Dialog, @@ -10,7 +10,6 @@ import { DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; -import { toast } from "sonner"; interface UrlDialogProps { shortUrl: string; @@ -77,22 +76,6 @@ export function UrlDialog({ -
-
- -
QR Code
-
-
- QR Code -
-
-
Original URL
diff --git a/frontend/src/components/url-shortener.tsx b/frontend/src/components/url-shortener.tsx index 1af90a9..572ada4 100644 --- a/frontend/src/components/url-shortener.tsx +++ b/frontend/src/components/url-shortener.tsx @@ -1,10 +1,10 @@ "use client"; +import { useState } from "react"; +import { toast } from "sonner"; import { postShorten } from "@/api/lnk"; import { UrlDialog } from "./url-dialog"; import { UrlInput } from "./url-input"; -import { useState } from "react"; -import { toast } from "sonner"; export function UrlShortener() { const [url, setUrl] = useState("");