Add Google Calendar integration and improve station selection
Implement full OAuth 2.0 flow for Google Calendar, including token exchange, refresh, and status checks. Add UI for connecting, syncing, and disconnecting Google accounts in the Calendar panel. Additionally: - Make mobile station selection async with error handling and alerts - Add HAFAS LocMatch method to API client for finding nearest station - Expose Google OAuth env vars in Next.js config
This commit is contained in:
@@ -92,12 +92,17 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
|||||||
searchTimerRef.current = setTimeout(() => searchStation(text), 400);
|
searchTimerRef.current = setTimeout(() => searchStation(text), 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectStation = (station: Station) => {
|
const selectStation = async (station: Station) => {
|
||||||
setOrigin(station);
|
setOrigin(station);
|
||||||
setQuery(station.name);
|
setQuery(station.name);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
saveOriginStation(station);
|
try {
|
||||||
rescheduleAllNotifications(); // Recalculate when origin changes
|
await saveOriginStation(station);
|
||||||
|
await rescheduleAllNotifications();
|
||||||
|
Alert.alert('Station gespeichert', station.name);
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Fehler', 'Station konnte nicht gespeichert werden.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const useCurrentLocation = async () => {
|
const useCurrentLocation = async () => {
|
||||||
@@ -114,29 +119,40 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
|||||||
const userLat = loc.coords.latitude;
|
const userLat = loc.coords.latitude;
|
||||||
const userLng = loc.coords.longitude;
|
const userLng = loc.coords.longitude;
|
||||||
|
|
||||||
// Find real public-transport stops near the user's GPS coordinates
|
// Try the WienerLinien nearby-stops proxy first
|
||||||
// via the WienerLinien nearby-stops proxy.
|
let stops: Awaited<ReturnType<typeof api.findNearbyStops>> | null = null;
|
||||||
const stops = await api.findNearbyStops(userLat, userLng, 2000);
|
try {
|
||||||
if (stops.length === 0) {
|
stops = await api.findNearbyStops(userLat, userLng, 2000);
|
||||||
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
|
} catch {
|
||||||
return;
|
// API unavailable, will fall back to HAFAS LocMatch below
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick the closest stop to the user's actual position
|
// If nearby-stops returned results, pick the closest one
|
||||||
|
if (stops && stops.length > 0) {
|
||||||
const closest = stops.reduce((best, candidate) => {
|
const closest = stops.reduce((best, candidate) => {
|
||||||
const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng);
|
const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng);
|
||||||
const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng);
|
const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng);
|
||||||
return candDist < bestDist ? candidate : best;
|
return candDist < bestDist ? candidate : best;
|
||||||
}, stops[0]);
|
}, stops[0]);
|
||||||
|
|
||||||
// Build a Station with the real stop id as extId — HAFAS can look this up.
|
|
||||||
const station: Station = {
|
const station: Station = {
|
||||||
name: closest.name,
|
name: closest.name,
|
||||||
extId: closest.id,
|
extId: closest.id,
|
||||||
lat: closest.lat,
|
lat: closest.lat,
|
||||||
lng: closest.lng,
|
lng: closest.lng,
|
||||||
};
|
};
|
||||||
selectStation(station);
|
await selectStation(station);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use HAFAS LocMatch directly (same pattern as the web app)
|
||||||
|
const nearestStation = await api.findNearestStationByCoords(userLat, userLng);
|
||||||
|
if (!nearestStation) {
|
||||||
|
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await selectStation(nearestStation);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.');
|
Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const nextConfig: NextConfig = {
|
|||||||
env: {
|
env: {
|
||||||
CORS_ALLOWED_ORIGINS: process.env.CORS_ALLOWED_ORIGINS,
|
CORS_ALLOWED_ORIGINS: process.env.CORS_ALLOWED_ORIGINS,
|
||||||
DEPLOYMENT_URL: process.env.DEPLOYMENT_URL,
|
DEPLOYMENT_URL: process.env.DEPLOYMENT_URL,
|
||||||
|
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
GOOGLE_REDIRECT_URI: process.env.GOOGLE_REDIRECT_URI,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return process.env.DEPLOYMENT_URL || "http://localhost:3000";
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarRedirect(params: Record<string, string>) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return NextResponse.redirect(`${getBaseUrl()}/calendar?${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const state = searchParams.get("state");
|
||||||
|
const oauthError = searchParams.get("error");
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
if (oauthError) {
|
||||||
|
cookieStore.delete("google_oauth_state");
|
||||||
|
return calendarRedirect({ google_error: oauthError });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedState = cookieStore.get("google_oauth_state")?.value;
|
||||||
|
cookieStore.delete("google_oauth_state");
|
||||||
|
|
||||||
|
if (!state || !storedState || state !== storedState) {
|
||||||
|
return calendarRedirect({ google_error: "invalid_state" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return calendarRedirect({ google_error: "missing_code" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
return calendarRedirect({ google_error: "not_configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri =
|
||||||
|
process.env.GOOGLE_REDIRECT_URI || `${getBaseUrl()}/api/auth/google/callback`;
|
||||||
|
|
||||||
|
let tokenData: {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
return calendarRedirect({ google_error: "token_exchange_failed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenData = await tokenResponse.json();
|
||||||
|
} catch {
|
||||||
|
return calendarRedirect({ google_error: "token_exchange_failed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieStore.set(
|
||||||
|
"google_tokens",
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: tokenData.access_token,
|
||||||
|
refresh_token: tokenData.refresh_token ?? null,
|
||||||
|
expires_at: Date.now() + tokenData.expires_in * 1000,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
path: "/",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return calendarRedirect({ google_connected: "1" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete("google_tokens");
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return process.env.DEPLOYMENT_URL || "http://localhost:3000";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||||
|
if (!clientId) {
|
||||||
|
return NextResponse.json({ error: "Google Calendar is not configured" }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri =
|
||||||
|
process.env.GOOGLE_REDIRECT_URI || `${getBaseUrl()}/api/auth/google/callback`;
|
||||||
|
|
||||||
|
const state = randomBytes(16).toString("hex");
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set("google_oauth_state", state, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 300,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "https://www.googleapis.com/auth/calendar.readonly",
|
||||||
|
access_type: "offline",
|
||||||
|
prompt: "consent",
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`https://accounts.google.com/o/oauth2/v2/auth?${params}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const configured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||||
|
if (!configured) {
|
||||||
|
return NextResponse.json({ configured: false, connected: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const connected = cookieStore.has("google_tokens");
|
||||||
|
return NextResponse.json({ configured: true, connected });
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import type { CalendarEvent } from "@timetoleave/core";
|
||||||
|
import { cleanLocation } from "@/lib/calendar-utils";
|
||||||
|
import { DEFAULT_DAYS } from "@/lib/constants";
|
||||||
|
|
||||||
|
interface GoogleTokens {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string | null;
|
||||||
|
expires_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoogleEventItem {
|
||||||
|
id: string;
|
||||||
|
summary?: string;
|
||||||
|
location?: string;
|
||||||
|
start?: { dateTime?: string; date?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(tokens: GoogleTokens): Promise<GoogleTokens | null> {
|
||||||
|
if (!tokens.refresh_token) return null;
|
||||||
|
|
||||||
|
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) return null;
|
||||||
|
|
||||||
|
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const data: { access_token: string; expires_in: number } = await response.json();
|
||||||
|
return {
|
||||||
|
access_token: data.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
expires_at: Date.now() + data.expires_in * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const tokensRaw = cookieStore.get("google_tokens")?.value;
|
||||||
|
|
||||||
|
if (!tokensRaw) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated with Google" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens: GoogleTokens;
|
||||||
|
try {
|
||||||
|
tokens = JSON.parse(tokensRaw) as GoogleTokens;
|
||||||
|
} catch {
|
||||||
|
cookieStore.delete("google_tokens");
|
||||||
|
return NextResponse.json({ error: "Invalid token data" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh if expired or within 5 minutes of expiry
|
||||||
|
if (Date.now() > tokens.expires_at - 5 * 60 * 1000) {
|
||||||
|
const refreshed = await refreshAccessToken(tokens);
|
||||||
|
if (!refreshed) {
|
||||||
|
cookieStore.delete("google_tokens");
|
||||||
|
return NextResponse.json({ error: "Token expired — please reconnect Google Calendar" }, { status: 401 });
|
||||||
|
}
|
||||||
|
tokens = refreshed;
|
||||||
|
cookieStore.set("google_tokens", JSON.stringify(tokens), {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const days = parseInt(searchParams.get("days") ?? String(DEFAULT_DAYS), 10);
|
||||||
|
const now = new Date();
|
||||||
|
const timeMax = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
timeMin: now.toISOString(),
|
||||||
|
timeMax: timeMax.toISOString(),
|
||||||
|
singleEvents: "true",
|
||||||
|
orderBy: "startTime",
|
||||||
|
maxResults: "100",
|
||||||
|
});
|
||||||
|
|
||||||
|
let gcalData: { items?: GoogleEventItem[] };
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://www.googleapis.com/calendar/v3/calendars/primary/events?${params}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
cookieStore.delete("google_tokens");
|
||||||
|
return NextResponse.json({ error: "Google token rejected — please reconnect" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: "Failed to fetch Google Calendar events" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
gcalData = await response.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Failed to reach Google Calendar API" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const events: CalendarEvent[] = (gcalData.items ?? [])
|
||||||
|
.filter((item) => item.location && item.start?.dateTime)
|
||||||
|
.map((item) => ({
|
||||||
|
id: `gcal_${item.id}`,
|
||||||
|
title: item.summary ?? "Untitled",
|
||||||
|
destination: cleanLocation(item.location!),
|
||||||
|
eventTime: item.start!.dateTime!,
|
||||||
|
source: "google_calendar",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(events);
|
||||||
|
}
|
||||||
@@ -3,15 +3,17 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useCalendar } from "@/hooks/useCalendar";
|
import { useCalendar } from "@/hooks/useCalendar";
|
||||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||||
|
import type { CalendarEvent } from "@timetoleave/core";
|
||||||
import UrlTab from "./UrlTab";
|
import UrlTab from "./UrlTab";
|
||||||
import FileTab from "./FileTab";
|
import FileTab from "./FileTab";
|
||||||
|
import GoogleTab from "./GoogleTab";
|
||||||
|
|
||||||
type CalendarPanelProps = {
|
type CalendarPanelProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
|
const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
|
||||||
const [activeTab, setActiveTab] = useState<"url" | "file">("url");
|
const [activeTab, setActiveTab] = useState<"url" | "file" | "google">("url");
|
||||||
const { events: calendarEvents, loading, error, fetchCalendarFromUrl, parseCalendarFromFile } = useCalendar();
|
const { events: calendarEvents, loading, error, fetchCalendarFromUrl, parseCalendarFromFile } = useCalendar();
|
||||||
const { mergeEvents } = useEventsStore();
|
const { mergeEvents } = useEventsStore();
|
||||||
|
|
||||||
@@ -30,6 +32,17 @@ const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGoogleEventsLoaded = (events: CalendarEvent[]) => {
|
||||||
|
mergeEvents(events);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabClass = (tab: "url" | "file" | "google") =>
|
||||||
|
`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
activeTab === tab
|
||||||
|
? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]"
|
||||||
|
: "text-[#F4F1EA]/58 hover:text-white"
|
||||||
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`brand-panel overflow-hidden rounded-2xl ${className}`}>
|
<div className={`brand-panel overflow-hidden rounded-2xl ${className}`}>
|
||||||
<div className="border-b border-white/10 p-4">
|
<div className="border-b border-white/10 p-4">
|
||||||
@@ -40,27 +53,26 @@ const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
|
|||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<nav className="inline-flex rounded-full border border-white/10 bg-black/20 p-1" aria-label="Tabs">
|
<nav className="inline-flex rounded-full border border-white/10 bg-black/20 p-1" aria-label="Tabs">
|
||||||
<button
|
<button className={tabClass("url")} onClick={() => setActiveTab("url")} disabled={loading}>
|
||||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${activeTab === "url" ? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]" : "text-[#F4F1EA]/58 hover:text-white"}`}
|
|
||||||
onClick={() => setActiveTab("url")}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
URL
|
URL
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className={tabClass("file")} onClick={() => setActiveTab("file")} disabled={loading}>
|
||||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${activeTab === "file" ? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]" : "text-[#F4F1EA]/58 hover:text-white"}`}
|
|
||||||
onClick={() => setActiveTab("file")}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
File
|
File
|
||||||
</button>
|
</button>
|
||||||
|
<button className={tabClass("google")} onClick={() => setActiveTab("google")} disabled={loading}>
|
||||||
|
Google
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
{activeTab === "url" ? (
|
{activeTab === "url" && (
|
||||||
<UrlTab onLoadCalendar={(url) => handleLoadCalendar(url)} loading={loading} error={error} />
|
<UrlTab onLoadCalendar={(url) => handleLoadCalendar(url)} loading={loading} error={error} />
|
||||||
) : (
|
)}
|
||||||
|
{activeTab === "file" && (
|
||||||
<FileTab onLoadCalendar={(file) => handleLoadCalendar(file)} loading={loading} error={error} />
|
<FileTab onLoadCalendar={(file) => handleLoadCalendar(file)} loading={loading} error={error} />
|
||||||
)}
|
)}
|
||||||
|
{activeTab === "google" && (
|
||||||
|
<GoogleTab onEventsLoaded={handleGoogleEventsLoaded} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import Button from "@/app/ui/Button";
|
||||||
|
import type { CalendarEvent } from "@timetoleave/core";
|
||||||
|
|
||||||
|
type GoogleStatus = "loading" | "not-configured" | "not-connected" | "connected";
|
||||||
|
|
||||||
|
type GoogleTabProps = {
|
||||||
|
onEventsLoaded: (events: CalendarEvent[]) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GoogleTab: React.FC<GoogleTabProps> = ({ onEventsLoaded, className = "" }) => {
|
||||||
|
const [status, setStatus] = useState<GoogleStatus>("loading");
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [syncedCount, setSyncedCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const checkStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/google/status");
|
||||||
|
const data: { configured: boolean; connected: boolean } = await res.json();
|
||||||
|
if (!data.configured) {
|
||||||
|
setStatus("not-configured");
|
||||||
|
} else if (data.connected) {
|
||||||
|
setStatus("connected");
|
||||||
|
} else {
|
||||||
|
setStatus("not-connected");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus("not-connected");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Handle OAuth return params
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const connected = params.get("google_connected");
|
||||||
|
const oauthError = params.get("google_error");
|
||||||
|
|
||||||
|
if (connected || oauthError) {
|
||||||
|
const clean = new URL(window.location.href);
|
||||||
|
clean.searchParams.delete("google_connected");
|
||||||
|
clean.searchParams.delete("google_error");
|
||||||
|
window.history.replaceState(null, "", clean.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oauthError) {
|
||||||
|
setError(friendlyOAuthError(oauthError));
|
||||||
|
setStatus("not-connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkStatus().then(() => {
|
||||||
|
if (connected) {
|
||||||
|
syncEvents();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncEvents = useCallback(async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/calendar/google");
|
||||||
|
if (res.status === 401) {
|
||||||
|
setStatus("not-connected");
|
||||||
|
setSyncing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const body: { error?: string } = await res.json().catch(() => ({}));
|
||||||
|
setError(body.error ?? "Failed to fetch Google Calendar events");
|
||||||
|
setSyncing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const events: CalendarEvent[] = await res.json();
|
||||||
|
onEventsLoaded(events);
|
||||||
|
setSyncedCount(events.length);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to reach the server");
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}, [onEventsLoaded]);
|
||||||
|
|
||||||
|
const disconnect = async () => {
|
||||||
|
await fetch("/api/auth/google/disconnect", { method: "POST" });
|
||||||
|
setStatus("not-connected");
|
||||||
|
setSyncedCount(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className={`p-1 ${className}`}>
|
||||||
|
<p className="text-sm text-[#F4F1EA]/50">Checking connection…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "not-configured") {
|
||||||
|
return (
|
||||||
|
<div className={`p-1 ${className}`}>
|
||||||
|
<p className="text-sm text-[#F4F1EA]/66">
|
||||||
|
Google Calendar is not configured. Add{" "}
|
||||||
|
<code className="rounded bg-white/10 px-1 py-0.5 text-xs">GOOGLE_CLIENT_ID</code> and{" "}
|
||||||
|
<code className="rounded bg-white/10 px-1 py-0.5 text-xs">GOOGLE_CLIENT_SECRET</code> to
|
||||||
|
your environment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`p-1 space-y-4 ${className}`}>
|
||||||
|
{status === "not-connected" ? (
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-sm text-[#F4F1EA]/66">
|
||||||
|
Connect your Google account to import events from Google Calendar.
|
||||||
|
</p>
|
||||||
|
<a href="/api/auth/google">
|
||||||
|
<Button type="button">
|
||||||
|
<GoogleIcon />
|
||||||
|
Connect Google Calendar
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.7)]" />
|
||||||
|
<p className="text-sm font-medium text-white">Google Calendar connected</p>
|
||||||
|
</div>
|
||||||
|
{syncedCount !== null && (
|
||||||
|
<p className="mb-3 text-sm text-[#F4F1EA]/66">
|
||||||
|
{syncedCount === 0
|
||||||
|
? "No upcoming events with a location found."
|
||||||
|
: `${syncedCount} event${syncedCount === 1 ? "" : "s"} imported.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button onClick={syncEvents} disabled={syncing}>
|
||||||
|
{syncing ? "Syncing…" : "Sync Now"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={disconnect} disabled={syncing}>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="text-[#FF2D8D] text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function GoogleIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyOAuthError(error: string): string {
|
||||||
|
switch (error) {
|
||||||
|
case "access_denied":
|
||||||
|
return "Google Calendar access was denied.";
|
||||||
|
case "invalid_state":
|
||||||
|
return "Security check failed. Please try connecting again.";
|
||||||
|
case "token_exchange_failed":
|
||||||
|
return "Could not complete Google sign-in. Please try again.";
|
||||||
|
case "not_configured":
|
||||||
|
return "Google Calendar is not configured on this server.";
|
||||||
|
default:
|
||||||
|
return `Google sign-in failed: ${error}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GoogleTab;
|
||||||
@@ -178,4 +178,55 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
return result?.svcReqL?.[0]?.res?.locL ?? [];
|
return result?.svcReqL?.[0]?.res?.locL ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the nearest station to given GPS coordinates using HAFAS LocMatch.
|
||||||
|
* This is a reliable fallback that works even when the nearby-stops proxy
|
||||||
|
* is unavailable or the base URL is empty.
|
||||||
|
*/
|
||||||
|
async findNearestStationByCoords(lat: number, lng: number): Promise<Station | null> {
|
||||||
|
interface HafasLocation extends Station {
|
||||||
|
type: string;
|
||||||
|
lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.hafasRequest<{
|
||||||
|
svcReqL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }>;
|
||||||
|
}>({
|
||||||
|
svcReqL: [
|
||||||
|
{
|
||||||
|
meth: "LocMatch",
|
||||||
|
req: {
|
||||||
|
input: {
|
||||||
|
loc: {
|
||||||
|
crd: {
|
||||||
|
x: Math.round(lng * 1e6),
|
||||||
|
y: Math.round(lat * 1e6),
|
||||||
|
},
|
||||||
|
type: "S",
|
||||||
|
},
|
||||||
|
maxLoc: 5,
|
||||||
|
field: "S",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stations = result?.svcReqL?.[0]?.res?.match?.locL ?? [];
|
||||||
|
if (stations.length === 0) return null;
|
||||||
|
|
||||||
|
// Filter to only "S" (station) type results, then pick the closest
|
||||||
|
const stationResults = stations.filter((s) => s.type === "S");
|
||||||
|
if (stationResults.length === 0) return null;
|
||||||
|
|
||||||
|
// Find the closest station by Euclidean distance
|
||||||
|
const closest = stationResults.reduce((best, candidate) => {
|
||||||
|
const bestDist = Math.hypot((best.lat ?? lat) - lat, (best.lng ?? lng) - lng);
|
||||||
|
const candDist = Math.hypot((candidate.lat ?? lat) - lat, (candidate.lng ?? lng) - lng);
|
||||||
|
return candDist < bestDist ? candidate : best;
|
||||||
|
}, stationResults[0]);
|
||||||
|
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user