feat: refactor auth into singleton and improve UI with custom fonts/colors/animations

This commit is contained in:
mARTin 2026-02-15 16:10:39 +01:00
parent 345ee6a5e6
commit cb10fd657f
16 changed files with 145 additions and 172 deletions

View File

@ -1,94 +1,5 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
import CredentialsProvider from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
const prisma = new PrismaClient();
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma) as any,
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
authorization: {
params: {
prompt: "consent",
access_type: "offline",
response_type: "code"
}
}
}),
// Apple Sign In can be added when credentials are available
// AppleProvider({
// clientId: process.env.APPLE_ID || "",
// clientSecret: process.env.APPLE_SECRET || "",
// }),
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("Invalid credentials");
}
const user = await prisma.user.findUnique({
where: {
email: credentials.email
}
});
if (!user || !user.passwordHash) {
throw new Error("Invalid credentials");
}
const isPasswordValid = await compare(
credentials.password,
user.passwordHash
);
if (!isPasswordValid) {
throw new Error("Invalid credentials");
}
return {
id: user.id,
email: user.email,
name: user.name || null,
image: user.image || null,
};
}
})
],
session: {
strategy: "jwt"
},
pages: {
signIn: "/auth/login",
signOut: "/auth/login",
error: "/auth/login",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
(session.user as any).id = token.id;
}
return session;
}
},
secret: process.env.NEXTAUTH_SECRET
};
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent, CalendarConnection } from '@/lib/calendar-events';

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { authOptions } from "@/lib/auth";
import { google } from 'googleapis';
import { PrismaClient } from '@prisma/client';

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { authOptions } from "@/lib/auth";
import { google } from 'googleapis';
// Initiate Google Calendar OAuth flow

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { getTokens, getUserCalendars } from '@/lib/outlook-calendar';

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { authOptions } from "@/lib/auth";
import { getAuthUrl } from '@/lib/outlook-calendar';
export async function GET(request: NextRequest) {

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '../../auth/[...nextauth]/route';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { getCalendarEvents, CalendarConnection } from '@/lib/calendar-events';

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '../auth/[...nextauth]/route';
import { authOptions } from "@/lib/auth";
import { PrismaClient, Task } from '@prisma/client';
const prisma = new PrismaClient();

View File

@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
export async function GET(request: Request) {

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '../../auth/[...nextauth]/route';
import { authOptions } from '@/lib/auth';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';

View File

@ -654,7 +654,7 @@ h3 {
.weekly-day-date {
font-size: var(--weekly-date-size, 0.65rem);
font-weight: var(--weekly-date-weight, 400);
color: var(--weekly-text-light);
color: var(--weekly-date-color, var(--weekly-text-light));
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.25rem;
@ -664,9 +664,8 @@ h3 {
.weekly-day-name {
font-size: var(--weekly-headline-size, 1.25rem);
font-weight: var(--weekly-headline-weight, 900);
text-transform: uppercase;
letter-spacing: -0.02em;
color: var(--weekly-text);
color: var(--weekly-weekday-color, var(--weekly-text));
font-family: var(--weekly-font-headline) !important;
}
@ -717,7 +716,7 @@ h3 {
font-size: var(--weekly-task-size, 0.9rem);
font-weight: var(--weekly-task-weight, 400);
line-height: var(--weekly-task-line-height, 1.5);
color: var(--weekly-text);
color: var(--weekly-task-color, var(--weekly-text));
border: none;
outline: none;
background: transparent;
@ -1612,9 +1611,9 @@ h3 {
}
.event-title {
font-weight: 600;
font-size: 0.85rem;
font-family: var(--weekly-font);
font-weight: var(--weekly-event-weight, 600);
font-size: var(--weekly-event-size, 0.85rem);
font-family: var(--weekly-event-font, var(--weekly-font));
}
.time-slot-event .event-title {
@ -1622,14 +1621,15 @@ h3 {
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
font-weight: 500;
font-family: var(--weekly-font);
font-weight: var(--weekly-event-weight, 500);
font-family: var(--weekly-event-font, var(--weekly-font));
font-size: var(--weekly-event-size, 0.85rem);
}
.time-slot-event .event-time-row {
font-size: 0.75rem;
opacity: 0.9;
font-family: var(--weekly-font);
font-family: var(--weekly-event-font, var(--weekly-font));
}
.time-slot-event:hover {
@ -2274,28 +2274,42 @@ h3 {
/* --- VIEW TRANSITION ANIMATION (Weekly-like) --- */
/* Only apply Container transitions (Full Slide) when navType is 'week' */
[data-nav-type="week"] .weekly-days-grid {
view-transition-name: week-grid;
}
/* When navType="day", NO name on container, so columns animate individually via inline view-transition-name */
/* Base Slide Animations */
@keyframes slideOutToLeft {
to {
transform: translateX(-100%);
opacity: 0.8;
to { transform: translateX(-20%); opacity: 0; }
}
}
@keyframes slideInFromRight {
from {
transform: translateX(100%);
from { transform: translateX(20%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOutToRight {
to { transform: translateX(20%); opacity: 0; }
}
@keyframes slideInFromLeft {
from { transform: translateX(-20%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
to {
transform: translateX(0);
/* Week Grid Transition Group */
::view-transition-group(week-grid) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.25, 1, 0.5, 1);
}
/* Slide Next (Left) */
[data-slide-direction="next"]::view-transition-old(week-grid) {
animation-name: slideOutToLeft;
}
[data-slide-direction="next"]::view-transition-new(week-grid) {
animation-name: slideInFromRight;
}
/* Slide Prev (Right) */
[data-slide-direction="prev"]::view-transition-old(week-grid) {
animation-name: slideOutToRight;
}
[data-slide-direction="prev"]::view-transition-new(week-grid) {
animation-name: slideInFromLeft;
}
@keyframes slideOutToRight {

View File

@ -450,7 +450,7 @@ export default function WeeklyView() {
const [cellDuration, setCellDuration] = useState<CellDuration>(60);
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
const [showTimeGrid, setShowTimeGrid] = useState(true);
const [slideDirection, setSlideDirection] = useState<'left' | 'right' | 'out-left' | 'out-right' | 'in-left' | 'in-right' | null>(null);
const [slideDirection, setSlideDirection] = useState<'next' | 'prev' | null>(null);
const [activeSlot, setActiveSlot] = useState<{ day: number; slot: string } | null>(null);
const [newSlotTask, setNewSlotTask] = useState('');
const [selectedTaskForNotes, setSelectedTaskForNotes] = useState<Task | null>(null);
@ -1149,12 +1149,13 @@ export default function WeeklyView() {
const navigate = (newDate: Date, direction: 'left' | 'right', type: 'day' | 'week') => {
if (typeof document !== 'undefined' && 'startViewTransition' in document) {
const doc = document as any;
doc.documentElement.dataset.transitionDirection = direction === 'left' ? 'next' : 'prev';
doc.documentElement.dataset.slideDirection = direction === 'left' ? 'next' : 'prev';
doc.documentElement.dataset.navType = 'week'; // Always use full-grid slide for both day and week
doc.startViewTransition(() => {
setCurrentWeekStart(newDate);
setSlideDirection(null);
// setSlideDirection state is not strictly needed for global transition but keeping it clean
setSlideDirection(direction === 'left' ? 'next' : 'prev');
});
} else {
setCurrentWeekStart(newDate);
@ -1987,8 +1988,10 @@ export default function WeeklyView() {
{/* Day Columns */}
<main
className={`weekly-days-grid cols-${viewDays} ${slideDirection ? `slide-${slideDirection}` : ''}`}
style={{ viewTransitionName: 'week-grid' } as React.CSSProperties}
className={`weekly-days-grid cols-${viewDays}`}
data-slide-direction={slideDirection}
data-nav-type={viewDays > 1 ? 'week' : 'day'}
style={{ viewTransitionName: viewDays > 1 ? 'week-grid' : 'none' } as React.CSSProperties}
>
{getVisibleDays().map((date, colIndex) => (
<div

View File

@ -1,39 +1,84 @@
// Mock authentication utilities
import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@auth/prisma-adapter";
import CredentialsProvider from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { prisma } from "@/lib/prisma";
export interface UserSession {
id: string;
email: string;
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma) as any,
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
authorization: {
params: {
prompt: "consent",
access_type: "offline",
response_type: "code"
}
}
}),
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("Invalid credentials");
}
/**
* Verify authentication token and return user session or null
*/
export async function verifyAuth(): Promise<UserSession | null> {
// In a real implementation, this would decode and validate the JWT token
// This is a simplified mock implementation
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
const user = await prisma.user.findUnique({
where: {
email: credentials.email
}
});
if (!token) {
return null;
if (!user || !user.passwordHash) {
throw new Error("Invalid credentials");
}
const isPasswordValid = await compare(
credentials.password,
user.passwordHash
);
if (!isPasswordValid) {
throw new Error("Invalid credentials");
}
// Mock verification - in reality this would validate the JWT signature
// and check expiration etc.
return {
id: 'mock-user-id',
email: 'user@example.com'
id: user.id,
email: user.email,
name: user.name || null,
image: user.image || null,
};
}
/**
* Require authentication - throw error if no valid session, return session if valid
*/
export async function requireAuth(): Promise<UserSession> {
const session = await verifyAuth();
if (!session) {
throw new Error('Authentication required');
})
],
session: {
strategy: "jwt"
},
pages: {
signIn: "/auth/login",
signOut: "/auth/login",
error: "/auth/login",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
(session.user as any).id = token.id;
}
return session;
}
},
secret: process.env.NEXTAUTH_SECRET
};

File diff suppressed because one or more lines are too long