Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/app/[lang]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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}
</a>
<Providers>
<DictionaryProvider dictionary={dictionary}>
{children}
<Toaster richColors />
<div className="fixed end-4 bottom-4 z-40 print:hidden">
<ReportIssue variant="icon" />
</div>
</DictionaryProvider>
</Providers>
</body>
Expand Down
3 changes: 2 additions & 1 deletion src/components/internationalization/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"dark": "داكن",
"system": "النظام",
"platform": "منصة",
"brandName": "مكان"
"brandName": "مكان",
"skipToContent": "تخطي إلى المحتوى الرئيسي"
},
"navigation": {
"menu": "القائمة",
Expand Down
3 changes: 2 additions & 1 deletion src/components/internationalization/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"dark": "Dark",
"system": "System",
"platform": "Platform",
"brandName": "Mkan"
"brandName": "Mkan",
"skipToContent": "Skip to main content"
},
"navigation": {
"menu": "Menu",
Expand Down
68 changes: 24 additions & 44 deletions src/components/report-issue.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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("")
Expand All @@ -97,10 +80,7 @@ export function ReportIssue({ variant = "text" }: ReportIssueProps) {
>
<DialogTrigger asChild>
{variant === "icon" ? (
<button
className="cursor-pointer"
aria-label={t.link}
>
<button className="cursor-pointer" aria-label={t.link}>
<Bug className="h-6 w-6" strokeWidth={1} />
</button>
) : (
Expand Down
67 changes: 35 additions & 32 deletions src/lib/actions/report-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`
Expand All @@ -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<string, unknown> = { 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),
})
}
Expand All @@ -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.",
}),
Expand Down
13 changes: 12 additions & 1 deletion src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Loading