- {/* Center - Map/Globe + Timeline */}
+ {/* Center โ Map/Globe (hero of the page) */}
- {/* View Switcher + Map Container */}
-
+
{/* View Mode Toggle */}
- {/* Map View */}
{viewMode === "2d" && (
)}
-
- {/* Globe View */}
{viewMode === "3d" && (
)}
@@ -109,8 +101,9 @@ export default function HomePage() {
- {/* Right Panel */}
+ {/* Right Panel โ Register + Feed */}
+
diff --git a/app/api/v1/device/name/route.ts b/app/api/v1/device/name/route.ts
new file mode 100644
index 0000000..8867ef4
--- /dev/null
+++ b/app/api/v1/device/name/route.ts
@@ -0,0 +1,40 @@
+import { NextRequest, NextResponse } from "next/server";
+import { eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { claws } from "@/lib/db/schema";
+import { authenticateRequest } from "@/lib/auth/request";
+import { updateNameSchema } from "@/lib/validators/schemas";
+
+export async function PUT(req: NextRequest) {
+ try {
+ const auth = await authenticateRequest(req);
+ if (auth instanceof NextResponse) {
+ return auth;
+ }
+ const { claw } = auth;
+
+ const body = await req.json();
+ const parsed = updateNameSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Validation failed", details: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ const { name } = parsed.data;
+
+ await db
+ .update(claws)
+ .set({ name, updatedAt: new Date() })
+ .where(eq(claws.id, claw.id));
+
+ return NextResponse.json({ ok: true, name });
+ } catch (error) {
+ console.error("Update name error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/v1/device/register/route.ts b/app/api/v1/device/register/route.ts
new file mode 100644
index 0000000..b3c1796
--- /dev/null
+++ b/app/api/v1/device/register/route.ts
@@ -0,0 +1,150 @@
+import { NextRequest, NextResponse } from "next/server";
+import { nanoid } from "nanoid";
+import { eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { claws } from "@/lib/db/schema";
+import { redis } from "@/lib/redis";
+import { generateApiKey } from "@/lib/auth/api-key";
+import { getGeoLocation } from "@/lib/geo/ip-location";
+import { applyDeterministicOffset } from "@/lib/geo/offset";
+import { deviceRegisterSchema } from "@/lib/validators/schemas";
+
+function getClientIp(req: NextRequest): string {
+ const forwarded = req.headers.get("x-forwarded-for");
+ if (forwarded) return forwarded.split(",")[0].trim();
+ const realIp = req.headers.get("x-real-ip");
+ if (realIp) return realIp.trim();
+ return "127.0.0.1";
+}
+
+const RATE_LIMIT_KEY_PREFIX = "ratelimit:device:";
+const RATE_LIMIT_MAX = 10;
+const RATE_LIMIT_WINDOW = 3600; // 1 hour
+
+export async function POST(req: NextRequest) {
+ try {
+ const clientIp = getClientIp(req);
+
+ // Rate limit check
+ const rateLimitKey = `${RATE_LIMIT_KEY_PREFIX}${clientIp}`;
+ const currentCount = await redis.incr(rateLimitKey);
+ if (currentCount === 1) {
+ await redis.expire(rateLimitKey, RATE_LIMIT_WINDOW);
+ }
+ if (currentCount > RATE_LIMIT_MAX) {
+ return NextResponse.json(
+ { error: "Too many requests. Try again later." },
+ { status: 429 }
+ );
+ }
+
+ const body = await req.json();
+ const parsed = deviceRegisterSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Validation failed", details: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ const { deviceId, name, platform } = parsed.data;
+
+ // Check if device already registered
+ const existing = await db
+ .select()
+ .from(claws)
+ .where(eq(claws.deviceId, deviceId))
+ .limit(1);
+
+ if (existing.length > 0) {
+ return NextResponse.json({
+ clawId: existing[0].id,
+ apiKey: existing[0].apiKey,
+ name: existing[0].name,
+ isNew: false,
+ });
+ }
+
+ // Create new claw
+ const clawId = nanoid(21);
+ const apiKey = generateApiKey();
+ const geo = await getGeoLocation(clientIp);
+ const now = new Date();
+
+ let finalLat: number | null = null;
+ let finalLng: number | null = null;
+ if (geo) {
+ const offset = applyDeterministicOffset(
+ geo.latitude,
+ geo.longitude,
+ clawId
+ );
+ finalLat = offset.lat;
+ finalLng = offset.lng;
+ }
+
+ // Auto-generate name if not provided
+ const clawName = name || generateClawName();
+
+ await db.insert(claws).values({
+ id: clawId,
+ apiKey,
+ deviceId,
+ name: clawName,
+ platform: platform ? platform.slice(0, 20) : null,
+ ip: clientIp,
+ latitude: finalLat !== null ? String(finalLat) : null,
+ longitude: finalLng !== null ? String(finalLng) : null,
+ city: geo?.city ?? null,
+ country: geo?.country ?? null,
+ countryCode: geo?.countryCode ?? null,
+ region: geo?.region ?? null,
+ lastHeartbeat: now,
+ totalTasks: 0,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ return NextResponse.json({
+ clawId,
+ apiKey,
+ name: clawName,
+ isNew: true,
+ });
+ } catch (error) {
+ console.error("Device register error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+const ADJECTIVES = [
+ "Swift",
+ "Brave",
+ "Cool",
+ "Dark",
+ "Fire",
+ "Ice",
+ "Storm",
+ "Shadow",
+ "Cyber",
+ "Neon",
+ "Pixel",
+ "Turbo",
+ "Mega",
+ "Ultra",
+ "Nova",
+ "Star",
+ "Flash",
+ "Thunder",
+ "Iron",
+ "Cosmic",
+];
+
+function generateClawName(): string {
+ const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
+ const num = Math.floor(Math.random() * 100);
+ return `${adj}Claw-${num}`;
+}
diff --git a/components/dashboard/claw-feed.tsx b/components/dashboard/claw-feed.tsx
index b232f33..7b75e4e 100644
--- a/components/dashboard/claw-feed.tsx
+++ b/components/dashboard/claw-feed.tsx
@@ -181,6 +181,7 @@ export function ClawFeed() {
{formatDuration(item.durationMs)}
)}
+ {new Date(item.timestamp).toLocaleDateString(locale, { month: "2-digit", day: "2-digit" })}{" "}
{new Date(item.timestamp).toLocaleTimeString(locale)}
diff --git a/components/layout/hero.tsx b/components/layout/hero.tsx
index 6438e91..e82c076 100644
--- a/components/layout/hero.tsx
+++ b/components/layout/hero.tsx
@@ -8,36 +8,36 @@ export function Hero() {
const t = useTranslations("hero");
return (
-