Compare commits

...

4 Commits

Author SHA1 Message Date
Min Zeya Phyo
964afce761 UI updates 2026-02-26 15:07:05 +06:30
Min Zeya Phyo
f0146c311c Add CLAUDE.md with project guidance for Claude Code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 10:51:05 +06:30
Deploy Bot
310fff9d55 Add force-dynamic to all pages for runtime DB queries 2026-02-19 15:04:03 +00:00
Deploy Bot
923d322273 Fix: use custom pg wrapper instead of @vercel/postgres 2026-02-19 14:53:59 +00:00
14 changed files with 720 additions and 275 deletions

1
.gitignore vendored
View File

@@ -41,3 +41,4 @@ coverage/
# Misc
*.tar.gz
*.zip
.playwright-mcp/

113
CLAUDE.md Normal file
View File

@@ -0,0 +1,113 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Burmddit is an automated AI news aggregator that scrapes English AI content, compiles related articles, translates them to Burmese using Claude API, and publishes them daily. It has two independent sub-systems:
- **Backend** (`/backend`): Python pipeline — scrape → compile → translate → publish
- **Frontend** (`/frontend`): Next.js 14 App Router site that reads from PostgreSQL
Both connect to the same PostgreSQL database hosted on Railway.
## Commands
### Frontend
```bash
cd frontend
npm install
npm run dev # Start dev server (localhost:3000)
npm run build # Production build
npm run lint # ESLint
```
### Backend
```bash
cd backend
pip install -r requirements.txt
# Run full pipeline (scrape + compile + translate + publish)
python run_pipeline.py
# Run individual stages
python scraper.py
python compiler.py
python translator.py
# Database management
python init_db.py # Initialize schema
python init_db.py stats # Show article/view counts
python init_db.py --reset # Drop and recreate (destructive)
```
### Required Environment Variables
**Frontend** (`.env.local`):
```
DATABASE_URL=postgresql://...
NEXT_PUBLIC_SITE_URL=https://burmddit.vercel.app
```
**Backend** (`.env`):
```
DATABASE_URL=postgresql://...
ANTHROPIC_API_KEY=sk-ant-...
ADMIN_PASSWORD=...
```
## Architecture
### Data Flow
```
[Scraper] → raw_articles table
[Compiler] → clusters related raw articles, generates compiled English articles
[Translator] → calls Claude API (claude-3-5-sonnet-20241022) to produce Burmese content
[Publisher] → inserts into articles table with status='published'
[Frontend] → queries published_articles view via @vercel/postgres
```
### Database Schema Key Points
- `raw_articles` — scraped source content, flagged `processed=TRUE` once compiled
- `articles` — final bilingual articles with both English and Burmese fields (`title`/`title_burmese`, `content`/`content_burmese`, etc.)
- `published_articles` — PostgreSQL view joining `articles` + `categories`, used by frontend queries
- `pipeline_logs` — tracks each stage execution for monitoring
### Frontend Architecture
Next.js 14 App Router with server components querying the database directly via `@vercel/postgres` (sql template tag). No API routes — all DB access happens in server components/actions.
Key pages: homepage (`app/page.tsx`), article detail (`app/[slug]/`), category listing (`app/category/`).
Burmese font: Noto Sans Myanmar loaded from Google Fonts. Apply `font-burmese` Tailwind class for Burmese text.
### Backend Pipeline (`run_pipeline.py`)
Orchestrates four stages sequentially. Each stage is a standalone module with a `run_*()` function. Pipeline exits early with a warning if a stage produces zero results. Logs go to both stderr and `burmddit_pipeline.log` (7-day rotation).
### Configuration (`backend/config.py`)
All tunable parameters live here:
- `SOURCES` — which RSS/scrape sources are enabled and their limits
- `PIPELINE` — articles per day, length limits, clustering threshold
- `TRANSLATION` — Claude model, temperature, technical terms to preserve in English
- `PUBLISHING` — default status (`'published'` or `'draft'`), image/video extraction settings
- `CATEGORY_KEYWORDS` — keyword lists for auto-detecting one of 4 categories
### Automation
Daily pipeline is triggered via GitHub Actions (`.github/workflows/daily-publish.yml`) at 6 AM UTC, using `DATABASE_URL` and `ANTHROPIC_API_KEY` repository secrets. Can also be triggered manually via `workflow_dispatch`.
## Deployment
- **Frontend**: Vercel (root directory: `frontend`, auto-detects Next.js)
- **Backend + DB**: Railway (root directory: `backend`, start command: `python run_pipeline.py`)
- **Database init**: Run `python init_db.py init` once from Railway console after first deploy

View File

@@ -1,12 +1,14 @@
import { sql } from '@vercel/postgres'
import { sql } from '@/lib/db'
export const dynamic = "force-dynamic"
import { notFound } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import ShareButtons from '@/components/ShareButtons'
async function getArticleWithTags(slug: string) {
try {
const { rows } = await sql`
SELECT
SELECT
a.*,
c.name as category_name,
c.name_burmese as category_name_burmese,
@@ -26,12 +28,9 @@ async function getArticleWithTags(slug: string) {
WHERE a.slug = ${slug} AND a.status = 'published'
GROUP BY a.id, c.id
`
if (rows.length === 0) return null
// Increment view count
// Increment view count only here (not in generateMetadata)
await sql`SELECT increment_view_count(${slug})`
return rows[0]
} catch (error) {
console.error('Error fetching article:', error)
@@ -39,34 +38,46 @@ async function getArticleWithTags(slug: string) {
}
}
// Separate metadata fetch — no view count increment
async function getArticleMeta(slug: string) {
try {
const { rows } = await sql`
SELECT title_burmese, excerpt_burmese, featured_image
FROM articles
WHERE slug = ${slug} AND status = 'published'
LIMIT 1
`
return rows[0] || null
} catch {
return null
}
}
async function getRelatedArticles(articleId: number) {
try {
const { rows } = await sql`SELECT * FROM get_related_articles(${articleId}, 6)`
return rows
} catch (error) {
} catch {
return []
}
}
export default async function ImprovedArticlePage({ params }: { params: { slug: string } }) {
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const article = await getArticleWithTags(params.slug)
if (!article) {
notFound()
}
if (!article) notFound()
const relatedArticles = await getRelatedArticles(article.id)
const publishedDate = new Date(article.published_at).toLocaleDateString('my-MM', {
year: 'numeric',
month: 'long',
day: 'numeric'
day: 'numeric',
})
return (
<div className="min-h-screen bg-white">
{/* Hero Cover Image */}
{article.featured_image && (
<div className="relative h-[70vh] w-full overflow-hidden">
<div className="relative h-[55vh] w-full overflow-hidden">
<Image
src={article.featured_image}
alt={article.title_burmese}
@@ -74,25 +85,20 @@ export default async function ImprovedArticlePage({ params }: { params: { slug:
className="object-cover"
priority
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-black/10" />
<div className="absolute inset-0 flex items-end">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-16 w-full">
{/* Category */}
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-10 w-full">
<Link
href={`/category/${article.category_slug}`}
className="inline-block mb-4 px-4 py-2 bg-primary rounded-full text-white font-semibold text-sm hover:bg-primary-dark transition-colors"
className="inline-block mb-3 px-4 py-1.5 bg-primary rounded-full text-white font-semibold text-sm hover:bg-primary-dark transition-colors font-burmese"
>
{article.category_name_burmese}
</Link>
{/* Title */}
<h1 className="text-5xl md:text-6xl font-bold text-white mb-6 font-burmese leading-tight">
<h1 className="text-2xl md:text-3xl lg:text-4xl font-bold text-white mb-4 font-burmese leading-snug line-clamp-3">
{article.title_burmese}
</h1>
{/* Meta */}
<div className="flex flex-wrap items-center gap-4 text-white/90">
<div className="flex flex-wrap items-center gap-3 text-white/80 text-sm">
<span className="font-burmese">{publishedDate}</span>
<span></span>
<span className="font-burmese">{article.reading_time} </span>
@@ -105,10 +111,10 @@ export default async function ImprovedArticlePage({ params }: { params: { slug:
)}
{/* Article Content */}
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<article className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Tags */}
{article.tags_burmese && article.tags_burmese.length > 0 && (
<div className="flex flex-wrap gap-2 mb-8 pb-8 border-b">
<div className="flex flex-wrap gap-2 mb-8 pb-8 border-b border-gray-100">
{article.tags_burmese.map((tag: string, idx: number) => (
<Link
key={idx}
@@ -121,33 +127,28 @@ export default async function ImprovedArticlePage({ params }: { params: { slug:
</div>
)}
{/* Article Body */}
{/* Body */}
<div className="article-content">
<div dangerouslySetInnerHTML={{ __html: formatContent(article.content_burmese) }} />
{/* Additional Images Gallery */}
{/* Image Gallery */}
{article.images && article.images.length > 1 && (
<div className="my-12">
<h3 className="text-2xl font-bold mb-6 font-burmese"></h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="my-10 not-prose">
<h3 className="text-xl font-bold mb-4 font-burmese text-gray-900"></h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{article.images.slice(1).map((img: string, idx: number) => (
<div key={idx} className="relative h-56 rounded-xl overflow-hidden image-zoom">
<Image
src={img}
alt={`${article.title_burmese} - ${idx + 2}`}
fill
className="object-cover"
/>
<div key={idx} className="relative h-48 rounded-xl overflow-hidden image-zoom">
<Image src={img} alt={`${article.title_burmese} ${idx + 2}`} fill className="object-cover" />
</div>
))}
</div>
</div>
)}
{/* Videos */}
{article.videos && article.videos.length > 0 && (
<div className="my-12">
<h3 className="text-2xl font-bold mb-6 font-burmese"></h3>
<div className="my-10 not-prose">
<h3 className="text-xl font-bold mb-4 font-burmese text-gray-900"></h3>
<div className="space-y-6">
{article.videos.map((video: string, idx: number) => (
<div key={idx} className="relative aspect-video rounded-xl overflow-hidden bg-gray-900 shadow-xl">
@@ -161,49 +162,36 @@ export default async function ImprovedArticlePage({ params }: { params: { slug:
{/* Source Attribution */}
{article.source_articles && article.source_articles.length > 0 && (
<div className="mt-16 p-8 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl shadow-lg">
<h3 className="text-2xl font-bold text-gray-900 mb-4 font-burmese flex items-center">
<svg className="w-7 h-7 mr-3 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="mt-12 p-6 bg-blue-50 rounded-2xl border border-blue-100">
<h3 className="text-lg font-bold text-gray-900 mb-3 font-burmese flex items-center gap-2">
<svg className="w-5 h-5 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</h3>
<p className="text-sm text-gray-700 mb-6 font-burmese leading-relaxed">
က က က က က
<p className="text-sm text-gray-600 mb-4 font-burmese leading-relaxed">
က က
</p>
<div className="space-y-4">
<div className="space-y-3">
{article.source_articles.map((source: any, index: number) => (
<div key={index} className="bg-white p-5 rounded-xl shadow-sm hover:shadow-md transition-shadow border border-gray-100">
<div className="flex items-start gap-4">
<span className="flex-shrink-0 w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center text-sm font-bold">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary-dark font-medium break-words hover:underline"
>
{source.title}
</a>
{source.author && source.author !== 'Unknown' && (
<p className="text-sm text-gray-600 mt-2">
<span className="font-burmese font-semibold">:</span> {source.author}
</p>
)}
</div>
<div key={index} className="bg-white p-4 rounded-xl border border-gray-100 flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 bg-primary text-white rounded-full flex items-center justify-center text-xs font-bold">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 text-primary hover:text-primary-dark"
title="Open source"
className="text-primary hover:underline text-sm font-medium break-words"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{source.title}
</a>
{source.author && source.author !== 'Unknown' && (
<p className="text-xs text-gray-500 mt-1 font-burmese">
: {source.author}
</p>
)}
</div>
</div>
))}
@@ -211,58 +199,39 @@ export default async function ImprovedArticlePage({ params }: { params: { slug:
</div>
)}
{/* Share Section */}
<div className="mt-12 py-8 border-y border-gray-200">
<div className="flex items-center justify-between">
<p className="font-burmese text-gray-700 font-semibold">:</p>
<div className="flex gap-3">
<button className="px-4 py-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors">
Facebook
</button>
<button className="px-4 py-2 bg-sky-500 text-white rounded-full hover:bg-sky-600 transition-colors">
Twitter
</button>
<button className="px-4 py-2 bg-green-600 text-white rounded-full hover:bg-green-700 transition-colors">
WhatsApp
</button>
</div>
</div>
{/* Share */}
<div className="mt-10 py-8 border-t border-gray-100">
<p className="font-burmese text-gray-700 font-semibold mb-4">:</p>
<ShareButtons title={article.title_burmese} />
</div>
</article>
{/* Related Articles */}
{relatedArticles.length > 0 && (
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 bg-gray-50">
<h2 className="text-3xl font-bold text-gray-900 mb-10 font-burmese">
က
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{relatedArticles.map((related: any) => (
<Link
key={related.id}
href={`/article/${related.slug}`}
className="card card-hover"
>
{related.featured_image && (
<div className="relative h-48 w-full image-zoom">
<Image
src={related.featured_image}
alt={related.title_burmese}
fill
className="object-cover"
/>
<section className="bg-gray-50 py-14">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-8 font-burmese">
က
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{relatedArticles.map((related: any) => (
<Link key={related.id} href={`/article/${related.slug}`} className="card card-hover">
{related.featured_image && (
<div className="relative h-44 w-full image-zoom">
<Image src={related.featured_image} alt={related.title_burmese} fill className="object-cover" />
</div>
)}
<div className="p-5">
<h3 className="font-bold text-gray-900 font-burmese line-clamp-3 hover:text-primary transition-colors leading-snug mb-2">
{related.title_burmese}
</h3>
<p className="text-sm text-gray-500 font-burmese line-clamp-2 leading-relaxed">
{related.excerpt_burmese}
</p>
</div>
)}
<div className="p-6">
<h3 className="font-bold text-gray-900 font-burmese line-clamp-2 hover:text-primary transition-colors text-lg mb-3">
{related.title_burmese}
</h3>
<p className="text-sm text-gray-600 font-burmese line-clamp-2">
{related.excerpt_burmese}
</p>
</div>
</Link>
))}
</Link>
))}
</div>
</div>
</section>
)}
@@ -271,30 +240,30 @@ export default async function ImprovedArticlePage({ params }: { params: { slug:
}
function formatContent(content: string): string {
let formatted = content
.replace(/\n\n/g, '</p><p>')
.replace(/## (.*?)\n/g, '<h2>$1</h2>')
.replace(/### (.*?)\n/g, '<h3>$1</h3>')
const formatted = content
.replace(/### (.*?)(\n|$)/g, '<h3>$1</h3>')
.replace(/## (.*?)(\n|$)/g, '<h2>$1</h2>')
.replace(/# (.*?)(\n|$)/g, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/\n\n+/g, '</p><p>')
.replace(/\n/g, '<br/>')
return `<p>${formatted}</p>`
}
function renderVideo(videoUrl: string) {
let videoId = null
let videoId: string | null = null
if (videoUrl.includes('youtube.com/watch')) {
const match = videoUrl.match(/v=([^&]+)/)
videoId = match ? match[1] : null
const m = videoUrl.match(/v=([^&]+)/)
videoId = m ? m[1] : null
} else if (videoUrl.includes('youtu.be/')) {
const match = videoUrl.match(/youtu\.be\/([^?]+)/)
videoId = match ? match[1] : null
const m = videoUrl.match(/youtu\.be\/([^?]+)/)
videoId = m ? m[1] : null
} else if (videoUrl.includes('youtube.com/embed/')) {
const match = videoUrl.match(/embed\/([^?]+)/)
videoId = match ? match[1] : null
const m = videoUrl.match(/embed\/([^?]+)/)
videoId = m ? m[1] : null
}
if (videoId) {
return (
<iframe
@@ -305,25 +274,12 @@ function renderVideo(videoUrl: string) {
/>
)
}
return (
<iframe
src={videoUrl}
className="w-full h-full"
allowFullScreen
/>
)
return null
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const article = await getArticleWithTags(params.slug)
if (!article) {
return {
title: 'Article Not Found',
}
}
const article = await getArticleMeta(params.slug)
if (!article) return { title: 'Article Not Found' }
return {
title: `${article.title_burmese} - Burmddit`,
description: article.excerpt_burmese,

View File

@@ -0,0 +1,118 @@
import { sql } from '@/lib/db'
export const dynamic = "force-dynamic"
import { notFound } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
const CATEGORY_META: Record<string, { icon: string; color: string }> = {
'ai-news': { icon: '📰', color: 'from-blue-600 to-blue-800' },
'tutorials': { icon: '📚', color: 'from-purple-600 to-purple-800' },
'tips-tricks': { icon: '💡', color: 'from-amber-500 to-orange-600' },
'upcoming': { icon: '🚀', color: 'from-emerald-600 to-teal-700' },
}
async function getCategory(slug: string) {
try {
const { rows } = await sql`SELECT * FROM categories WHERE slug = ${slug}`
return rows[0] || null
} catch {
return null
}
}
async function getArticlesByCategory(slug: string) {
try {
const { rows } = await sql`
SELECT
a.id, a.title_burmese, a.slug, a.excerpt_burmese,
a.featured_image, a.reading_time, a.view_count, a.published_at,
c.name_burmese as category_name_burmese, c.slug as category_slug
FROM articles a
JOIN categories c ON a.category_id = c.id
WHERE c.slug = ${slug} AND a.status = 'published'
ORDER BY a.published_at DESC
LIMIT 30
`
return rows
} catch {
return []
}
}
export default async function CategoryPage({ params }: { params: { slug: string } }) {
const [category, articles] = await Promise.all([
getCategory(params.slug),
getArticlesByCategory(params.slug),
])
if (!category) notFound()
const meta = CATEGORY_META[params.slug] ?? { icon: '📄', color: 'from-gray-600 to-gray-800' }
return (
<div className="min-h-screen bg-gray-50">
{/* Category Header */}
<div className={`bg-gradient-to-br ${meta.color} text-white`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="text-5xl mb-4">{meta.icon}</div>
<h1 className="text-4xl font-bold font-burmese mb-3">{category.name_burmese}</h1>
<p className="text-white/80 font-burmese text-lg">
{articles.length}
</p>
</div>
</div>
{/* Articles Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{articles.length === 0 ? (
<div className="text-center py-20 bg-white rounded-2xl shadow-sm">
<div className="text-6xl mb-4">📭</div>
<p className="text-xl text-gray-500 font-burmese"> က က</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{articles.map((article: any) => (
<article key={article.id} className="card card-hover">
{article.featured_image && (
<Link href={`/article/${article.slug}`} className="block image-zoom">
<div className="relative h-52 w-full">
<Image
src={article.featured_image}
alt={article.title_burmese}
fill
className="object-cover"
/>
</div>
</Link>
)}
<div className="p-6">
<h2 className="text-lg font-bold text-gray-900 mb-3 font-burmese line-clamp-3 leading-snug">
<Link href={`/article/${article.slug}`} className="hover:text-primary transition-colors">
{article.title_burmese}
</Link>
</h2>
<p className="text-gray-600 mb-4 font-burmese line-clamp-3 text-sm leading-relaxed">
{article.excerpt_burmese}
</p>
<div className="flex items-center justify-between text-sm text-gray-500 pt-4 border-t border-gray-100">
<span className="font-burmese">{article.reading_time} </span>
<span>{article.view_count} views</span>
</div>
</div>
</article>
))}
</div>
)}
</div>
</div>
)
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const category = await getCategory(params.slug)
if (!category) return { title: 'Category Not Found' }
return {
title: `${category.name_burmese} - Burmddit`,
description: `${category.name_burmese} နှင့် ပတ်သက်သော နောက်ဆုံးရ AI ဆောင်းပါးများ`,
}
}

View File

@@ -17,24 +17,9 @@
}
}
/* Burmese Fonts - Better rendering */
@font-face {
font-family: 'Pyidaungsu';
src: url('https://myanmar-tools-website.appspot.com/fonts/Pyidaungsu-2.5.3_Regular.ttf') format('truetype');
font-weight: 400;
font-display: swap;
font-feature-settings: "liga" 1;
}
@font-face {
font-family: 'Pyidaungsu';
src: url('https://myanmar-tools-website.appspot.com/fonts/Pyidaungsu-2.5.3_Bold.ttf') format('truetype');
font-weight: 700;
font-display: swap;
}
/* Burmese Fonts */
.font-burmese {
font-family: 'Pyidaungsu', 'Noto Sans Myanmar', 'Myanmar Text', sans-serif;
font-family: 'Noto Sans Myanmar', 'Myanmar Text', sans-serif;
letter-spacing: 0.01em;
}

View File

@@ -1,15 +1,24 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { Inter, Noto_Sans_Myanmar } from 'next/font/google'
import './globals.css'
import Header from '@/components/Header'
import Footer from '@/components/Footer'
const inter = Inter({ subsets: ['latin'] })
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
const notoSansMyanmar = Noto_Sans_Myanmar({
weight: ['300', '400', '500', '600', '700'],
subsets: ['myanmar'],
variable: '--font-burmese',
display: 'swap',
})
export const metadata: Metadata = {
title: 'Burmddit - Myanmar AI News & Tutorials',
description: 'Daily AI news, tutorials, and tips in Burmese. Stay updated with the latest in artificial intelligence.',
keywords: 'AI, Myanmar, Burmese, AI news, AI tutorials, machine learning, ChatGPT',
title: 'Burmddit - Myanmar AI သတင်းများ',
description: 'AI နှင့် နည်းပညာဆိုင်ရာ သတင်းများ၊ သင်ခန်းစာများနှင့် အကြံပြုချက်များကို မြန်မာဘာသာဖြင့် နေ့စဉ် ဖတ်ရှုနိုင်သော ပလက်ဖောင်း',
keywords: 'AI, Myanmar, Burmese, AI news, AI tutorials, machine learning, ChatGPT, မြန်မာ AI',
icons: {
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%232563eb'/><text x='50' y='72' font-size='60' text-anchor='middle' fill='white' font-family='Arial' font-weight='bold'>B</text></svg>",
},
}
export default function RootLayout({
@@ -18,13 +27,8 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<html lang="my" className="font-burmese">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Myanmar:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body className={`${inter.className} bg-gray-50`}>
<html lang="my" className={`${inter.variable} ${notoSansMyanmar.variable}`}>
<body className={`${inter.className} bg-gray-50 antialiased`}>
<Header />
<main className="min-h-screen">
{children}

View File

@@ -0,0 +1,32 @@
import Link from 'next/link'
export default function NotFound() {
return (
<div className="min-h-[70vh] flex items-center justify-center bg-gray-50">
<div className="text-center px-4 max-w-lg">
<div className="text-8xl mb-6">🔍</div>
<h1 className="text-7xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-2xl font-bold text-gray-800 mb-4 font-burmese">
က
</h2>
<p className="text-gray-500 mb-8 font-burmese leading-relaxed">
ကက က URL
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/"
className="inline-flex items-center justify-center px-8 py-3 bg-primary text-white rounded-full font-semibold hover:bg-primary-dark transition-all font-burmese"
>
က
</Link>
<Link
href="/search"
className="inline-flex items-center justify-center px-8 py-3 bg-white text-gray-700 border border-gray-300 rounded-full font-semibold hover:bg-gray-50 transition-all font-burmese"
>
🔍
</Link>
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { sql } from '@vercel/postgres'
import { sql } from '@/lib/db'
export const dynamic = "force-dynamic"
import Image from 'next/image'
import Link from 'next/link'
@@ -80,7 +81,7 @@ export default async function ImprovedHome() {
</Link>
{/* Title */}
<h1 className="text-5xl md:text-6xl font-bold text-white mb-4 font-burmese leading-tight">
<h1 className="text-2xl md:text-3xl lg:text-4xl font-bold text-white mb-4 font-burmese leading-snug line-clamp-3">
<Link href={`/article/${featured.slug}`} className="hover:text-gray-200 transition-colors">
{featured.title_burmese}
</Link>

View File

@@ -0,0 +1,133 @@
import { sql } from '@/lib/db'
export const dynamic = "force-dynamic"
import Link from 'next/link'
import Image from 'next/image'
async function searchArticles(query: string) {
if (!query || query.trim().length < 2) return []
try {
const pattern = `%${query.trim()}%`
const { rows } = await sql`
SELECT
a.id, a.title_burmese, a.slug, a.excerpt_burmese,
a.featured_image, a.reading_time, a.view_count, a.published_at,
c.name_burmese as category_name_burmese, c.slug as category_slug
FROM articles a
JOIN categories c ON a.category_id = c.id
WHERE a.status = 'published'
AND (
a.title_burmese ILIKE ${pattern}
OR a.excerpt_burmese ILIKE ${pattern}
OR a.title ILIKE ${pattern}
)
ORDER BY a.published_at DESC
LIMIT 20
`
return rows
} catch {
return []
}
}
export default async function SearchPage({
searchParams,
}: {
searchParams: { q?: string }
}) {
const query = searchParams.q ?? ''
const results = await searchArticles(query)
return (
<div className="min-h-screen bg-gray-50">
{/* Search Header */}
<div className="bg-white border-b border-gray-200">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<h1 className="text-2xl font-bold text-gray-900 mb-6 font-burmese"></h1>
<form action="/search" method="GET">
<div className="relative">
<svg
className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
name="q"
defaultValue={query}
placeholder="ဆောင်းပါးများ ရှာဖွေရန်..."
className="w-full pl-12 pr-4 py-4 border-2 border-gray-200 rounded-2xl font-burmese text-lg focus:outline-none focus:border-primary transition-colors bg-gray-50"
autoFocus
/>
</div>
</form>
</div>
</div>
{/* Results */}
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
{query && (
<p className="text-sm text-gray-500 mb-6 font-burmese">
&ldquo;{query}&rdquo; က {results.length}
</p>
)}
{!query && (
<div className="text-center py-20 text-gray-400">
<div className="text-6xl mb-4">🔍</div>
<p className="font-burmese text-lg"> က </p>
</div>
)}
{query && results.length === 0 && (
<div className="text-center py-20">
<div className="text-6xl mb-4">😕</div>
<p className="text-gray-500 font-burmese text-lg mb-4"></p>
<Link href="/" className="text-primary font-burmese hover:underline">
က
</Link>
</div>
)}
<div className="space-y-6">
{results.map((article: any) => (
<Link
key={article.id}
href={`/article/${article.slug}`}
className="flex gap-4 bg-white rounded-xl p-4 shadow-sm hover:shadow-md transition-all border border-gray-100 group"
>
{article.featured_image && (
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden">
<Image
src={article.featured_image}
alt={article.title_burmese}
fill
className="object-cover"
/>
</div>
)}
<div className="flex-1 min-w-0">
<span className="inline-block px-2 py-0.5 bg-primary/10 text-primary rounded text-xs font-burmese mb-2">
{article.category_name_burmese}
</span>
<h2 className="font-bold text-gray-900 font-burmese line-clamp-2 group-hover:text-primary transition-colors leading-snug mb-1">
{article.title_burmese}
</h2>
<p className="text-sm text-gray-500 font-burmese line-clamp-2 leading-relaxed">
{article.excerpt_burmese}
</p>
</div>
</Link>
))}
</div>
</div>
</div>
)
}
export async function generateMetadata({ searchParams }: { searchParams: { q?: string } }) {
const q = searchParams.q
return {
title: q ? `"${q}" - ရှာဖွေမှု - Burmddit` : 'ရှာဖွေရန် - Burmddit',
}
}

View File

@@ -1,4 +1,5 @@
import { sql } from '@vercel/postgres'
import { sql } from '@/lib/db'
export const dynamic = "force-dynamic"
import { notFound } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'

View File

@@ -1,69 +1,66 @@
import Link from 'next/link'
const categories = [
{ href: '/category/ai-news', label: 'AI သတင်းများ' },
{ href: '/category/tutorials', label: 'သင်ခန်းစာများ' },
{ href: '/category/tips-tricks', label: 'အကြံပြုချက်များ' },
{ href: '/category/upcoming', label: 'လာမည့်အရာများ' },
]
export default function Footer() {
return (
<footer className="bg-gray-900 text-white mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* About */}
{/* Brand */}
<div>
<h3 className="text-lg font-bold mb-4 font-burmese">Burmddit က</h3>
<p className="text-gray-400 text-sm font-burmese">
<Link href="/" className="flex items-center space-x-2 mb-4">
<span className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-white font-bold text-lg">B</span>
<span className="text-xl font-bold font-burmese">Burmddit</span>
</Link>
<p className="text-gray-400 text-sm font-burmese leading-relaxed">
AI က က
</p>
</div>
{/* Links */}
{/* Categories */}
<div>
<h3 className="text-lg font-bold mb-4 font-burmese"></h3>
<h3 className="text-base font-bold mb-4 font-burmese"></h3>
<ul className="space-y-2 text-sm">
<li>
<a href="/category/ai-news" className="text-gray-400 hover:text-white font-burmese">
AI
</a>
</li>
<li>
<a href="/category/tutorials" className="text-gray-400 hover:text-white font-burmese">
</a>
</li>
<li>
<a href="/category/tips-tricks" className="text-gray-400 hover:text-white font-burmese">
ကက
</a>
</li>
<li>
<a href="/category/upcoming" className="text-gray-400 hover:text-white font-burmese">
</a>
</li>
{categories.map((c) => (
<li key={c.href}>
<Link href={c.href} className="text-gray-400 hover:text-white font-burmese transition-colors">
{c.label}
</Link>
</li>
))}
</ul>
</div>
{/* Contact */}
{/* Quick Links */}
<div>
<h3 className="text-lg font-bold mb-4">Contact</h3>
<p className="text-gray-400 text-sm">
<h3 className="text-base font-bold mb-4 font-burmese"></h3>
<ul className="space-y-2 text-sm">
<li>
<Link href="/search" className="text-gray-400 hover:text-white font-burmese transition-colors">
🔍
</Link>
</li>
<li>
<Link href="/" className="text-gray-400 hover:text-white font-burmese transition-colors">
📰 က
</Link>
</li>
</ul>
<p className="text-gray-500 text-xs mt-6 font-burmese leading-relaxed">
Built with for Myanmar tech community
</p>
<div className="mt-4 flex space-x-4">
<a href="#" className="text-gray-400 hover:text-white">
<span className="sr-only">Twitter</span>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" />
</svg>
</a>
<a href="#" className="text-gray-400 hover:text-white">
<span className="sr-only">GitHub</span>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
</a>
</div>
</div>
</div>
<div className="mt-8 pt-8 border-t border-gray-800 text-center">
<p className="text-gray-400 text-sm">
© {new Date().getFullYear()} Burmddit. All rights reserved.
<p className="text-gray-500 text-sm font-burmese">
© {new Date().getFullYear()} Burmddit.
</p>
</div>
</div>

View File

@@ -1,53 +1,104 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const navLinks = [
{ href: '/', label: 'ပင်မစာမျက်နှာ' },
{ href: '/category/ai-news', label: 'AI သတင်းများ' },
{ href: '/category/tutorials', label: 'သင်ခန်းစာများ' },
{ href: '/category/tips-tricks', label: 'အကြံပြုချက်များ' },
{ href: '/category/upcoming', label: 'လာမည့်အရာများ' },
]
export default function Header() {
const [mobileOpen, setMobileOpen] = useState(false)
const pathname = usePathname()
return (
<header className="bg-white shadow-sm sticky top-0 z-50">
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2">
<span className="text-2xl font-bold text-primary-600">B</span>
<span className="text-xl font-bold text-gray-900 font-burmese">
Burmddit
</span>
<Link href="/" className="flex items-center space-x-2 flex-shrink-0">
<span className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-white font-bold text-lg">B</span>
<span className="text-xl font-bold text-gray-900 font-burmese">Burmddit</span>
</Link>
{/* Navigation */}
<div className="hidden md:flex space-x-8">
<Link
href="/"
className="text-gray-700 hover:text-primary-600 font-medium font-burmese"
>
က
</Link>
<Link
href="/category/ai-news"
className="text-gray-700 hover:text-primary-600 font-medium font-burmese"
>
AI
</Link>
<Link
href="/category/tutorials"
className="text-gray-700 hover:text-primary-600 font-medium font-burmese"
>
</Link>
<Link
href="/category/tips-tricks"
className="text-gray-700 hover:text-primary-600 font-medium font-burmese"
>
ကက
</Link>
{/* Desktop Nav */}
<div className="hidden md:flex items-center space-x-1">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`px-3 py-2 rounded-lg text-sm font-medium font-burmese transition-colors ${
pathname === link.href
? 'bg-primary/10 text-primary'
: 'text-gray-700 hover:text-primary hover:bg-gray-100'
}`}
>
{link.label}
</Link>
))}
</div>
{/* Search Icon */}
<button className="p-2 text-gray-600 hover:text-primary-600">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
{/* Right: Search + Mobile Hamburger */}
<div className="flex items-center space-x-1">
<Link
href="/search"
className="p-2 text-gray-600 hover:text-primary hover:bg-gray-100 rounded-lg transition-colors"
aria-label="ရှာဖွေရန်"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</Link>
{/* Hamburger */}
<button
className="md:hidden p-2 text-gray-600 hover:text-primary hover:bg-gray-100 rounded-lg transition-colors"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Menu"
>
{mobileOpen ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
</div>
{/* Mobile Menu */}
{mobileOpen && (
<div className="md:hidden pb-4 pt-2 border-t border-gray-100 space-y-1">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className={`block px-4 py-3 rounded-lg text-sm font-medium font-burmese transition-colors ${
pathname === link.href
? 'bg-primary/10 text-primary'
: 'text-gray-700 hover:text-primary hover:bg-gray-50'
}`}
>
{link.label}
</Link>
))}
<Link
href="/search"
onClick={() => setMobileOpen(false)}
className="block px-4 py-3 rounded-lg text-sm font-medium font-burmese text-gray-700 hover:text-primary hover:bg-gray-50 transition-colors"
>
🔍
</Link>
</div>
)}
</nav>
</header>
)

View File

@@ -0,0 +1,51 @@
'use client'
import { useEffect, useState } from 'react'
export default function ShareButtons({ title }: { title: string }) {
const [url, setUrl] = useState('')
useEffect(() => {
setUrl(window.location.href)
}, [])
const encodedUrl = encodeURIComponent(url)
const encodedTitle = encodeURIComponent(title)
return (
<div className="flex flex-wrap gap-3">
<a
href={`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-full text-sm font-semibold hover:bg-blue-700 transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
Facebook
</a>
<a
href={`https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-black text-white rounded-full text-sm font-semibold hover:bg-gray-800 transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
X (Twitter)
</a>
<a
href={`https://wa.me/?text=${encodedTitle}%20${encodedUrl}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-green-600 text-white rounded-full text-sm font-semibold hover:bg-green-700 transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
WhatsApp
</a>
</div>
)
}

View File

@@ -9,20 +9,22 @@ const config: Config = {
theme: {
extend: {
fontFamily: {
'burmese': ['Pyidaungsu', 'Noto Sans Myanmar', 'Myanmar Text', 'sans-serif'],
'burmese': ['Noto Sans Myanmar', 'Myanmar Text', 'sans-serif'],
},
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
DEFAULT: '#2563eb',
dark: '#1e40af',
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
},
},