diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index 7714ae8..12d65f3 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,12 +1,22 @@ -import type { Metadata } from 'next'; +import type { Metadata, Viewport } from 'next'; import { Inter, Rubik } from 'next/font/google'; import { getDictionary } from '@/components/internationalization/dictionaries'; import { DictionaryProvider } from '@/components/internationalization/dictionary-context'; import { type Locale, localeConfig, i18n } from '@/components/internationalization/config'; +import { ReportIssue } from '@/components/report-issue'; import { Providers } from '../providers'; import { Toaster } from 'sonner'; import '../globals.css'; +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#ffffff' }, + { media: '(prefers-color-scheme: dark)', color: '#000000' }, + ], +}; + // Enable ISR with 1-hour revalidation export const revalidate = 3600; @@ -71,12 +81,15 @@ export default async function LocaleLayout({ href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-background focus:text-foreground focus:border focus:border-border focus:rounded-md focus:m-2" > - {isRTL ? 'تخطي إلى المحتوى الرئيسي' : 'Skip to main content'} + {dictionary.common.skipToContent} {children} +
+ +
diff --git a/src/components/internationalization/ar.json b/src/components/internationalization/ar.json index 7a3ca9c..2881f62 100644 --- a/src/components/internationalization/ar.json +++ b/src/components/internationalization/ar.json @@ -37,7 +37,8 @@ "dark": "داكن", "system": "النظام", "platform": "منصة", - "brandName": "مكان" + "brandName": "مكان", + "skipToContent": "تخطي إلى المحتوى الرئيسي" }, "navigation": { "menu": "القائمة", diff --git a/src/components/internationalization/en.json b/src/components/internationalization/en.json index c131749..9e73c64 100644 --- a/src/components/internationalization/en.json +++ b/src/components/internationalization/en.json @@ -37,7 +37,8 @@ "dark": "Dark", "system": "System", "platform": "Platform", - "brandName": "Mkan" + "brandName": "Mkan", + "skipToContent": "Skip to main content" }, "navigation": { "menu": "Menu", diff --git a/src/components/report-issue.tsx b/src/components/report-issue.tsx index ab08cf5..765195c 100644 --- a/src/components/report-issue.tsx +++ b/src/components/report-issue.tsx @@ -1,10 +1,10 @@ "use client" import { useState } from "react" -import { usePathname } from "next/navigation" import { Bug } from "lucide-react" import { reportIssue } from "@/lib/actions/report-issue" +import { useDictionary } from "@/components/internationalization/dictionary-context" import { Button } from "@/components/ui/button" import { Dialog, @@ -14,56 +14,37 @@ import { DialogTrigger, } from "@/components/ui/dialog" -const translations = { - en: { - link: "Report an issue", - title: "Report an issue", - placeholder: "Describe the issue...", - submit: "Submit", - submitting: "Submitting...", - success: "Submitted. Thank you!", - error: "Something went wrong. Try again.", - }, - ar: { - link: "\u0627\u0644\u0625\u0628\u0644\u0627\u063a \u0639\u0646 \u0645\u0634\u0643\u0644\u0629", - title: "\u0627\u0644\u0625\u0628\u0644\u0627\u063a \u0639\u0646 \u0645\u0634\u0643\u0644\u0629", - placeholder: "\u0635\u0641 \u0627\u0644\u0645\u0634\u0643\u0644\u0629...", - submit: "\u0625\u0631\u0633\u0627\u0644", - submitting: "\u062c\u0627\u0631\u064a \u0627\u0644\u0625\u0631\u0633\u0627\u0644...", - success: "\u062a\u0645 \u0627\u0644\u0625\u0631\u0633\u0627\u0644. \u0634\u0643\u0631\u0627\u064b \u0644\u0643!", - error: "\u062d\u062f\u062b \u062e\u0637\u0623. \u062d\u0627\u0648\u0644 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.", - }, -} as const - interface ReportIssueProps { variant?: "text" | "icon" } function parseBrowser(ua: string): string { - if (ua.includes("Firefox/")) return `Firefox / ${getOS(ua)}` - if (ua.includes("Edg/")) return `Edge / ${getOS(ua)}` - if (ua.includes("Chrome/")) return `Chrome / ${getOS(ua)}` - if (ua.includes("Safari/")) return `Safari / ${getOS(ua)}` + const os = ua.includes("Mac OS") + ? "macOS" + : ua.includes("Windows") + ? "Windows" + : ua.includes("Android") + ? "Android" + : ua.includes("iPhone") || ua.includes("iPad") + ? "iOS" + : ua.includes("Linux") + ? "Linux" + : "Unknown" + if (ua.includes("Firefox/")) return `Firefox / ${os}` + if (ua.includes("Edg/")) return `Edge / ${os}` + if (ua.includes("Chrome/")) return `Chrome / ${os}` + if (ua.includes("Safari/")) return `Safari / ${os}` return ua.slice(0, 50) } -function getOS(ua: string): string { - if (ua.includes("Mac OS")) return "macOS" - if (ua.includes("Windows")) return "Windows" - if (ua.includes("Android")) return "Android" - if (ua.includes("iPhone") || ua.includes("iPad")) return "iOS" - if (ua.includes("Linux")) return "Linux" - return "Unknown" -} - export function ReportIssue({ variant = "text" }: ReportIssueProps) { const [open, setOpen] = useState(false) const [description, setDescription] = useState("") const [status, setStatus] = useState< "idle" | "loading" | "success" | "error" >("idle") - const pathname = usePathname() - const t = translations[pathname?.startsWith("/ar") ? "ar" : "en"] + const dictionary = useDictionary() + const t = dictionary.reportIssue async function handleSubmit() { if (!description.trim()) return @@ -72,9 +53,11 @@ export function ReportIssue({ variant = "text" }: ReportIssueProps) { await reportIssue({ description, pageUrl: window.location.href, - viewport: `${window.innerWidth}x${window.innerHeight}`, - direction: document.documentElement.dir || "ltr", - browser: parseBrowser(navigator.userAgent), + meta: { + viewport: `${window.innerWidth}x${window.innerHeight}`, + direction: document.documentElement.dir || "ltr", + browser: parseBrowser(navigator.userAgent), + }, }) setStatus("success") setDescription("") @@ -97,10 +80,7 @@ export function ReportIssue({ variant = "text" }: ReportIssueProps) { > {variant === "icon" ? ( - ) : ( diff --git a/src/lib/actions/report-issue.ts b/src/lib/actions/report-issue.ts index b70e2bc..0012bed 100644 --- a/src/lib/actions/report-issue.ts +++ b/src/lib/actions/report-issue.ts @@ -2,24 +2,29 @@ import { auth } from "@/lib/auth" -export async function reportIssue(data: { +interface ReportIssueInput { description: string pageUrl: string - viewport?: string - direction?: string - browser?: string -}) { + meta?: { + viewport?: string + direction?: string + browser?: string + } +} + +export async function reportIssue(data: ReportIssueInput) { const token = process.env.GITHUB_PERSONAL_ACCESS_TOKEN const repo = process.env.GITHUB_REPO || "databayt/mkan" + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + } if (!token) throw new Error("Issue reporting is not configured") - const desc = data.description - const truncated = - desc.length > 80 ? desc.slice(0, 77) + "..." : desc - const title = truncated + const desc = data.description.trim() + const title = desc.length > 80 ? desc.slice(0, 77) + "..." : desc - // Reporter from auth session const session = await auth().catch(() => null) const reporter = session?.user ? `${session.user.name} (${session.user.email})` @@ -30,37 +35,39 @@ export async function reportIssue(data: { "", "---", "", - `**Reporter**: ${reporter}`, - `**Page**: \`${data.pageUrl}\``, - data.viewport ? `**Viewport**: ${data.viewport}` : null, - data.direction ? `**Direction**: ${data.direction}` : null, - data.browser ? `**Browser**: ${data.browser}` : null, + `**Page**: ${data.pageUrl}`, `**Time**: ${new Date().toISOString()}`, + `**Reporter**: ${reporter}`, + data.meta?.browser && `**Browser**: ${data.meta.browser}`, + data.meta?.viewport && `**Viewport**: ${data.meta.viewport}`, + data.meta?.direction && `**Direction**: ${data.meta.direction}`, ] .filter(Boolean) .join("\n") - // Try with label first, fall back without if label doesn't exist - const payload: Record = { title, body, labels: ["report"] } + const payload = { title, body, labels: ["report"] } let response = await fetch(`https://api.github.com/repos/${repo}/issues`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, + headers, body: JSON.stringify(payload), }) - // If 422 (label doesn't exist), retry without labels + // If 422 (label doesn't exist), create it then retry if (response.status === 422) { - delete payload.labels + await fetch(`https://api.github.com/repos/${repo}/labels`, { + method: "POST", + headers, + body: JSON.stringify({ + name: "report", + color: "d93f0b", + description: "User-reported issues", + }), + }).catch(() => {}) + response = await fetch(`https://api.github.com/repos/${repo}/issues`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, + headers, body: JSON.stringify(payload), }) } @@ -71,15 +78,11 @@ export async function reportIssue(data: { throw new Error(`GitHub API error: ${response.status}`) } - // Acknowledgment comment (fire-and-forget) const issueData = await response.json().catch(() => null) if (issueData?.comments_url) { fetch(issueData.comments_url, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, + headers, body: JSON.stringify({ body: "Received. This report is queued for automated review and fix. You'll be notified here when resolved.", }), diff --git a/src/lib/db.ts b/src/lib/db.ts index 65b1ad5..1c6b5a1 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -54,10 +54,21 @@ const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; +const isNeonUrl = (url: string | undefined): boolean => + typeof url === "string" && /\.neon\.tech/i.test(url); + const createPrismaClient = () => { const isProduction = process.env.NODE_ENV === "production"; const connectionString = getConnectionUrl(); - const adapterKind = (process.env.DATABASE_URL_ADAPTER ?? "pg").toLowerCase(); + // Default to the neon serverless adapter when the URL points at Neon. + // The pg adapter holds long-lived TCP connections that Neon drops when + // its serverless compute scales to zero — the next query then fails with + // "Server has closed the connection" (issue #4). The neon adapter speaks + // HTTPS+WS and wakes the compute on demand. + const adapterKind = ( + process.env.DATABASE_URL_ADAPTER ?? + (isNeonUrl(connectionString) ? "neon" : "pg") + ).toLowerCase(); let adapter; if (adapterKind === "neon") {