diff options
| author | trisusilo48 <tri.susilo@altama.co.id> | 2024-10-21 14:54:11 +0700 |
|---|---|---|
| committer | trisusilo48 <tri.susilo@altama.co.id> | 2024-10-21 14:54:11 +0700 |
| commit | 83d1a1c558293e1b14c9a5847628e7661f749c66 (patch) | |
| tree | 5f083f90192df0fc2aff41e3dd1c3c84f2592352 /app | |
| parent | 30c5eb5776fcc60f023ad6aa51153cb375c87930 (diff) | |
initial commit
Diffstat (limited to 'app')
| -rw-r--r-- | app/lib/api/auth.ts | 42 | ||||
| -rw-r--r-- | app/lib/api/odooApi.ts | 56 | ||||
| -rw-r--r-- | app/lib/camera/component/camera.tsx | 79 | ||||
| -rw-r--r-- | app/lib/camera/component/cardFoto.tsx | 27 | ||||
| -rw-r--r-- | app/lib/camera/component/hedear.tsx | 21 | ||||
| -rw-r--r-- | app/lib/camera/component/pakageCamera.tsx | 44 | ||||
| -rw-r--r-- | app/lib/camera/component/scannerBarcode.tsx | 56 | ||||
| -rw-r--r-- | app/lib/camera/component/sjCamera.tsx | 48 | ||||
| -rw-r--r-- | app/lib/camera/hooks/useCamera.ts | 32 | ||||
| -rw-r--r-- | app/lib/camera/hooks/useCameraStore.ts | 22 | ||||
| -rw-r--r-- | app/login/page.tsx | 174 | ||||
| -rw-r--r-- | app/page.tsx | 248 |
12 files changed, 757 insertions, 92 deletions
diff --git a/app/lib/api/auth.ts b/app/lib/api/auth.ts new file mode 100644 index 0000000..d954dc5 --- /dev/null +++ b/app/lib/api/auth.ts @@ -0,0 +1,42 @@ +import { deleteCookie, getCookie, setCookie } from "cookies-next" + +type AuthProps = { + id: number; + parentId: number; + parentName: string; + partnerId: number; + name: string; + email: string; + phone: string; + npwp: string; + mobile: string; + external: boolean; + company: boolean; + pricelist: string | null; + token: string; + feature : { + onlyReadyStock : boolean, + soApproval : boolean + } + }; + +const getAuth = () : AuthProps | boolean => { + const auth = getCookie('auth') + + if (auth) return JSON.parse(auth) + return false + +} + +const setAuth = (user : AuthProps) : boolean => { + setCookie('auth', JSON.stringify(user)) + return true +} + + +const deleteAuth = () : boolean => { + deleteCookie('auth') + return true +} + +export { getAuth , setAuth, deleteAuth}
\ No newline at end of file diff --git a/app/lib/api/odooApi.ts b/app/lib/api/odooApi.ts new file mode 100644 index 0000000..c2c9d82 --- /dev/null +++ b/app/lib/api/odooApi.ts @@ -0,0 +1,56 @@ +import axios from "axios" +import { getCookie, setCookie } from "cookies-next"; +import { getAuth } from "./auth"; + +type axiosParameters = { + method : string, + url : string, + headers : { + Authorization : string, + 'Content-Type'? : string, + Token? : string + }, + data ?: string +} + +const renewToken = async () => { + const token = await axios.get(process.env.NEXT_PUBLIC_ODOO_API_HOST + '/api/token') + setCookie('token', token.data.result) + return token.data.result +}; + +const getToken = async () => { + let token = getCookie('token') + if (token == undefined) token = await renewToken() + return token +}; + +const odooApi = async (method : string, url : string, data = {}, headers = {}) => { + try { + const token = await getToken() + const auth = getAuth(); + const axiosParameter : axiosParameters = { + method, + url: process.env.NEXT_PUBLIC_ODOO_API_HOST + url, + headers: { Authorization: token ? token : '', ...headers }, + }; + console.log('ini adalah tipe',axiosParameter) + if (auth && typeof auth === 'object' && 'token' in auth) { + axiosParameter.headers['Token'] = auth.token; + } + if (method.toUpperCase() == 'POST') + axiosParameter.headers['Content-Type'] = + 'application/x-www-form-urlencoded'; + if (Object.keys(data).length > 0) + axiosParameter.data = new URLSearchParams( + Object.entries(data) + ).toString(); + const response = await axios(axiosParameter); + return response.data; + } catch (error) { + console.log( JSON.stringify(error)); + } +} + + +export default odooApi
\ No newline at end of file diff --git a/app/lib/camera/component/camera.tsx b/app/lib/camera/component/camera.tsx new file mode 100644 index 0000000..b398616 --- /dev/null +++ b/app/lib/camera/component/camera.tsx @@ -0,0 +1,79 @@ +import { CameraSharp } from "@mui/icons-material"; +import { Button, IconButton } from "@mui/material"; +import Image from "next/image"; +import React, { useRef } from "react"; +import Webcam from "react-webcam"; + +interface WebcamCaptureProps { + image: string | null; + setImage: (image: string | null) => void; + isWebcamVisible: boolean; + setIsWebcamVisible: (isVisible: boolean) => void; +} + +const WebcamCapture: React.FC<WebcamCaptureProps> = ({ + image, + setImage, + isWebcamVisible, + setIsWebcamVisible, +}) => { + const webcamRef = useRef<Webcam>(null); + + // Mengambil foto dari webcam + const capture = () => { + const image = webcamRef.current?.getScreenshot(); + setIsWebcamVisible(false); + setImage(image || null); + }; + + const takePicture = () => { + setImage(null); + setIsWebcamVisible(true); + }; + + // Mengatur ukuran webcam + const videoConstraints = { + width: 500, + height: 480, + facingMode: { + exact: "environment" // untuk kamera belakang + }, + }; + + return ( + <div className="items-center"> + {!isWebcamVisible && ( + <Button variant="text" size="large" onClick={() => takePicture()}> + Ambil Foto Surat Jalan + </Button> + )} + + {isWebcamVisible && ( + <div> + <Webcam + audio={false} + ref={webcamRef} + screenshotFormat="image/jpeg" + videoConstraints={videoConstraints} + /> + <IconButton aria-label="camera" size="large" onClick={() => capture()}> + <CameraSharp fontSize="inherit" /> + </IconButton> + </div> + )} + {image && ( + <div> + <Image + src={image} + alt="Captured" + width={500} + height={480} + unoptimized + /> + </div> + )} + </div> + ); +}; + +export default WebcamCapture; diff --git a/app/lib/camera/component/cardFoto.tsx b/app/lib/camera/component/cardFoto.tsx new file mode 100644 index 0000000..34da216 --- /dev/null +++ b/app/lib/camera/component/cardFoto.tsx @@ -0,0 +1,27 @@ +import { Card, CardContent, Typography } from "@mui/material"; +import Image from "next/image"; +import React from "react"; +import useCameraStore from "../hooks/useCameraStore"; + +const CardFotos = () => { + const { imagePackage } = useCameraStore(); + return ( + <Card sx={{ maxWidth: 200 }}> + <Image + src={imagePackage ?? ''} + alt="Captured" + layout="fill" + objectFit="cover" + unoptimized + className="p-2" + /> + <CardContent> + <Typography gutterBottom variant="h5" component="div"> + Lizard + </Typography> + </CardContent> + </Card> + ); +} + +export default CardFotos diff --git a/app/lib/camera/component/hedear.tsx b/app/lib/camera/component/hedear.tsx new file mode 100644 index 0000000..5cf3f1d --- /dev/null +++ b/app/lib/camera/component/hedear.tsx @@ -0,0 +1,21 @@ +// components/Header.tsx +import Image from "next/image"; + +export default function Header() { + return ( + <nav className="fixed top-0 left-0 w-full bg-white border-b-2 border-red-500 py-4 px-4 sm:px-96 z-50 shadow-md"> + <div className="flex justify-between items-center"> + <div className="flex items-center"> + <Image + src="/images/indoteknik-logo.png" // Ganti dengan path logo Anda + alt="Logo" + width={120} + height={60} + className="rounded-full" + /> + </div> + + </div> + </nav> + ); +} diff --git a/app/lib/camera/component/pakageCamera.tsx b/app/lib/camera/component/pakageCamera.tsx new file mode 100644 index 0000000..e0c7158 --- /dev/null +++ b/app/lib/camera/component/pakageCamera.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import useCameraStore from "../hooks/useCameraStore"; +import { IconButton } from "@mui/material"; +import { PhotoCameraFrontOutlined } from "@mui/icons-material"; + +const PackageCamera: React.FC = () => { + const { setImagePackage } = useCameraStore(); + const handleCapture = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + setImagePackage(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + return ( + <div className="px-4 py-8 items-center border-2 rounded-md shadow-sm w-[49%] text-center "> + <input + type="file" + accept="image/*" + capture="environment" + onChange={handleCapture} + className="hidden" + id="pakageCameraInput" + /> + <label htmlFor="pakageCameraInput" className="text-gray-600"> + <IconButton + color="primary" + aria-label="upload picture" + component="span" + > + <PhotoCameraFrontOutlined fontSize="large" /> + </IconButton> + <br /> + Foto Penerima + </label> + </div> + ); +}; + +export default PackageCamera; diff --git a/app/lib/camera/component/scannerBarcode.tsx b/app/lib/camera/component/scannerBarcode.tsx new file mode 100644 index 0000000..3079e33 --- /dev/null +++ b/app/lib/camera/component/scannerBarcode.tsx @@ -0,0 +1,56 @@ +import { QrCode2 } from "@mui/icons-material"; +import { Button, TextField } from "@mui/material"; +import dynamic from "next/dynamic"; +import React, { useState } from "react"; +import useCameraStore from "../hooks/useCameraStore"; + +const BarcodeScannerComponent = dynamic( + () => import("react-qr-barcode-scanner"), + { ssr: false } +); +const BarcodeScanner: React.FC = () => { + const { barcode, setBarcode } = useCameraStore(); + const [isCameraActive, setIsCameraActive] = useState(false); + + return ( + <div> + <Button + variant="outlined" + onClick={() => setIsCameraActive(!isCameraActive)} + startIcon={<QrCode2 />} + color="error" + className="mb-2" + > + {isCameraActive ? "Cancel" : "Scane Code"} + </Button> + + {isCameraActive && ( + <BarcodeScannerComponent + width={500} // Tingkatkan ukuran untuk memperjelas gambar + height={300} + onUpdate={(err, result) => { + if (result) { + setBarcode(result.getText()); + setIsCameraActive(false); + } + }} + /> + )} + + <div className="mt-4"> + <TextField + fullWidth + label="Detected Picking Code" + id="outlined-basic" + value={barcode} + onChange={ (e) => setBarcode(e.target.value) } + InputLabelProps={{ + shrink: true, // Label akan selalu berada di atas (outline) + }} + /> + </div> + </div> + ); +}; + +export default BarcodeScanner; diff --git a/app/lib/camera/component/sjCamera.tsx b/app/lib/camera/component/sjCamera.tsx new file mode 100644 index 0000000..5cc39ad --- /dev/null +++ b/app/lib/camera/component/sjCamera.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import useCameraStore from "../hooks/useCameraStore"; +import { IconButton } from "@mui/material"; +import { PendingActions } from "@mui/icons-material"; + +const SjCamera: React.FC = () => { + const { setImageSj } = useCameraStore(); + const handleSuratJalanCapture = ( + event: React.ChangeEvent<HTMLInputElement> + ) => { + const file = event.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + setImageSj(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + return ( + <> + <div className="p-4 py-8 items-center border-2 rounded-md shadow-sm w-[49%] text-center"> + <input + type="file" + accept="image/*" + capture="environment" + onChange={handleSuratJalanCapture} + className="hidden" + id="suratJalanInput" + /> + <label htmlFor="suratJalanInput" className="text-gray-600"> + <IconButton + color="primary" + aria-label="upload picture" + component="span" + > + <PendingActions fontSize="large" /> + </IconButton> + <br /> + Foto Surat Jalan + </label> + </div> + </> + ); +}; + +export default SjCamera; diff --git a/app/lib/camera/hooks/useCamera.ts b/app/lib/camera/hooks/useCamera.ts new file mode 100644 index 0000000..d85d978 --- /dev/null +++ b/app/lib/camera/hooks/useCamera.ts @@ -0,0 +1,32 @@ +import { useState } from "react" + +const useCamera = () => { + + const [imageSJ, setImageSj] = useState<string | null>(null) + const [imagePackage, setImagePackage] = useState<string | null>(null) + const [barcode, setBarcode] = useState<string | null>(null) + + const [isWebcamVisibleSj, setIsWebcamVisibleSJ] = useState<boolean>(false) + const [isWebcamVisiblePackage, setIsWebcamVisiblePackage] = useState<boolean>(false) + + const handleSubmit = () => { + setIsWebcamVisibleSJ(false) + } + + + return { + barcode, + setBarcode, + imageSJ, + imagePackage, + setImageSj, + setImagePackage, + isWebcamVisibleSj, + isWebcamVisiblePackage, + setIsWebcamVisibleSJ, + setIsWebcamVisiblePackage, + handleSubmit + } +} + +export default useCamera
\ No newline at end of file diff --git a/app/lib/camera/hooks/useCameraStore.ts b/app/lib/camera/hooks/useCameraStore.ts new file mode 100644 index 0000000..c922d1c --- /dev/null +++ b/app/lib/camera/hooks/useCameraStore.ts @@ -0,0 +1,22 @@ +// store/useCameraStore.ts +import { create } from 'zustand' + +interface CameraStore { + barcode: string | null + setBarcode: (barcode: string) => void + imageSj: string | null + setImageSj: (image: string) => void + imagePackage: string | null + setImagePackage: (image: string) => void +} + +const useCameraStore = create<CameraStore>((set) => ({ + barcode: null, + setBarcode: (barcode: string) => set({ barcode: barcode }), + imageSj: null, + setImageSj: (image: string) => set({ imageSj: image }), + imagePackage: null, + setImagePackage: (image: string) => set({ imagePackage: image }), +})) + +export default useCameraStore
\ No newline at end of file diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..8df5b2c --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,174 @@ +"use client"; +import { + Box, + Button, + FormControl, + FormLabel, + TextField, + Typography, +} from "@mui/material"; +import Header from "../lib/camera/component/hedear"; +import odooApi from "../lib/api/odooApi"; +import { getAuth, setAuth } from "../lib/api/auth"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +const Login = () => { + const router = useRouter(); + const [emailError, setEmailError] = useState(false); + const [emailErrorMessage, setEmailErrorMessage] = useState(""); + const [passwordError, setPasswordError] = useState(false); + const [passwordErrorMessage, setPasswordErrorMessage] = useState(""); + + useEffect(() => { + const token = getAuth(); + + if (token) { + router.push("/"); + } + }, [router]); + + const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { + event.preventDefault(); + if (emailError || passwordError) { + return; + } + const data = new FormData(event.currentTarget); + const email = data.get("email"); + const password = data.get("password"); + + console.log('ini user', email, password) + try { + odooApi("POST", "/api/v1/user/login", { + email , + password + }).then((res) => { + const auth = res.result; + if (auth.is_auth) { + setAuth(auth.user); + router.push("/"); + return; + } + switch (auth.reason) { + case "NOT_FOUND": + alert("Email tidak ditemukan"); + break; + case "NOT_ACTIVE": + alert("Akun anda belum aktif"); + break; + } + console.log('ini akhir',res); + }); + } catch (error) { + console.log(error); + } + }; + + const validateInputs = () => { + const email = document.getElementById("email") as HTMLInputElement; + const password = document.getElementById("password") as HTMLInputElement; + + let isValid = true; + + if (!email.value || !/\S+@\S+\.\S+/.test(email.value)) { + setEmailError(true); + setEmailErrorMessage("Please enter a valid email address."); + isValid = false; + } else { + setEmailError(false); + setEmailErrorMessage(""); + } + + if (!password.value || password.value.length < 6) { + setPasswordError(true); + setPasswordErrorMessage("Password must be at least 6 characters long."); + isValid = false; + } else { + setPasswordError(false); + setPasswordErrorMessage(""); + } + + return isValid; + }; + return ( + <div className="bg-[#fafeff] h-screen overflow-auto"> + <Header /> + <div className="py-4 px-4 sm:px-96 pt-20"> + <div className="bg-white py-6 px-4 sm:px-96 shadow-md rounded-sm"> + <Typography + component="h1" + variant="h4" + sx={{ + width: "100%", + fontSize: "clamp(2rem, 10vw, 2.15rem)", + mb: 4, + }} + > + Sign in + </Typography> + <Box + component="form" + onSubmit={handleSubmit} + noValidate + sx={{ + display: "flex", + flexDirection: "column", + width: "100%", + gap: 2, + }} + > + <FormControl> + <FormLabel htmlFor="email">Email</FormLabel> + <TextField + error={emailError} + helperText={emailErrorMessage} + id="email" + type="email" + name="email" + placeholder="your@email.com" + autoComplete="email" + autoFocus + required + fullWidth + variant="outlined" + color={emailError ? "error" : "primary"} + sx={{ ariaLabel: "email" }} + size="small" + /> + </FormControl> + <FormControl> + <Box sx={{ display: "flex", justifyContent: "space-between" }}> + <FormLabel htmlFor="password">Password</FormLabel> + </Box> + <TextField + error={passwordError} + helperText={passwordErrorMessage} + name="password" + placeholder="••••••" + type="password" + id="password" + autoComplete="current-password" + autoFocus + required + fullWidth + variant="outlined" + color={passwordError ? "error" : "primary"} + size="small" + /> + </FormControl> + <Button + type="submit" + fullWidth + variant="contained" + onClick={validateInputs} + > + Sign in + </Button> + </Box> + </div> + </div> + </div> + ); +}; + +export default Login; diff --git a/app/page.tsx b/app/page.tsx index 433c8aa..ccfb7f7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,101 +1,165 @@ +"use client"; import Image from "next/image"; +import PackageCamera from "./lib/camera/component/pakageCamera"; +import BarcodeScanner from "./lib/camera/component/scannerBarcode"; +import SjCamera from "./lib/camera/component/sjCamera"; +import useCameraStore from "./lib/camera/hooks/useCameraStore"; +import Header from "./lib/camera/component/hedear"; +import { Button } from "@mui/material"; +import { SaveAsOutlined } from "@mui/icons-material"; +import axios from "axios"; +import odooApi from "./lib/api/odooApi"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { getAuth } from "./lib/api/auth"; export default function Home() { + const [isLogin, setIsLogin] = useState<boolean>(true); + const { + barcode, + imageSj, + imagePackage, + setBarcode, + setImageSj, + setImagePackage, + } = useCameraStore(); + const [isLoading, setIsLoading] = useState<boolean>(false); + + const router = useRouter(); + + useEffect(() => { + const token = getAuth(); + + if (!token) { + router.push("/login"); + } else { + setIsLogin(true); + } + }, [router]); + + const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { + event.preventDefault(); + setIsLoading(true); + + if (!barcode || !imageSj || !imagePackage) { + alert("Barcode dan gambar harus tersedia."); + setIsLoading(false); + return; + } + + try { + const newSjImage = imageSj.replace(/^.*?,/, ""); + const newPackageImage = imagePackage.replace(/^.*?,/, ""); + // const method = 'PUT'; + + const data = { + sj_document: newSjImage, // Kirim base64 lengkap dengan prefix + paket_document: newPackageImage, // Kirim base64 lengkap dengan prefix + }; + + const response = await odooApi( + "PUT", + `/api/v1/stock-picking/${barcode}/documentation`, + data + ); + console.log(response); + if (response.status.code == 200) { + alert("Berhasil Submit Data"); + setBarcode(""); + setImageSj(""); + setImagePackage(""); + setIsLoading(false); + } + return response.data; + } catch (error: unknown) { + if (error instanceof Error) { + console.error("Error mengirim data:", error.message); + } else if (axios.isAxiosError(error)) { + console.error("Error:", error.response?.data); + } else { + console.error("Unknown error:", error); + } + setIsLoading(false); + } + }; + return ( - <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]"> - <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start"> - <Image - className="dark:invert" - src="https://nextjs.org/icons/next.svg" - alt="Next.js logo" - width={180} - height={38} - priority - /> - <ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]"> - <li className="mb-2"> - Get started by editing{" "} - <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold"> - app/page.tsx - </code> - . - </li> - <li>Save and see your changes instantly.</li> - </ol> + <div className="bg-white h-screen overflow-auto"> + <Header /> + {isLogin && ( + <div className="py-4 px-4 sm:px-96 pt-20"> + <form onSubmit={handleSubmit}> + <div> + <BarcodeScanner /> + </div> + <div className="h-4"></div> + + <div className="flex justify-between"> + <SjCamera /> + <PackageCamera /> + </div> + <div className="h-2"></div> - <div className="flex gap-4 items-center flex-col sm:flex-row"> - <a - className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5" - href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - className="dark:invert" - src="https://nextjs.org/icons/vercel.svg" - alt="Vercel logomark" - width={20} - height={20} - /> - Deploy now - </a> - <a - className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44" - href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - Read our docs - </a> + {imageSj && ( + <> + <label className="block mt-2 text-sm font-medium text-gray-700 text-center"> + Gambar Foto Surat Jalan + </label> + <div className="relative w-full h-[300px] border-2 border-gray-200 p-2 rounded-sm"> + <Image + src={imageSj} + alt="Captured" + layout="fill" + objectFit="cover" + unoptimized + className="p-2" + /> + </div> + </> + )} + + <div className="h-2"></div> + + {imagePackage && ( + <> + <label className="block mt-2 text-sm font-medium text-gray-700 text-center"> + Gambar Foto Penerima + </label> + <div className="relative w-full h-[300px] border-2 border-gray-200 p-2 rounded-sm"> + <Image + src={imagePackage} + alt="Captured" + layout="fill" + objectFit="cover" + unoptimized + className="p-2" + /> + </div> + </> + )} + <div> + <div className="h-4"></div> + <Button + className="w-[50%] sm:w-[25%]" + variant="contained" + color="error" + startIcon={<SaveAsOutlined />} + type="submit" + disabled={isLoading} + > + Simpan + </Button> + </div> + </form> + </div> + )} + {!isLogin && ( + <div className="py-4 px-4 sm:px-96 pt-20"> + <div className="text-center"> + <p className="text-2xl font-bold">Loading...</p> + </div> </div> - </main> - <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center"> - <a - className="flex items-center gap-2 hover:underline hover:underline-offset-4" - href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - aria-hidden - src="https://nextjs.org/icons/file.svg" - alt="File icon" - width={16} - height={16} - /> - Learn - </a> - <a - className="flex items-center gap-2 hover:underline hover:underline-offset-4" - href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - aria-hidden - src="https://nextjs.org/icons/window.svg" - alt="Window icon" - width={16} - height={16} - /> - Examples - </a> - <a - className="flex items-center gap-2 hover:underline hover:underline-offset-4" - href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - aria-hidden - src="https://nextjs.org/icons/globe.svg" - alt="Globe icon" - width={16} - height={16} - /> - Go to nextjs.org → - </a> - </footer> + )} </div> ); } |
