Lanyards is a dedicated profile for researchers, built on the AT Protocol.

Stashing WIP for the weekend...

+1448 -542
+146
.docs/brief.md
···
··· 1 + # Overview 2 + 'Lanyard' is a dedicated profile for researchers, built on the AT profile. 3 + 4 + Researchers will use this as an alternative to the ORCID id. 5 + 6 + # Technology Stack 7 + * eslint 8 + * nextjs (latest version) 9 + * postcss 10 + * prettier 11 + * Relevant @atproto/* npm packages (search https://www.npmjs.com/search?q=%40atproto%2F ) 12 + * tailwind (v4) 13 + * typescript 14 + 15 + ## Potential NPM Packages 16 + 17 + > [!IMPORTANT] 18 + > These packages are listed as optional and should not be considered essential or mandatory. 19 + 20 + * @atproto/api 21 + * @atproto/common 22 + * @atproto/identity 23 + * @atproto/lex-cli 24 + * @atproto/lexicon 25 + * @atproto/oauth-client-node 26 + * @atproto/sync 27 + * @atproto/syntax 28 + * @atproto/xrpc-server 29 + * cors 30 + * dotenv 31 + * types 32 + * uuid 33 + * zod 34 + 35 + > [!IMPORTANT] 36 + > NEVER speculate on package version numbers. Always use 'latest' version in the package.json. 37 + 38 + # Features: 39 + 40 + ## Account Creation and Sign-In 41 + Create accounts using your @bluesky account: 42 + 43 + * Users can create an account with their DID (e.g. a bluesky handle), hosted on *any* PDS, securly using *Oauth* **only** 44 + * No email signup supported 45 + 46 + ## Researcher Profile 47 + 48 + Display your managed data beautifully 49 + 50 + - Mobile-first (for easy realworld networking) 51 + - "Follow on Bluesky" primary action 52 + - View profile link as QR Code (for easy sharing at conferences) 53 + <!-- - Broadcast via Bluetooth (advertist you) --> 54 + 55 + ## Manage Profile 56 + 57 + "Build a rich user profile, designed for **Researchers**" 58 + 59 + ### Basics 60 + Manage your User Profile 61 + 62 + * Avatar Photo (locked, added from authenticated account) 63 + * Description Text (locked, added from authenticated account) 64 + * Honorifics 65 + * Add Doctor 66 + * Add Professor 67 + * Location 68 + * ISO Codes 69 + 70 + ### Affiliations 71 + Manage Professional Affiliations 72 + 73 + Manage here means CRUD (create, read, update, remove). 74 + 75 + * allow multiple 76 + * required start date 77 + * optional end date (marked as `current` if without end date) 78 + * optional mark as `primary` (max 1) 79 + 80 + > [!IMPORTANT] 81 + > Use Ringgold or Grid for Organisation data 82 + 83 + ### Social Network Profiles 84 + Manage Social Network Profile Links 85 + 86 + * Bluesky (Only 1 allowed) 87 + * added from authenticated account 88 + * cannot be edited/hidden/deleted 89 + * Twitter Profile (Only 1 allowed) 90 + * can be created/edited/deleted 91 + * LinkedIn Profile (Only 1 allowed) 92 + * can be created/edited/deleted 93 + * ResearchGate Profile (Only 1 allowed) 94 + * can be created/edited/deleted 95 + * Google Scholar Profile (Only 1 allowed) 96 + * can be created/edited/deleted 97 + * Semble Profile (Only 1 allowed) 98 + * can be created/edited/deleted 99 + * https://semble.so for details 100 + 101 + ### Web Links 102 + 103 + Manage Web Links (up to 3) 104 + * can be created/edited/deleted 105 + 106 + ## Manage Scholarly Contributions 107 + 108 + "Add your research to your profile, using DOIs" 109 + 110 + * Add Research Links 111 + * Type (e.g. Abstract, Poster, Paper, Conference Proceeding) 112 + * No upper limit 113 + * Add DOI only 114 + * Metadata is collected from link destination 115 + 116 + ## Manage Academic Events 117 + Add your conference presentations 118 + 119 + * Type (e.g. Conference, Symposium, etc) 120 + * Date of Event (as a single date, or a range) 121 + * Add related Research (as Scholarly Contribution) 122 + * Organiser (as Organisation) 123 + 124 + # Typed Lexicons 125 + 126 + * User 127 + * Location (for user, organisation) 128 + * Organisation (for Affiliation) 129 + * Social Network Profiles 130 + * Web Links 131 + * Work (for Scholarly Contributions) 132 + * Event 133 + 134 + # Leverage Collections in PDS 135 + 136 + Where possible and relevant, use data from collections in the PDS, such as 137 + 138 + * app.bsky.actor.profile 139 + * app.bsky.graph.block 140 + * app.bsky.graph.follow 141 + * app.bsky.graph.verification 142 + 143 + [Future development!!!] Where the user has a semble.so account 144 + * network.cosmik.card 145 + * network.cosmik.collection 146 + * network.cosmik.collectionLink
+74
.docs/ux.md
···
··· 1 + The experience should be super simple, like creating a Linktree profile. 2 + 3 + # User Journey 4 + 5 + 1. Landing Page (for promotion and prompts Create account / Sign in) 6 + 2. Create account / Sign in 7 + 3. Dashboard with overview of all features 8 + 4. Manage Profile 9 + 1. View Profile as Owner 10 + 2. View Profile as Visitor 11 + 3. Edit Profile Details: Edit basic info about yourself (as researcher) 12 + 4. Customise Profile: Basic styling options (out of scop for MVP!!!) 13 + 5. Share profile 14 + 1. View link as QR Code 15 + 2. Copy link to clipboard 16 + 5. Manage Research Links 17 + 1. View 'All Research' (with Zero Data State) 18 + 2. Add Research: Add a DOI, and system uses CrossRef API to grab title, abstract, authors, publication details etc. 19 + 3. Import from ORCID (out of scop for MVP!!!) 20 + 4. Import from Google Scholar Profile (out of scop for MVP!!!) 21 + 6. Manage Events 22 + 1. View 'All Events' (with Zero Data State) 23 + 2. Add Event: Add upcoming/past conferences 24 + 7. Manage WebLinks 25 + 1. View 'All WebLinks' (with Zero Data State) 26 + 2. Add WebLinks Form: inc social media profiles 27 + 3. 28 + 8. Share profile 29 + 1. accessible from Dashboard, copy to profile 30 + 2. from profile, visible to allV 31 + 1. view as QR code 32 + 33 + # url structure 34 + 35 + > [!IMPORTANT] 36 + > State is always preserved in the URL 37 + 38 + - landing page = 39 + - domain root = https://lanyard.at 40 + - auth 41 + - on a path 42 + - https://lanyard.at/auth 43 + - dashboard = 44 + - on a subdomain = 45 + - https://app.lanyard.at 46 + - view content = 47 + - on a path, in the subdomain 48 + - e.g. https://app.lanyard.at/weblinks 49 + - edit content = 50 + - on a path, in the subdomain 51 + - e.g. https://app.lanyard.at/weblinks/edit?ID=someID 52 + - e.g. https://app.lanyard.at/weblinks/create 53 + - profile = 54 + - path based on [handle] 55 + - https://lanyard.at/[handle] 56 + - https://lanyard.at/@renderg.host 57 + - https://lanyard.at/@alice.bsky.social 58 + - potentially different actions available for authenticated users on their own profile 59 + 60 + # Responsiveness 61 + 62 + The majority of users will use this from their phone, so keep design single-column and user cards, not tables, for lists of objects (e.g. list of research works). 63 + 64 + > [!IMPORTANT] 65 + > Desktop breakpoints are not important in the MVP!!! 66 + > 67 + > [!NOTE] 68 + > Focus on the 'sm': '640px' tailwind breakpoint and below! 69 + 70 + * Landing Page = Mobile First 71 + * Dashboard = Mobile First 72 + * Forms + Flows = Mobile First 73 + * Public Profile = Mobile First 74 +
+1 -1
.gitignore
··· 42 *~ 43 44 # mine 45 - .docs 46 .claude
··· 42 *~ 43 44 # mine 45 + # .docs 46 .claude
+46
lexicons/affiliation/affiliation.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.lanyard.affiliation", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A professional affiliation with an academic or research organization", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["organization", "startDate", "createdAt"], 12 + "properties": { 13 + "organization": { 14 + "type": "ref", 15 + "ref": "at.lanyard.organization#main", 16 + "description": "Reference to the affiliated organization" 17 + }, 18 + "role": { 19 + "type": "string", 20 + "maxLength": 100, 21 + "description": "Role or position at the organization (e.g., 'Research Fellow', 'Professor')" 22 + }, 23 + "startDate": { 24 + "type": "string", 25 + "format": "datetime", 26 + "description": "Start date of affiliation" 27 + }, 28 + "endDate": { 29 + "type": "string", 30 + "format": "datetime", 31 + "description": "End date of affiliation (null if current)" 32 + }, 33 + "isPrimary": { 34 + "type": "boolean", 35 + "description": "Whether this is the primary affiliation (maximum 1 primary per researcher)" 36 + }, 37 + "createdAt": { 38 + "type": "string", 39 + "format": "datetime", 40 + "description": "Timestamp when this affiliation record was created" 41 + } 42 + } 43 + } 44 + } 45 + } 46 + }
+65
lexicons/profile/profile.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.lanyard.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A user's profile record - designed for academics to showcase their identity, affiliations, and professional presence", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["did", "handle", "createdAt"], 12 + "properties": { 13 + "did": { 14 + "type": "string", 15 + "format": "did", 16 + "description": "The user's AT Protocol decentralized identifier (DID)" 17 + }, 18 + "handle": { 19 + "type": "string", 20 + "description": "The user's handle (from Bluesky account)" 21 + }, 22 + "displayName": { 23 + "type": "string", 24 + "maxLength": 64, 25 + "description": "Display name (from Bluesky profile)" 26 + }, 27 + "avatar": { 28 + "type": "string", 29 + "description": "Avatar URL (from Bluesky profile, locked)" 30 + }, 31 + "description": { 32 + "type": "string", 33 + "maxGraphemes": 256, 34 + "maxLength": 2560, 35 + "description": "Profile description (from Bluesky profile, locked)" 36 + }, 37 + "banner": { 38 + "type": "string", 39 + "description": "Banner image URL (from Bluesky profile, locked)" 40 + }, 41 + "honorific": { 42 + "type": "string", 43 + "enum": ["none", "Dr", "Prof"], 44 + "description": "Academic honorific - one of: none, Dr, or Prof (tradition: use one or the other, never both)" 45 + }, 46 + "location": { 47 + "type": "ref", 48 + "ref": "at.lanyard.location#main", 49 + "description": "User's home location using ISO codes" 50 + }, 51 + "createdAt": { 52 + "type": "string", 53 + "format": "datetime", 54 + "description": "Timestamp when the profile was created" 55 + }, 56 + "updatedAt": { 57 + "type": "string", 58 + "format": "datetime", 59 + "description": "Timestamp when the profile was last updated" 60 + } 61 + } 62 + } 63 + } 64 + } 65 + }
-103
lexicons/researcher/researcher.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "at.lanyard.researcher", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "A researcher's profile record - designed for academics to showcase their identity, affiliations, and professional presence", 8 - "key": "literal:self", 9 - "record": { 10 - "type": "object", 11 - "required": ["did", "handle", "createdAt"], 12 - "properties": { 13 - "did": { 14 - "type": "string", 15 - "format": "did", 16 - "description": "The user's AT Protocol decentralized identifier (DID)" 17 - }, 18 - "handle": { 19 - "type": "string", 20 - "description": "The user's handle (from Bluesky account)" 21 - }, 22 - "displayName": { 23 - "type": "string", 24 - "maxLength": 64, 25 - "description": "Display name (from Bluesky profile)" 26 - }, 27 - "avatar": { 28 - "type": "string", 29 - "description": "Avatar URL (from Bluesky profile, locked)" 30 - }, 31 - "description": { 32 - "type": "string", 33 - "maxGraphemes": 256, 34 - "maxLength": 2560, 35 - "description": "Profile description (from Bluesky profile, locked)" 36 - }, 37 - "honorifics": { 38 - "type": "array", 39 - "items": { 40 - "type": "string", 41 - "enum": ["Dr", "Prof"] 42 - }, 43 - "description": "Academic honorifics (Doctor, Professor)" 44 - }, 45 - "location": { 46 - "type": "ref", 47 - "ref": "at.lanyard.location#main", 48 - "description": "Researcher's home location using ISO codes" 49 - }, 50 - "affiliations": { 51 - "type": "array", 52 - "items": { 53 - "type": "ref", 54 - "ref": "#affiliation" 55 - }, 56 - "description": "Professional affiliations with institutions" 57 - }, 58 - "createdAt": { 59 - "type": "string", 60 - "format": "datetime", 61 - "description": "Timestamp when the profile was created" 62 - }, 63 - "updatedAt": { 64 - "type": "string", 65 - "format": "datetime", 66 - "description": "Timestamp when the profile was last updated" 67 - } 68 - } 69 - } 70 - }, 71 - "affiliation": { 72 - "type": "object", 73 - "description": "A professional affiliation with an organization", 74 - "required": ["organization", "startDate"], 75 - "properties": { 76 - "organization": { 77 - "type": "ref", 78 - "ref": "at.lanyard.organization#main", 79 - "description": "Reference to the affiliated organization" 80 - }, 81 - "role": { 82 - "type": "string", 83 - "maxLength": 100, 84 - "description": "Role or position at the organization (e.g., 'Research Fellow', 'Professor')" 85 - }, 86 - "startDate": { 87 - "type": "string", 88 - "format": "datetime", 89 - "description": "Start date of affiliation" 90 - }, 91 - "endDate": { 92 - "type": "string", 93 - "format": "datetime", 94 - "description": "End date of affiliation (null if current)" 95 - }, 96 - "isPrimary": { 97 - "type": "boolean", 98 - "description": "Whether this is the primary affiliation (maximum 1 primary)" 99 - } 100 - } 101 - } 102 - } 103 - }
···
+10
lexicons/work/work.json
··· 51 "maxLength": 200, 52 "description": "Journal or publication venue (fetched from DOI metadata)" 53 }, 54 "publication": { 55 "type": "ref", 56 "ref": "at.lanyard.publication#main",
··· 51 "maxLength": 200, 52 "description": "Journal or publication venue (fetched from DOI metadata)" 53 }, 54 + "abstract": { 55 + "type": "string", 56 + "maxLength": 5000, 57 + "description": "Work abstract (fetched from DOI metadata)" 58 + }, 59 + "url": { 60 + "type": "string", 61 + "maxLength": 500, 62 + "description": "URL to the work (fetched from DOI metadata)" 63 + }, 64 "publication": { 65 "type": "ref", 66 "ref": "at.lanyard.publication#main",
+2
next.config.ts
··· 16 'thread-stream', 17 'sonic-boom', 18 '@atproto/common', 19 'multiformats', 20 ], 21 typescript: {
··· 16 'thread-stream', 17 'sonic-boom', 18 '@atproto/common', 19 + '@atproto/xrpc', 20 + '@atproto/lexicon', 21 'multiformats', 22 ], 23 typescript: {
+2 -2
package.json
··· 3 "version": "0.1.0", 4 "private": true, 5 "scripts": { 6 - "dev": "next dev", 7 "build": "npm run lex:gen && next build", 8 "start": "next start", 9 "lint": "next lint", 10 "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"", 11 - "lex:gen": "lex gen-api ./src/types/generated ./lexicons/**/*.json", 12 "lex:watch": "lex gen-api --watch ./src/types/generated ./lexicons/**/*.json" 13 }, 14 "dependencies": {
··· 3 "version": "0.1.0", 4 "private": true, 5 "scripts": { 6 + "dev": "npm run lex:gen && next dev", 7 "build": "npm run lex:gen && next build", 8 "start": "next start", 9 "lint": "next lint", 10 "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"", 11 + "lex:gen": "lex gen-api ./src/types/generated ./lexicons/**/*.json && node scripts/fix-generated-imports.js", 12 "lex:watch": "lex gen-api --watch ./src/types/generated ./lexicons/**/*.json" 13 }, 14 "dependencies": {
+46
scripts/fix-generated-imports.js
···
··· 1 + /** 2 + * Post-process generated AT Protocol files to fix import paths 3 + * Removes .js extensions from imports since we're in a TypeScript environment 4 + */ 5 + 6 + const fs = require('fs'); 7 + const path = require('path'); 8 + 9 + const generatedDir = path.join(__dirname, '../src/types/generated'); 10 + 11 + function fixImportsInFile(filePath) { 12 + let content = fs.readFileSync(filePath, 'utf8'); 13 + let modified = false; 14 + 15 + // Replace .js extensions in import/export statements 16 + const newContent = content.replace( 17 + /(from\s+['"])(.+?)\.js(['"])/g, 18 + (match, p1, p2, p3) => { 19 + modified = true; 20 + return `${p1}${p2}${p3}`; 21 + } 22 + ); 23 + 24 + if (modified) { 25 + fs.writeFileSync(filePath, newContent, 'utf8'); 26 + console.log(`Fixed imports in: ${path.relative(process.cwd(), filePath)}`); 27 + } 28 + } 29 + 30 + function processDirectory(dir) { 31 + const entries = fs.readdirSync(dir, { withFileTypes: true }); 32 + 33 + for (const entry of entries) { 34 + const fullPath = path.join(dir, entry.name); 35 + 36 + if (entry.isDirectory()) { 37 + processDirectory(fullPath); 38 + } else if (entry.isFile() && entry.name.endsWith('.ts')) { 39 + fixImportsInFile(fullPath); 40 + } 41 + } 42 + } 43 + 44 + console.log('Fixing generated TypeScript imports...'); 45 + processDirectory(generatedDir); 46 + console.log('Done!');
+39 -6
src/app/[handle]/page.tsx
··· 1 - import { AtpAgent } from '@atproto/api'; 2 - import { ResearcherRepository } from '@/lib/data/repository'; 3 import ProfileView from '@/components/profile/ProfileView'; 4 5 interface PageProps { 6 params: Promise<{ ··· 11 export default async function ProfilePage({ params }: PageProps) { 12 const { handle } = await params; 13 14 - // Resolve handle to DID 15 - const agent = new AtpAgent({ service: 'https://bsky.social' }); 16 17 try { 18 - const resolved = await agent.resolveHandle({ handle }); 19 const did = resolved.data.did; 20 21 // Get Bluesky profile 22 const bskyProfile = await agent.getProfile({ actor: did }); 23 24 // Get Lanyards profile 25 - const repo = new ResearcherRepository(agent); 26 const lanyardProfile = await repo.getProfile(did); 27 28 if (!lanyardProfile) { ··· 55 ...lanyardProfile, 56 displayName: bskyProfile.data.displayName, 57 avatar: bskyProfile.data.avatar, 58 description: bskyProfile.data.description, 59 }} 60 affiliations={affiliations} 61 webLinks={webLinks} 62 works={works} 63 events={events} 64 /> 65 ); 66 } catch (error) {
··· 1 + import { ProfileRepository } from '@/lib/data/repository'; 2 import ProfileView from '@/components/profile/ProfileView'; 3 + import { getServerAgent, getPublicAgent } from '@/lib/auth/server-agent'; 4 + import { getSession } from '@/lib/auth/session'; 5 6 interface PageProps { 7 params: Promise<{ ··· 12 export default async function ProfilePage({ params }: PageProps) { 13 const { handle } = await params; 14 15 + // Validate handle format - reject obvious non-handles 16 + if ( 17 + !handle || 18 + handle.includes('.ico') || 19 + handle.includes('.png') || 20 + handle.includes('.jpg') || 21 + handle.includes('.svg') || 22 + handle.length < 3 23 + ) { 24 + return ( 25 + <main className="flex min-h-screen flex-col items-center justify-center p-6"> 26 + <div className="text-center"> 27 + <h1 className="text-2xl font-bold mb-4">Invalid Handle</h1> 28 + <p className="text-gray-600 mb-6"> 29 + The handle provided is not valid. 30 + </p> 31 + <a href="/" className="text-blue-600 hover:underline"> 32 + Go to homepage 33 + </a> 34 + </div> 35 + </main> 36 + ); 37 + } 38 39 try { 40 + // Use public agent for resolving handle (doesn't require auth) 41 + const publicAgent = getPublicAgent(); 42 + const resolved = await publicAgent.resolveHandle({ handle }); 43 const did = resolved.data.did; 44 45 + // Check if the current user is viewing their own profile 46 + const session = await getSession(); 47 + const isOwner = session?.did === did; 48 + 49 + // Get authenticated agent for profile operations 50 + const agent = await getServerAgent(); 51 + 52 // Get Bluesky profile 53 const bskyProfile = await agent.getProfile({ actor: did }); 54 55 // Get Lanyards profile 56 + const repo = new ProfileRepository(agent); 57 const lanyardProfile = await repo.getProfile(did); 58 59 if (!lanyardProfile) { ··· 86 ...lanyardProfile, 87 displayName: bskyProfile.data.displayName, 88 avatar: bskyProfile.data.avatar, 89 + banner: bskyProfile.data.banner, 90 description: bskyProfile.data.description, 91 }} 92 affiliations={affiliations} 93 webLinks={webLinks} 94 works={works} 95 events={events} 96 + isOwner={isOwner} 97 /> 98 ); 99 } catch (error) {
+28 -54
src/app/api/auth/login/route.ts
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 - import { createAuthUrl } from '@/lib/auth/oauth-client'; 3 - import { getAuthMethod } from '@/lib/auth/config'; 4 - import { 5 - loginWithAppPassword, 6 - getConfiguredCredentials, 7 - } from '@/lib/auth/app-password'; 8 import { createSession } from '@/lib/auth/session'; 9 10 export async function POST(request: NextRequest) { 11 try { 12 - const authMethod = getAuthMethod(); 13 14 - if (authMethod === 'app_password') { 15 - // App Password authentication 16 - const credentials = await getConfiguredCredentials(); 17 - 18 - if (!credentials) { 19 - return NextResponse.json( 20 - { 21 - error: 22 - 'App password not configured. Please set BLUESKY_HANDLE and BLUESKY_APP_PASSWORD in .env', 23 - }, 24 - { status: 500 } 25 - ); 26 - } 27 - 28 - // Login with app password 29 - const session = await loginWithAppPassword( 30 - credentials.handle, 31 - credentials.password 32 ); 33 - 34 - // Create session 35 - await createSession({ 36 - did: session.did, 37 - handle: session.handle, 38 - accessToken: session.accessJwt, 39 - refreshToken: session.refreshJwt, 40 - expiresAt: Date.now() + 24 * 60 * 60 * 1000, // 24 hours 41 - }); 42 43 - return NextResponse.json({ 44 - success: true, 45 - redirect: '/dashboard', 46 - }); 47 - } else { 48 - // OAuth authentication 49 - const body = await request.json(); 50 - const { handle } = body; 51 52 - if (!handle) { 53 - return NextResponse.json( 54 - { error: 'Handle is required' }, 55 - { status: 400 } 56 - ); 57 - } 58 - 59 - // Create authorization URL 60 - const authUrl = await createAuthUrl(handle); 61 62 - return NextResponse.json({ authUrl }); 63 - } 64 } catch (error) { 65 console.error('Login error:', error); 66 return NextResponse.json( 67 { 68 error: 69 - error instanceof Error ? error.message : 'Failed to initiate login', 70 }, 71 { status: 500 } 72 );
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 + import { loginWithAppPassword } from '@/lib/auth/app-password'; 3 import { createSession } from '@/lib/auth/session'; 4 5 export async function POST(request: NextRequest) { 6 try { 7 + const body = await request.json(); 8 + const { identifier, password, pdsUrl } = body; 9 10 + // Validate required fields 11 + if (!identifier || !password) { 12 + return NextResponse.json( 13 + { error: 'Username and password are required' }, 14 + { status: 400 } 15 ); 16 + } 17 18 + // Login with app password 19 + const session = await loginWithAppPassword( 20 + identifier, 21 + password, 22 + pdsUrl || 'https://bsky.social' 23 + ); 24 25 + // Create session 26 + await createSession({ 27 + did: session.did, 28 + handle: session.handle, 29 + accessToken: session.accessJwt, 30 + refreshToken: session.refreshJwt, 31 + expiresAt: Date.now() + 24 * 60 * 60 * 1000, // 24 hours 32 + }); 33 34 + return NextResponse.json({ 35 + success: true, 36 + redirect: '/dashboard', 37 + }); 38 } catch (error) { 39 console.error('Login error:', error); 40 return NextResponse.json( 41 { 42 error: 43 + error instanceof Error ? error.message : 'Failed to login', 44 }, 45 { status: 500 } 46 );
+2 -2
src/app/api/profile/affiliations/route.ts
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 import { getAgent } from '@/lib/auth/atproto'; 3 - import { ResearcherRepository } from '@/lib/data/repository'; 4 5 export async function POST(request: NextRequest) { 6 try { ··· 12 13 const affiliation = await request.json(); 14 15 - const repo = new ResearcherRepository(agent); 16 const rkey = await repo.createAffiliation(affiliation); 17 18 return NextResponse.json({ success: true, rkey });
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 import { getAgent } from '@/lib/auth/atproto'; 3 + import { ProfileRepository } from '@/lib/data/repository'; 4 5 export async function POST(request: NextRequest) { 6 try { ··· 12 13 const affiliation = await request.json(); 14 15 + const repo = new ProfileRepository(agent); 16 const rkey = await repo.createAffiliation(affiliation); 17 18 return NextResponse.json({ success: true, rkey });
+4 -4
src/app/api/profile/basics/route.ts
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 import { getAgent } from '@/lib/auth/atproto'; 3 - import { ResearcherRepository } from '@/lib/data/repository'; 4 5 export async function PUT(request: NextRequest) { 6 try { ··· 11 } 12 13 const body = await request.json(); 14 - const { honorifics, location } = body; 15 16 - const repo = new ResearcherRepository(agent); 17 await repo.updateProfile({ 18 - honorifics, 19 location, 20 }); 21
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 import { getAgent } from '@/lib/auth/atproto'; 3 + import { ProfileRepository } from '@/lib/data/repository'; 4 5 export async function PUT(request: NextRequest) { 6 try { ··· 11 } 12 13 const body = await request.json(); 14 + const { honorific, location } = body; 15 16 + const repo = new ProfileRepository(agent); 17 await repo.updateProfile({ 18 + honorific, 19 location, 20 }); 21
+57 -2
src/app/api/profile/events/route.ts
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 import { getAgent } from '@/lib/auth/atproto'; 3 - import { ResearcherRepository } from '@/lib/data/repository'; 4 5 export async function POST(request: NextRequest) { 6 try { ··· 12 13 const event = await request.json(); 14 15 - const repo = new ResearcherRepository(agent); 16 const rkey = await repo.createEvent(event); 17 18 return NextResponse.json({ success: true, rkey }); ··· 24 ); 25 } 26 }
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 import { getAgent } from '@/lib/auth/atproto'; 3 + import { ProfileRepository } from '@/lib/data/repository'; 4 5 export async function POST(request: NextRequest) { 6 try { ··· 12 13 const event = await request.json(); 14 15 + const repo = new ProfileRepository(agent); 16 const rkey = await repo.createEvent(event); 17 18 return NextResponse.json({ success: true, rkey }); ··· 24 ); 25 } 26 } 27 + 28 + export async function PUT(request: NextRequest) { 29 + try { 30 + const agent = await getAgent(); 31 + 32 + if (!agent) { 33 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 34 + } 35 + 36 + const { rkey, ...updates } = await request.json(); 37 + 38 + if (!rkey) { 39 + return NextResponse.json({ error: 'rkey is required' }, { status: 400 }); 40 + } 41 + 42 + const repo = new ProfileRepository(agent); 43 + await repo.updateEvent(rkey, updates); 44 + 45 + return NextResponse.json({ success: true }); 46 + } catch (error) { 47 + console.error('Error updating event:', error); 48 + return NextResponse.json( 49 + { error: 'Failed to update event' }, 50 + { status: 500 } 51 + ); 52 + } 53 + } 54 + 55 + export async function DELETE(request: NextRequest) { 56 + try { 57 + const agent = await getAgent(); 58 + 59 + if (!agent) { 60 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 61 + } 62 + 63 + const { searchParams } = new URL(request.url); 64 + const rkey = searchParams.get('rkey'); 65 + 66 + if (!rkey) { 67 + return NextResponse.json({ error: 'rkey is required' }, { status: 400 }); 68 + } 69 + 70 + const repo = new ProfileRepository(agent); 71 + await repo.deleteEvent(rkey); 72 + 73 + return NextResponse.json({ success: true }); 74 + } catch (error) { 75 + console.error('Error deleting event:', error); 76 + return NextResponse.json( 77 + { error: 'Failed to delete event' }, 78 + { status: 500 } 79 + ); 80 + } 81 + }
+57 -2
src/app/api/profile/links/route.ts
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 import { getAgent } from '@/lib/auth/atproto'; 3 - import { ResearcherRepository } from '@/lib/data/repository'; 4 5 export async function POST(request: NextRequest) { 6 try { ··· 12 13 const link = await request.json(); 14 15 - const repo = new ResearcherRepository(agent); 16 const rkey = await repo.createWebLink(link); 17 18 return NextResponse.json({ success: true, rkey }); ··· 23 return NextResponse.json({ error: message }, { status: 500 }); 24 } 25 }
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 import { getAgent } from '@/lib/auth/atproto'; 3 + import { ProfileRepository } from '@/lib/data/repository'; 4 5 export async function POST(request: NextRequest) { 6 try { ··· 12 13 const link = await request.json(); 14 15 + const repo = new ProfileRepository(agent); 16 const rkey = await repo.createWebLink(link); 17 18 return NextResponse.json({ success: true, rkey }); ··· 23 return NextResponse.json({ error: message }, { status: 500 }); 24 } 25 } 26 + 27 + export async function PUT(request: NextRequest) { 28 + try { 29 + const agent = await getAgent(); 30 + 31 + if (!agent) { 32 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 33 + } 34 + 35 + const { rkey, ...updates } = await request.json(); 36 + 37 + if (!rkey) { 38 + return NextResponse.json({ error: 'rkey is required' }, { status: 400 }); 39 + } 40 + 41 + const repo = new ProfileRepository(agent); 42 + await repo.updateWebLink(rkey, updates); 43 + 44 + return NextResponse.json({ success: true }); 45 + } catch (error) { 46 + console.error('Error updating link:', error); 47 + return NextResponse.json( 48 + { error: 'Failed to update link' }, 49 + { status: 500 } 50 + ); 51 + } 52 + } 53 + 54 + export async function DELETE(request: NextRequest) { 55 + try { 56 + const agent = await getAgent(); 57 + 58 + if (!agent) { 59 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 60 + } 61 + 62 + const { searchParams } = new URL(request.url); 63 + const rkey = searchParams.get('rkey'); 64 + 65 + if (!rkey) { 66 + return NextResponse.json({ error: 'rkey is required' }, { status: 400 }); 67 + } 68 + 69 + const repo = new ProfileRepository(agent); 70 + await repo.deleteWebLink(rkey); 71 + 72 + return NextResponse.json({ success: true }); 73 + } catch (error) { 74 + console.error('Error deleting link:', error); 75 + return NextResponse.json( 76 + { error: 'Failed to delete link' }, 77 + { status: 500 } 78 + ); 79 + } 80 + }
+78 -5
src/app/api/profile/works/route.ts
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 import { getAgent } from '@/lib/auth/atproto'; 3 - import { ResearcherRepository } from '@/lib/data/repository'; 4 import { resolveDOI } from '@/lib/data/doi'; 5 6 export async function POST(request: NextRequest) { ··· 11 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 12 } 13 14 - const { doi, type } = await request.json(); 15 16 // Resolve DOI metadata 17 const metadata = await resolveDOI(doi); ··· 22 title: metadata?.title, 23 authors: metadata?.authors, 24 publicationDate: metadata?.publicationDate, 25 - journal: metadata?.journal, 26 - metadata: metadata as Record<string, unknown> | undefined, 27 }; 28 29 - const repo = new ResearcherRepository(agent); 30 const rkey = await repo.createWork(work); 31 32 return NextResponse.json({ success: true, rkey }); ··· 38 ); 39 } 40 }
··· 1 import { NextRequest, NextResponse } from 'next/server'; 2 import { getAgent } from '@/lib/auth/atproto'; 3 + import { ProfileRepository } from '@/lib/data/repository'; 4 import { resolveDOI } from '@/lib/data/doi'; 5 6 export async function POST(request: NextRequest) { ··· 11 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 12 } 13 14 + const { doi, type, bypassDuplicateCheck } = await request.json(); 15 + 16 + const repo = new ProfileRepository(agent); 17 + 18 + // Check for duplicates unless explicitly bypassed 19 + if (!bypassDuplicateCheck) { 20 + const existingWorks = await repo.listWorks(agent.session?.did || ''); 21 + const duplicate = existingWorks.find((w) => w.doi === doi); 22 + 23 + if (duplicate) { 24 + return NextResponse.json( 25 + { 26 + error: 'DUPLICATE_DOI', 27 + message: 'This DOI has already been added to your research.', 28 + }, 29 + { status: 409 } 30 + ); 31 + } 32 + } 33 34 // Resolve DOI metadata 35 const metadata = await resolveDOI(doi); ··· 40 title: metadata?.title, 41 authors: metadata?.authors, 42 publicationDate: metadata?.publicationDate, 43 + venue: metadata?.journal, 44 + abstract: metadata?.abstract, 45 + url: metadata?.url, 46 }; 47 48 const rkey = await repo.createWork(work); 49 50 return NextResponse.json({ success: true, rkey }); ··· 56 ); 57 } 58 } 59 + 60 + export async function PUT(request: NextRequest) { 61 + try { 62 + const agent = await getAgent(); 63 + 64 + if (!agent) { 65 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 66 + } 67 + 68 + const { rkey, type } = await request.json(); 69 + 70 + if (!rkey) { 71 + return NextResponse.json({ error: 'rkey is required' }, { status: 400 }); 72 + } 73 + 74 + const repo = new ProfileRepository(agent); 75 + await repo.updateWork(rkey, { type }); 76 + 77 + return NextResponse.json({ success: true }); 78 + } catch (error) { 79 + console.error('Error updating work:', error); 80 + return NextResponse.json( 81 + { error: 'Failed to update work' }, 82 + { status: 500 } 83 + ); 84 + } 85 + } 86 + 87 + export async function DELETE(request: NextRequest) { 88 + try { 89 + const agent = await getAgent(); 90 + 91 + if (!agent) { 92 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 93 + } 94 + 95 + const { searchParams } = new URL(request.url); 96 + const rkey = searchParams.get('rkey'); 97 + 98 + if (!rkey) { 99 + return NextResponse.json({ error: 'rkey is required' }, { status: 400 }); 100 + } 101 + 102 + const repo = new ProfileRepository(agent); 103 + await repo.deleteWork(rkey); 104 + 105 + return NextResponse.json({ success: true }); 106 + } catch (error) { 107 + console.error('Error deleting work:', error); 108 + return NextResponse.json( 109 + { error: 'Failed to delete work' }, 110 + { status: 500 } 111 + ); 112 + } 113 + }
+13 -2
src/app/auth/page.tsx
··· 5 <main className="flex min-h-screen flex-col items-center justify-center p-6 bg-gray-50"> 6 <div className="w-full max-w-md"> 7 <div className="text-center mb-8"> 8 - <h1 className="text-4xl font-bold mb-2">Lanyard</h1> 9 <p className="text-gray-600"> 10 - Sign in with your Bluesky account to get started 11 </p> 12 </div> 13 ··· 25 className="text-blue-600 hover:underline" 26 > 27 Create one here 28 </a> 29 </p> 30 </div>
··· 5 <main className="flex min-h-screen flex-col items-center justify-center p-6 bg-gray-50"> 6 <div className="w-full max-w-md"> 7 <div className="text-center mb-8"> 8 + <h1 className="text-4xl font-bold mb-2">Lanyards</h1> 9 <p className="text-gray-600"> 10 + Sign in with your Bluesky app password 11 </p> 12 </div> 13 ··· 25 className="text-blue-600 hover:underline" 26 > 27 Create one here 28 + </a> 29 + </p> 30 + <p className="mt-2"> 31 + Need an app password?{' '} 32 + <a 33 + href="https://bsky.app/settings/app-passwords" 34 + target="_blank" 35 + rel="noopener noreferrer" 36 + className="text-blue-600 hover:underline" 37 + > 38 + Generate one in settings 39 </a> 40 </p> 41 </div>
+8 -7
src/app/dashboard/events/edit/page.tsx
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 5 import EventForm from '@/components/events/EventForm'; 6 import Link from 'next/link'; 7 8 export default async function EditEventPage({ 9 searchParams, 10 }: { 11 - searchParams: { id?: string }; 12 }) { 13 const session = await getSession(); 14 ··· 16 redirect('/auth'); 17 } 18 19 - if (searchParams.id === undefined) { 20 redirect('/dashboard/events'); 21 } 22 ··· 26 redirect('/auth'); 27 } 28 29 - const repo = new ResearcherRepository(agent); 30 const events = await repo.listEvents(session.did); 31 32 - const eventIndex = parseInt(searchParams.id, 10); 33 - const event = events[eventIndex]; 34 35 if (!event) { 36 redirect('/dashboard/events'); ··· 67 68 {/* Content */} 69 <div className="max-w-2xl mx-auto px-4 py-6"> 70 - <EventForm mode="edit" initialData={event} eventIndex={eventIndex} /> 71 </div> 72 </main> 73 );
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 import EventForm from '@/components/events/EventForm'; 6 import Link from 'next/link'; 7 8 export default async function EditEventPage({ 9 searchParams, 10 }: { 11 + searchParams: Promise<{ rkey?: string }>; 12 }) { 13 const session = await getSession(); 14 ··· 16 redirect('/auth'); 17 } 18 19 + const params = await searchParams; 20 + 21 + if (!params.rkey) { 22 redirect('/dashboard/events'); 23 } 24 ··· 28 redirect('/auth'); 29 } 30 31 + const repo = new ProfileRepository(agent); 32 const events = await repo.listEvents(session.did); 33 34 + const event = events.find((e) => e.rkey === params.rkey); 35 36 if (!event) { 37 redirect('/dashboard/events'); ··· 68 69 {/* Content */} 70 <div className="max-w-2xl mx-auto px-4 py-6"> 71 + <EventForm mode="edit" initialData={event} /> 72 </div> 73 </main> 74 );
+19 -17
src/app/dashboard/events/page.tsx
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 5 import Link from 'next/link'; 6 7 export default async function EventsPage() { ··· 17 redirect('/auth'); 18 } 19 20 - const repo = new ResearcherRepository(agent); 21 const events = await repo.listEvents(session.did); 22 23 return ( ··· 44 </svg> 45 Back 46 </Link> 47 - <h1 className="text-xl font-bold">Events</h1> 48 - <p className="text-sm text-gray-600 mt-1"> 49 - Manage conferences, workshops, and seminars 50 - </p> 51 </div> 52 </div> 53 ··· 86 ) : ( 87 // Events list 88 <div className="space-y-4"> 89 - {events.map((event, index) => { 90 const startDate = new Date(event.startDate); 91 const endDate = event.endDate ? new Date(event.endDate) : null; 92 const isUpcoming = startDate > new Date(); 93 94 return ( 95 <div 96 - key={index} 97 className="bg-white rounded-lg p-4 shadow-sm" 98 > 99 <div className="flex justify-between items-start gap-3"> ··· 175 </div> 176 </div> 177 <Link 178 - href={`/dashboard/events/edit?id=${index}`} 179 className="text-sm text-gray-600 hover:text-gray-900" 180 > 181 Edit ··· 184 </div> 185 ); 186 })} 187 - 188 - {/* Add button */} 189 - <Link 190 - href="/dashboard/events/create" 191 - className="block w-full bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 transition-colors text-center" 192 - > 193 - Add Event 194 - </Link> 195 </div> 196 )} 197 </div>
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 import Link from 'next/link'; 6 7 export default async function EventsPage() { ··· 17 redirect('/auth'); 18 } 19 20 + const repo = new ProfileRepository(agent); 21 const events = await repo.listEvents(session.did); 22 23 return ( ··· 44 </svg> 45 Back 46 </Link> 47 + <div className="flex items-center justify-between"> 48 + <div> 49 + <h1 className="text-xl font-bold">Events</h1> 50 + <p className="text-sm text-gray-600 mt-1"> 51 + Manage conferences, workshops, and seminars 52 + </p> 53 + </div> 54 + <Link 55 + href="/dashboard/events/create" 56 + className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors" 57 + > 58 + Add Event 59 + </Link> 60 + </div> 61 </div> 62 </div> 63 ··· 96 ) : ( 97 // Events list 98 <div className="space-y-4"> 99 + {events.map((event) => { 100 const startDate = new Date(event.startDate); 101 const endDate = event.endDate ? new Date(event.endDate) : null; 102 const isUpcoming = startDate > new Date(); 103 104 return ( 105 <div 106 + key={event.rkey} 107 className="bg-white rounded-lg p-4 shadow-sm" 108 > 109 <div className="flex justify-between items-start gap-3"> ··· 185 </div> 186 </div> 187 <Link 188 + href={`/dashboard/events/edit?rkey=${encodeURIComponent(event.rkey)}`} 189 className="text-sm text-gray-600 hover:text-gray-900" 190 > 191 Edit ··· 194 </div> 195 ); 196 })} 197 </div> 198 )} 199 </div>
+2 -2
src/app/dashboard/links/edit/page.tsx
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 5 import LinkForm from '@/components/links/LinkForm'; 6 import Link from 'next/link'; 7 ··· 26 redirect('/auth'); 27 } 28 29 - const repo = new ResearcherRepository(agent); 30 const links = await repo.listWebLinks(session.did); 31 32 const linkIndex = parseInt(searchParams.id, 10);
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 import LinkForm from '@/components/links/LinkForm'; 6 import Link from 'next/link'; 7 ··· 26 redirect('/auth'); 27 } 28 29 + const repo = new ProfileRepository(agent); 30 const links = await repo.listWebLinks(session.did); 31 32 const linkIndex = parseInt(searchParams.id, 10);
+19 -17
src/app/dashboard/links/page.tsx
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 5 import Link from 'next/link'; 6 7 const PLATFORM_ICONS: Record<string, string> = { ··· 39 redirect('/auth'); 40 } 41 42 - const repo = new ResearcherRepository(agent); 43 const links = await repo.listWebLinks(session.did); 44 45 return ( ··· 66 </svg> 67 Back 68 </Link> 69 - <h1 className="text-xl font-bold">WebLinks</h1> 70 - <p className="text-sm text-gray-600 mt-1"> 71 - Manage social media and custom web links 72 - </p> 73 </div> 74 </div> 75 ··· 108 ) : ( 109 // Links list 110 <div className="space-y-4"> 111 - {links.map((link, index) => { 112 const platformIcon = 113 PLATFORM_ICONS[link.platform || 'custom'] || '🔗'; 114 const platformName = ··· 116 117 return ( 118 <div 119 - key={index} 120 className="bg-white rounded-lg p-4 shadow-sm" 121 > 122 <div className="flex justify-between items-start gap-3"> ··· 168 </div> 169 {!link.isLocked && ( 170 <Link 171 - href={`/dashboard/links/edit?id=${index}`} 172 className="text-sm text-gray-600 hover:text-gray-900" 173 > 174 Edit ··· 178 </div> 179 ); 180 })} 181 - 182 - {/* Add button */} 183 - <Link 184 - href="/dashboard/links/create" 185 - className="block w-full bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 transition-colors text-center" 186 - > 187 - Add Link 188 - </Link> 189 </div> 190 )} 191 </div>
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 import Link from 'next/link'; 6 7 const PLATFORM_ICONS: Record<string, string> = { ··· 39 redirect('/auth'); 40 } 41 42 + const repo = new ProfileRepository(agent); 43 const links = await repo.listWebLinks(session.did); 44 45 return ( ··· 66 </svg> 67 Back 68 </Link> 69 + <div className="flex items-center justify-between"> 70 + <div> 71 + <h1 className="text-xl font-bold">WebLinks</h1> 72 + <p className="text-sm text-gray-600 mt-1"> 73 + Manage social media and custom web links 74 + </p> 75 + </div> 76 + <Link 77 + href="/dashboard/links/create" 78 + className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors" 79 + > 80 + Add Link 81 + </Link> 82 + </div> 83 </div> 84 </div> 85 ··· 118 ) : ( 119 // Links list 120 <div className="space-y-4"> 121 + {links.map((link) => { 122 const platformIcon = 123 PLATFORM_ICONS[link.platform || 'custom'] || '🔗'; 124 const platformName = ··· 126 127 return ( 128 <div 129 + key={link.rkey} 130 className="bg-white rounded-lg p-4 shadow-sm" 131 > 132 <div className="flex justify-between items-start gap-3"> ··· 178 </div> 179 {!link.isLocked && ( 180 <Link 181 + href={`/dashboard/links/edit?rkey=${encodeURIComponent(link.rkey)}`} 182 className="text-sm text-gray-600 hover:text-gray-900" 183 > 184 Edit ··· 188 </div> 189 ); 190 })} 191 </div> 192 )} 193 </div>
+2 -2
src/app/dashboard/page.tsx
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 5 import Link from 'next/link'; 6 import ShareProfileButton from '@/components/profile/ShareProfileButton'; 7 ··· 18 redirect('/auth'); 19 } 20 21 - const repo = new ResearcherRepository(agent); 22 23 try { 24 // Get profile
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 import Link from 'next/link'; 6 import ShareProfileButton from '@/components/profile/ShareProfileButton'; 7 ··· 18 redirect('/auth'); 19 } 20 21 + const repo = new ProfileRepository(agent); 22 23 try { 24 // Get profile
+16 -6
src/app/dashboard/profile/edit/page.tsx
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 5 import ProfileForm from '@/components/profile/ProfileForm'; 6 import Link from 'next/link'; 7 ··· 18 redirect('/auth'); 19 } 20 21 - const repo = new ResearcherRepository(agent); 22 const profile = await repo.getProfile(session.did); 23 24 if (!profile) { ··· 49 </svg> 50 Back 51 </Link> 52 - <h1 className="text-xl font-bold">Edit Profile</h1> 53 - <p className="text-sm text-gray-600 mt-1"> 54 - Update your researcher profile information 55 - </p> 56 </div> 57 </div> 58
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 import ProfileForm from '@/components/profile/ProfileForm'; 6 import Link from 'next/link'; 7 ··· 18 redirect('/auth'); 19 } 20 21 + const repo = new ProfileRepository(agent); 22 const profile = await repo.getProfile(session.did); 23 24 if (!profile) { ··· 49 </svg> 50 Back 51 </Link> 52 + <div className="flex items-center justify-between"> 53 + <div> 54 + <h1 className="text-xl font-bold">Edit Profile</h1> 55 + <p className="text-sm text-gray-600 mt-1"> 56 + Update your researcher profile information 57 + </p> 58 + </div> 59 + <Link 60 + href={`/${profile.handle}`} 61 + className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" 62 + > 63 + View Profile 64 + </Link> 65 + </div> 66 </div> 67 </div> 68
+2 -2
src/app/dashboard/research/create/page.tsx
··· 34 </svg> 35 Back 36 </Link> 37 - <h1 className="text-xl font-bold">Add Publication</h1> 38 <p className="text-sm text-gray-600 mt-1"> 39 - Enter a DOI to add a publication to your profile 40 </p> 41 </div> 42 </div>
··· 34 </svg> 35 Back 36 </Link> 37 + <h1 className="text-xl font-bold">Add Research</h1> 38 <p className="text-sm text-gray-600 mt-1"> 39 + Enter a DOI to add research to your profile 40 </p> 41 </div> 42 </div>
+10 -45
src/app/dashboard/research/edit/page.tsx
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 5 - import ResearchForm from '@/components/research/ResearchForm'; 6 - import Link from 'next/link'; 7 8 export default async function EditResearchPage({ 9 searchParams, 10 }: { 11 - searchParams: { doi?: string }; 12 }) { 13 const session = await getSession(); 14 ··· 16 redirect('/auth'); 17 } 18 19 - if (!searchParams.doi) { 20 redirect('/dashboard/research'); 21 } 22 ··· 26 redirect('/auth'); 27 } 28 29 - const repo = new ResearcherRepository(agent); 30 const works = await repo.listWorks(session.did); 31 32 - // Find the work with the matching DOI 33 - const work = works.find((w) => w.doi === searchParams.doi); 34 35 if (!work) { 36 redirect('/dashboard/research'); 37 } 38 39 - return ( 40 - <main className="min-h-screen bg-gray-50"> 41 - {/* Header */} 42 - <div className="bg-white border-b border-gray-200"> 43 - <div className="max-w-2xl mx-auto px-4 py-4"> 44 - <Link 45 - href="/dashboard/research" 46 - className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-2" 47 - > 48 - <svg 49 - className="w-4 h-4 mr-1" 50 - fill="none" 51 - stroke="currentColor" 52 - viewBox="0 0 24 24" 53 - > 54 - <path 55 - strokeLinecap="round" 56 - strokeLinejoin="round" 57 - strokeWidth={2} 58 - d="M15 19l-7-7 7-7" 59 - /> 60 - </svg> 61 - Back 62 - </Link> 63 - <h1 className="text-xl font-bold">Edit Publication</h1> 64 - <p className="text-sm text-gray-600 mt-1"> 65 - Update publication details 66 - </p> 67 - </div> 68 - </div> 69 - 70 - {/* Content */} 71 - <div className="max-w-2xl mx-auto px-4 py-6"> 72 - <ResearchForm mode="edit" initialData={work} /> 73 - </div> 74 - </main> 75 - ); 76 }
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 + import EditResearchClient from '@/components/research/EditResearchClient'; 6 7 export default async function EditResearchPage({ 8 searchParams, 9 }: { 10 + searchParams: Promise<{ rkey?: string }>; 11 }) { 12 const session = await getSession(); 13 ··· 15 redirect('/auth'); 16 } 17 18 + const params = await searchParams; 19 + 20 + if (!params.rkey) { 21 redirect('/dashboard/research'); 22 } 23 ··· 27 redirect('/auth'); 28 } 29 30 + const repo = new ProfileRepository(agent); 31 const works = await repo.listWorks(session.did); 32 33 + // Find the work with the matching rkey 34 + const work = works.find((w) => w.rkey === params.rkey); 35 36 if (!work) { 37 redirect('/dashboard/research'); 38 } 39 40 + return <EditResearchClient work={work} />; 41 }
+22 -20
src/app/dashboard/research/page.tsx
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 5 import Link from 'next/link'; 6 7 export default async function ResearchPage() { ··· 17 redirect('/auth'); 18 } 19 20 - const repo = new ResearcherRepository(agent); 21 const works = await repo.listWorks(session.did); 22 23 return ( ··· 44 </svg> 45 Back 46 </Link> 47 - <h1 className="text-xl font-bold">Research Links</h1> 48 - <p className="text-sm text-gray-600 mt-1"> 49 - Manage your publications and scholarly works 50 - </p> 51 </div> 52 </div> 53 ··· 71 /> 72 </svg> 73 </div> 74 - <h2 className="text-lg font-semibold mb-2">No publications yet</h2> 75 <p className="text-gray-600 text-sm mb-6"> 76 - Add your first publication by entering its DOI. We&apos;ll 77 automatically fetch the metadata from CrossRef. 78 </p> 79 <Link 80 href="/dashboard/research/create" 81 className="inline-block bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 transition-colors" 82 > 83 - Add Publication 84 </Link> 85 </div> 86 ) : ( ··· 88 <div className="space-y-4"> 89 {works.map((work) => ( 90 <div 91 - key={work.doi} 92 className="bg-white rounded-lg p-4 shadow-sm" 93 > 94 <div className="flex justify-between items-start gap-3"> ··· 112 )} 113 {work.publicationDate && ( 114 <span className="text-xs text-gray-500"> 115 - {new Date(work.publicationDate).getFullYear()} 116 </span> 117 )} 118 </div> ··· 126 </a> 127 </div> 128 <Link 129 - href={`/dashboard/research/edit?doi=${encodeURIComponent(work.doi)}`} 130 className="text-sm text-gray-600 hover:text-gray-900" 131 > 132 Edit ··· 134 </div> 135 </div> 136 ))} 137 - 138 - {/* Add button */} 139 - <Link 140 - href="/dashboard/research/create" 141 - className="block w-full bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 transition-colors text-center" 142 - > 143 - Add Publication 144 - </Link> 145 </div> 146 )} 147 </div>
··· 1 import { redirect } from 'next/navigation'; 2 import { getSession } from '@/lib/auth/session'; 3 import { getAgent } from '@/lib/auth/atproto'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 import Link from 'next/link'; 6 7 export default async function ResearchPage() { ··· 17 redirect('/auth'); 18 } 19 20 + const repo = new ProfileRepository(agent); 21 const works = await repo.listWorks(session.did); 22 23 return ( ··· 44 </svg> 45 Back 46 </Link> 47 + <div className="flex items-center justify-between"> 48 + <div> 49 + <h1 className="text-xl font-bold">Your Research</h1> 50 + <p className="text-sm text-gray-600 mt-1"> 51 + Manage your publications and scholarly works 52 + </p> 53 + </div> 54 + <Link 55 + href="/dashboard/research/create" 56 + className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors" 57 + > 58 + Add Research 59 + </Link> 60 + </div> 61 </div> 62 </div> 63 ··· 81 /> 82 </svg> 83 </div> 84 + <h2 className="text-lg font-semibold mb-2">No research yet</h2> 85 <p className="text-gray-600 text-sm mb-6"> 86 + Add your first research item by entering its DOI. We&apos;ll 87 automatically fetch the metadata from CrossRef. 88 </p> 89 <Link 90 href="/dashboard/research/create" 91 className="inline-block bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 transition-colors" 92 > 93 + Add Research 94 </Link> 95 </div> 96 ) : ( ··· 98 <div className="space-y-4"> 99 {works.map((work) => ( 100 <div 101 + key={work.rkey} 102 className="bg-white rounded-lg p-4 shadow-sm" 103 > 104 <div className="flex justify-between items-start gap-3"> ··· 122 )} 123 {work.publicationDate && ( 124 <span className="text-xs text-gray-500"> 125 + Published: {new Date(work.publicationDate).getFullYear()} 126 </span> 127 )} 128 </div> ··· 136 </a> 137 </div> 138 <Link 139 + href={`/dashboard/research/edit?rkey=${encodeURIComponent(work.rkey)}`} 140 className="text-sm text-gray-600 hover:text-gray-900" 141 > 142 Edit ··· 144 </div> 145 </div> 146 ))} 147 </div> 148 )} 149 </div>
+80 -56
src/components/auth/LoginForm.tsx
··· 1 'use client'; 2 3 - import { useState, useEffect } from 'react'; 4 5 export default function LoginForm() { 6 - const [handle, setHandle] = useState(''); 7 const [loading, setLoading] = useState(false); 8 const [error, setError] = useState(''); 9 - const [authMethod, setAuthMethod] = useState<'oauth' | 'app_password'>( 10 - 'app_password' 11 - ); 12 - 13 - useEffect(() => { 14 - // Fetch auth method from server 15 - fetch('/api/auth/method') 16 - .then((res) => res.json()) 17 - .then((data) => setAuthMethod(data.method)) 18 - .catch(() => { 19 - // Default to app_password if fetch fails 20 - setAuthMethod('app_password'); 21 - }); 22 - }, []); 23 24 const handleSubmit = async (e: React.FormEvent) => { 25 e.preventDefault(); ··· 32 headers: { 33 'Content-Type': 'application/json', 34 }, 35 - body: JSON.stringify({ handle }), 36 }); 37 38 const data = await response.json(); ··· 41 throw new Error(data.error || 'Failed to login'); 42 } 43 44 - // Handle different response types 45 if (data.redirect) { 46 - // App password mode - direct redirect 47 window.location.href = data.redirect; 48 - } else if (data.authUrl) { 49 - // OAuth mode - redirect to authorization URL 50 - window.location.href = data.authUrl; 51 } 52 } catch (err) { 53 setError(err instanceof Error ? err.message : 'An error occurred'); ··· 57 58 return ( 59 <form onSubmit={handleSubmit} className="w-full max-w-md space-y-4"> 60 - {authMethod === 'oauth' && ( 61 - <div> 62 - <label 63 - htmlFor="handle" 64 - className="block text-sm font-medium text-gray-700 mb-2" 65 > 66 - Bluesky Handle 67 - </label> 68 - <input 69 - type="text" 70 - id="handle" 71 - value={handle} 72 - onChange={(e) => setHandle(e.target.value)} 73 - placeholder="username.bsky.social" 74 - className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" 75 - required 76 - disabled={loading} 77 - /> 78 - <p className="mt-2 text-sm text-gray-500"> 79 - Enter your Bluesky handle or custom domain 80 - </p> 81 - </div> 82 - )} 83 84 - {authMethod === 'app_password' && ( 85 - <div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> 86 - <p className="text-sm text-blue-900"> 87 - Using configured Bluesky account for authentication 88 - </p> 89 - <p className="text-xs text-blue-700 mt-1"> 90 - Authentication method: App Password 91 - </p> 92 - </div> 93 - )} 94 95 {error && ( 96 <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm"> ··· 100 101 <button 102 type="submit" 103 - disabled={loading || (authMethod === 'oauth' && !handle)} 104 className="w-full bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors" 105 > 106 - {loading ? 'Connecting...' : 'Sign in with Bluesky'} 107 </button> 108 109 <p className="text-xs text-gray-500 text-center">
··· 1 'use client'; 2 3 + import { useState } from 'react'; 4 5 export default function LoginForm() { 6 + const [identifier, setIdentifier] = useState(''); 7 + const [password, setPassword] = useState(''); 8 + const [pdsUrl, setPdsUrl] = useState('https://bsky.social'); 9 const [loading, setLoading] = useState(false); 10 const [error, setError] = useState(''); 11 12 const handleSubmit = async (e: React.FormEvent) => { 13 e.preventDefault(); ··· 20 headers: { 21 'Content-Type': 'application/json', 22 }, 23 + body: JSON.stringify({ identifier, password, pdsUrl }), 24 }); 25 26 const data = await response.json(); ··· 29 throw new Error(data.error || 'Failed to login'); 30 } 31 32 + // Redirect to dashboard on successful login 33 if (data.redirect) { 34 window.location.href = data.redirect; 35 } 36 } catch (err) { 37 setError(err instanceof Error ? err.message : 'An error occurred'); ··· 41 42 return ( 43 <form onSubmit={handleSubmit} className="w-full max-w-md space-y-4"> 44 + <div> 45 + <label 46 + htmlFor="identifier" 47 + className="block text-sm font-medium text-gray-700 mb-2" 48 + > 49 + Username or Handle 50 + </label> 51 + <input 52 + type="text" 53 + id="identifier" 54 + value={identifier} 55 + onChange={(e) => setIdentifier(e.target.value)} 56 + placeholder="username.bsky.social or username" 57 + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" 58 + required 59 + disabled={loading} 60 + autoComplete="username" 61 + /> 62 + <p className="mt-1 text-sm text-gray-500"> 63 + Enter your Bluesky handle or username 64 + </p> 65 + </div> 66 + 67 + <div> 68 + <label 69 + htmlFor="password" 70 + className="block text-sm font-medium text-gray-700 mb-2" 71 + > 72 + App Password 73 + </label> 74 + <input 75 + type="password" 76 + id="password" 77 + value={password} 78 + onChange={(e) => setPassword(e.target.value)} 79 + placeholder="xxxx-xxxx-xxxx-xxxx" 80 + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" 81 + required 82 + disabled={loading} 83 + autoComplete="current-password" 84 + /> 85 + <p className="mt-1 text-sm text-gray-500"> 86 + Get your app password from{' '} 87 + <a 88 + href="https://bsky.app/settings/app-passwords" 89 + target="_blank" 90 + rel="noopener noreferrer" 91 + className="text-blue-600 hover:underline" 92 > 93 + Bluesky settings 94 + </a> 95 + </p> 96 + </div> 97 98 + <div> 99 + <label 100 + htmlFor="pdsUrl" 101 + className="block text-sm font-medium text-gray-700 mb-2" 102 + > 103 + PDS Server (optional) 104 + </label> 105 + <input 106 + type="url" 107 + id="pdsUrl" 108 + value={pdsUrl} 109 + onChange={(e) => setPdsUrl(e.target.value)} 110 + placeholder="https://bsky.social" 111 + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" 112 + disabled={loading} 113 + /> 114 + <p className="mt-1 text-sm text-gray-500"> 115 + Defaults to bsky.social - only change if using a custom PDS 116 + </p> 117 + </div> 118 119 {error && ( 120 <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm"> ··· 124 125 <button 126 type="submit" 127 + disabled={loading || !identifier || !password} 128 className="w-full bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors" 129 > 130 + {loading ? 'Signing in...' : 'Sign in'} 131 </button> 132 133 <p className="text-xs text-gray-500 text-center">
+32 -23
src/components/events/EventForm.tsx
··· 6 7 interface EventFormProps { 8 mode: 'create' | 'edit'; 9 - initialData?: Event; 10 - eventIndex?: number; 11 } 12 13 const EVENT_TYPES: { value: EventType; label: string }[] = [ ··· 24 export default function EventForm({ 25 mode, 26 initialData, 27 - eventIndex, 28 }: EventFormProps) { 29 const router = useRouter(); 30 const [loading, setLoading] = useState(false); ··· 42 city: initialData?.location?.city || '', 43 country: initialData?.location?.country || '', 44 url: initialData?.url || '', 45 }); 46 47 const handleSubmit = async (e: React.FormEvent) => { ··· 50 setError(''); 51 52 try { 53 - const payload = { 54 - name: formData.name, 55 - type: formData.type, 56 - startDate: new Date(formData.startDate).toISOString(), 57 - endDate: formData.endDate 58 - ? new Date(formData.endDate).toISOString() 59 - : undefined, 60 - location: 61 - formData.city || formData.country 62 - ? { city: formData.city, country: formData.country } 63 - : undefined, 64 - url: formData.url || undefined, 65 - }; 66 67 - const url = 68 - mode === 'edit' 69 - ? `/api/profile/events?index=${eventIndex}` 70 - : '/api/profile/events'; 71 - 72 - const response = await fetch(url, { 73 method: mode === 'create' ? 'POST' : 'PUT', 74 headers: { 75 'Content-Type': 'application/json', ··· 100 101 try { 102 const response = await fetch( 103 - `/api/profile/events?index=${eventIndex}`, 104 { 105 method: 'DELETE', 106 }
··· 6 7 interface EventFormProps { 8 mode: 'create' | 'edit'; 9 + initialData?: Event & { rkey?: string }; 10 } 11 12 const EVENT_TYPES: { value: EventType; label: string }[] = [ ··· 23 export default function EventForm({ 24 mode, 25 initialData, 26 }: EventFormProps) { 27 const router = useRouter(); 28 const [loading, setLoading] = useState(false); ··· 40 city: initialData?.location?.city || '', 41 country: initialData?.location?.country || '', 42 url: initialData?.url || '', 43 + rkey: initialData?.rkey, 44 }); 45 46 const handleSubmit = async (e: React.FormEvent) => { ··· 49 setError(''); 50 51 try { 52 + const payload = mode === 'create' 53 + ? { 54 + name: formData.name, 55 + type: formData.type, 56 + startDate: new Date(formData.startDate).toISOString(), 57 + endDate: formData.endDate 58 + ? new Date(formData.endDate).toISOString() 59 + : undefined, 60 + location: 61 + formData.city || formData.country 62 + ? { city: formData.city, country: formData.country } 63 + : undefined, 64 + url: formData.url || undefined, 65 + } 66 + : { 67 + rkey: formData.rkey, 68 + name: formData.name, 69 + type: formData.type, 70 + startDate: new Date(formData.startDate).toISOString(), 71 + endDate: formData.endDate 72 + ? new Date(formData.endDate).toISOString() 73 + : undefined, 74 + location: 75 + formData.city || formData.country 76 + ? { city: formData.city, country: formData.country } 77 + : undefined, 78 + url: formData.url || undefined, 79 + }; 80 81 + const response = await fetch('/api/profile/events', { 82 method: mode === 'create' ? 'POST' : 'PUT', 83 headers: { 84 'Content-Type': 'application/json', ··· 109 110 try { 111 const response = await fetch( 112 + `/api/profile/events?rkey=${encodeURIComponent(formData.rkey || '')}`, 113 { 114 method: 'DELETE', 115 }
+78 -59
src/components/profile/ProfileForm.tsx
··· 2 3 import { useState } from 'react'; 4 import { useRouter } from 'next/navigation'; 5 - import type { Researcher } from '@/types'; 6 7 interface ProfileFormProps { 8 - profile: Researcher; 9 } 10 11 - const HONORIFIC_OPTIONS = [ 12 - { value: 'Dr', label: 'Dr' }, 13 - { value: 'Prof', label: 'Prof' }, 14 ]; 15 16 export default function ProfileForm({ profile }: ProfileFormProps) { ··· 19 const [error, setError] = useState(''); 20 21 const [formData, setFormData] = useState({ 22 - honorifics: profile.honorifics || [], 23 city: profile.location?.city || '', 24 country: profile.location?.country || '', 25 }); 26 27 - const handleHonorificToggle = (honorific: 'Dr' | 'Prof') => { 28 - const current = formData.honorifics || []; 29 - if (current.includes(honorific)) { 30 - setFormData({ 31 - ...formData, 32 - honorifics: current.filter((h) => h !== honorific), 33 - }); 34 - } else { 35 - setFormData({ 36 - ...formData, 37 - honorifics: [...current, honorific], 38 - }); 39 - } 40 - }; 41 - 42 const handleSubmit = async (e: React.FormEvent) => { 43 e.preventDefault(); 44 setLoading(true); ··· 46 47 try { 48 const payload = { 49 - honorifics: formData.honorifics.length > 0 ? formData.honorifics : undefined, 50 location: 51 formData.city || formData.country 52 ? { ··· 80 return ( 81 <div className="space-y-4"> 82 {/* Profile Preview (Locked Fields) */} 83 - <div className="bg-white rounded-lg p-6 shadow-sm"> 84 - <h2 className="font-semibold mb-4 flex items-center gap-2"> 85 - <span>Bluesky Profile</span> 86 <span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded flex items-center gap-1"> 87 <svg 88 className="w-3 h-3" ··· 99 </svg> 100 Locked 101 </span> 102 - </h2> 103 - <div className="space-y-3"> 104 - <div> 105 - <label className="block text-sm font-medium text-gray-700 mb-1"> 106 - Display Name 107 - </label> 108 - <div className="text-gray-900 bg-gray-50 px-4 py-2 rounded-lg"> 109 - {profile.displayName || 'Not set'} 110 </div> 111 - </div> 112 - <div> 113 - <label className="block text-sm font-medium text-gray-700 mb-1"> 114 - Handle 115 - </label> 116 - <div className="text-gray-900 bg-gray-50 px-4 py-2 rounded-lg"> 117 - @{profile.handle} 118 </div> 119 - </div> 120 - {profile.description && ( 121 <div> 122 <label className="block text-sm font-medium text-gray-700 mb-1"> 123 - Bio 124 </label> 125 <div className="text-gray-900 bg-gray-50 px-4 py-2 rounded-lg"> 126 - {profile.description} 127 </div> 128 </div> 129 - )} 130 </div> 131 - <p className="text-xs text-gray-500 mt-4"> 132 - These fields are synced from your Bluesky profile and cannot be 133 - edited here. Update them on Bluesky to change them. 134 - </p> 135 </div> 136 137 {/* Editable Fields Form */} ··· 141 <div className="space-y-4"> 142 <div> 143 <label className="block text-sm font-medium text-gray-700 mb-2"> 144 - Honorifics 145 </label> 146 - <div className="flex gap-3"> 147 {HONORIFIC_OPTIONS.map((option) => ( 148 <label 149 key={option.value} 150 className="flex items-center gap-2 cursor-pointer" 151 > 152 <input 153 - type="checkbox" 154 - checked={formData.honorifics.includes( 155 - option.value as 'Dr' | 'Prof' 156 - )} 157 - onChange={() => 158 - handleHonorificToggle(option.value as 'Dr' | 'Prof') 159 } 160 - className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" 161 /> 162 <span className="text-sm text-gray-700">{option.label}</span> 163 </label> 164 ))} 165 </div> 166 <p className="mt-1 text-sm text-gray-500"> 167 - Select all that apply 168 </p> 169 </div> 170
··· 2 3 import { useState } from 'react'; 4 import { useRouter } from 'next/navigation'; 5 + import Image from 'next/image'; 6 + import type { Profile, Honorific } from '@/types'; 7 8 interface ProfileFormProps { 9 + profile: Profile; 10 } 11 12 + const HONORIFIC_OPTIONS: { value: Honorific; label: string }[] = [ 13 + { value: 'none', label: 'None' }, 14 + { value: 'Dr', label: 'Dr.' }, 15 + { value: 'Prof', label: 'Prof.' }, 16 ]; 17 18 export default function ProfileForm({ profile }: ProfileFormProps) { ··· 21 const [error, setError] = useState(''); 22 23 const [formData, setFormData] = useState({ 24 + honorific: profile.honorific || 'none', 25 city: profile.location?.city || '', 26 country: profile.location?.country || '', 27 }); 28 29 const handleSubmit = async (e: React.FormEvent) => { 30 e.preventDefault(); 31 setLoading(true); ··· 33 34 try { 35 const payload = { 36 + honorific: formData.honorific, 37 location: 38 formData.city || formData.country 39 ? { ··· 67 return ( 68 <div className="space-y-4"> 69 {/* Profile Preview (Locked Fields) */} 70 + <div className="bg-white rounded-lg overflow-hidden shadow-sm"> 71 + <div className="flex items-center gap-2 p-6 pb-4"> 72 + <h2 className="font-semibold">Bluesky Profile</h2> 73 <span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded flex items-center gap-1"> 74 <svg 75 className="w-3 h-3" ··· 86 </svg> 87 Locked 88 </span> 89 + </div> 90 + 91 + {/* Banner */} 92 + {profile.banner && ( 93 + <div className="relative w-full h-32 bg-gray-200"> 94 + <Image 95 + src={profile.banner} 96 + alt="Profile banner" 97 + fill 98 + className="object-cover" 99 + /> 100 + </div> 101 + )} 102 + 103 + {/* Avatar and Info */} 104 + <div className="px-6 pb-6"> 105 + {profile.avatar && ( 106 + <div className={`relative mb-4 ${profile.banner ? '-mt-12' : 'mt-4'}`}> 107 + <div className="relative w-24 h-24 rounded-full overflow-hidden border-4 border-white bg-gray-200"> 108 + <Image 109 + src={profile.avatar} 110 + alt={profile.displayName || profile.handle} 111 + fill 112 + className="object-cover" 113 + /> 114 + </div> 115 </div> 116 + )} 117 + 118 + <div className="space-y-3"> 119 + <div> 120 + <label className="block text-sm font-medium text-gray-700 mb-1"> 121 + Display Name 122 + </label> 123 + <div className="text-gray-900 bg-gray-50 px-4 py-2 rounded-lg"> 124 + {profile.displayName || 'Not set'} 125 + </div> 126 </div> 127 <div> 128 <label className="block text-sm font-medium text-gray-700 mb-1"> 129 + Handle 130 </label> 131 <div className="text-gray-900 bg-gray-50 px-4 py-2 rounded-lg"> 132 + @{profile.handle} 133 </div> 134 </div> 135 + {profile.description && ( 136 + <div> 137 + <label className="block text-sm font-medium text-gray-700 mb-1"> 138 + Bio 139 + </label> 140 + <div className="text-gray-900 bg-gray-50 px-4 py-2 rounded-lg whitespace-pre-wrap"> 141 + {profile.description} 142 + </div> 143 + </div> 144 + )} 145 + </div> 146 + <p className="text-xs text-gray-500 mt-4"> 147 + These fields are synced from your Bluesky profile and cannot be 148 + edited here. Update them on Bluesky to change them. 149 + </p> 150 </div> 151 </div> 152 153 {/* Editable Fields Form */} ··· 157 <div className="space-y-4"> 158 <div> 159 <label className="block text-sm font-medium text-gray-700 mb-2"> 160 + Honorific 161 </label> 162 + <div className="flex gap-4"> 163 {HONORIFIC_OPTIONS.map((option) => ( 164 <label 165 key={option.value} 166 className="flex items-center gap-2 cursor-pointer" 167 > 168 <input 169 + type="radio" 170 + name="honorific" 171 + value={option.value} 172 + checked={formData.honorific === option.value} 173 + onChange={(e) => 174 + setFormData({ 175 + ...formData, 176 + honorific: e.target.value as Honorific, 177 + }) 178 } 179 + className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500" 180 /> 181 <span className="text-sm text-gray-700">{option.label}</span> 182 </label> 183 ))} 184 </div> 185 <p className="mt-1 text-sm text-gray-500"> 186 + Choose one honorific (tradition: use Dr. or Prof., never both) 187 </p> 188 </div> 189
+109 -51
src/components/profile/ProfileView.tsx
··· 3 import Image from 'next/image'; 4 import QRCodeButton from './QRCodeButton'; 5 import type { 6 - Researcher, 7 Affiliation, 8 Link as WebLink, 9 Work, ··· 11 } from '@/types'; 12 13 interface ProfileViewProps { 14 - profile: Researcher; 15 affiliations: Affiliation[]; 16 webLinks: WebLink[]; 17 works: Work[]; 18 events: Event[]; 19 } 20 21 export default function ProfileView({ ··· 24 webLinks, 25 works, 26 events, 27 }: ProfileViewProps) { 28 const primaryAffiliation = affiliations.find((a) => a.isPrimary); 29 const currentAffiliations = affiliations.filter((a) => !a.endDate); ··· 33 const customLinks = webLinks.filter((l) => l.type === 'web'); 34 const blueskyProfile = socialLinks.find((s) => s.platform === 'bluesky'); 35 36 return ( 37 <main className="min-h-screen bg-gray-50"> 38 {/* Header Section - Mobile-first */} 39 <div className="bg-white border-b border-gray-200"> 40 - <div className="max-w-2xl mx-auto px-4 py-6"> 41 - {/* Avatar and Basic Info */} 42 - <div className="flex items-start gap-4 mb-4"> 43 - {profile.avatar && ( 44 <Image 45 - src={profile.avatar} 46 - alt={profile.displayName || profile.handle} 47 - width={80} 48 - height={80} 49 - className="rounded-full" 50 /> 51 - )} 52 - <div className="flex-1 min-w-0"> 53 - <div className="flex items-baseline gap-2 mb-1"> 54 - {profile.honorifics && profile.honorifics.length > 0 && ( 55 - <span className="text-sm text-gray-600"> 56 - {profile.honorifics.join(', ')} 57 - </span> 58 - )} 59 </div> 60 - <h1 className="text-2xl font-bold truncate"> 61 - {profile.displayName || profile.handle} 62 - </h1> 63 - <p className="text-gray-600">@{profile.handle}</p> 64 </div> 65 - </div> 66 67 - {/* Primary Action - Follow on Bluesky */} 68 - {blueskyProfile && ( 69 <a 70 href={blueskyProfile.url} 71 target="_blank" ··· 74 > 75 Follow on Bluesky 76 </a> 77 - )} 78 79 {/* QR Code Button */} 80 - <div className="mb-4"> 81 <QRCodeButton 82 url={`${typeof window !== 'undefined' ? window.location.origin : ''}/${profile.handle}`} 83 handle={profile.handle} 84 /> 85 </div> 86 - 87 - {/* Description */} 88 - {profile.description && ( 89 - <p className="text-gray-700 mb-4">{profile.description}</p> 90 - )} 91 - 92 - {/* Current Affiliation */} 93 - {primaryAffiliation && ( 94 - <div className="text-sm text-gray-600"> 95 - <p className="font-medium">{primaryAffiliation.organization.name}</p> 96 - {primaryAffiliation.role && <p>{primaryAffiliation.role}</p>} 97 - </div> 98 - )} 99 - 100 - {/* Location */} 101 - {profile.location && ( 102 - <p className="text-sm text-gray-500 mt-2"> 103 - {profile.location.city && `${profile.location.city}, `} 104 - {profile.location.country} 105 - </p> 106 - )} 107 </div> 108 </div> 109 ··· 174 </section> 175 )} 176 177 - {/* Works */} 178 {works.length > 0 && ( 179 <section className="bg-white rounded-lg p-4 shadow-sm"> 180 <h2 className="text-lg font-semibold mb-3"> 181 - Scholarly Contributions 182 </h2> 183 <div className="space-y-4"> 184 {works.map((work, idx) => (
··· 3 import Image from 'next/image'; 4 import QRCodeButton from './QRCodeButton'; 5 import type { 6 + Profile, 7 Affiliation, 8 Link as WebLink, 9 Work, ··· 11 } from '@/types'; 12 13 interface ProfileViewProps { 14 + profile: Profile; 15 affiliations: Affiliation[]; 16 webLinks: WebLink[]; 17 works: Work[]; 18 events: Event[]; 19 + isOwner?: boolean; 20 } 21 22 export default function ProfileView({ ··· 25 webLinks, 26 works, 27 events, 28 + isOwner = false, 29 }: ProfileViewProps) { 30 const primaryAffiliation = affiliations.find((a) => a.isPrimary); 31 const currentAffiliations = affiliations.filter((a) => !a.endDate); ··· 35 const customLinks = webLinks.filter((l) => l.type === 'web'); 36 const blueskyProfile = socialLinks.find((s) => s.platform === 'bluesky'); 37 38 + // Format display name with honorific 39 + const getDisplayName = () => { 40 + const name = profile.displayName || profile.handle; 41 + if (profile.honorific && profile.honorific !== 'none') { 42 + return `${profile.honorific}. ${name}`; 43 + } 44 + return name; 45 + }; 46 + 47 return ( 48 <main className="min-h-screen bg-gray-50"> 49 {/* Header Section - Mobile-first */} 50 <div className="bg-white border-b border-gray-200"> 51 + <div className="max-w-2xl mx-auto"> 52 + {/* Banner */} 53 + {profile.banner && ( 54 + <div className="relative w-full h-48 bg-gray-200"> 55 <Image 56 + src={profile.banner} 57 + alt="Profile banner" 58 + fill 59 + className="object-cover" 60 + priority 61 /> 62 + </div> 63 + )} 64 + 65 + <div className="px-4 py-6"> 66 + {/* Avatar and Basic Info */} 67 + <div className="flex items-start gap-4 mb-4"> 68 + {profile.avatar && ( 69 + <div className={profile.banner ? '-mt-16' : ''}> 70 + <div className="relative w-24 h-24 rounded-full overflow-hidden border-4 border-white bg-gray-200"> 71 + <Image 72 + src={profile.avatar} 73 + alt={getDisplayName()} 74 + fill 75 + className="object-cover" 76 + priority 77 + /> 78 + </div> 79 + </div> 80 + )} 81 + <div className="flex-1 min-w-0"> 82 + <h1 className="text-2xl font-bold truncate"> 83 + {getDisplayName()} 84 + </h1> 85 + <a 86 + href={`https://bsky.app/profile/${profile.handle}`} 87 + target="_blank" 88 + rel="noopener noreferrer" 89 + className="text-gray-600 hover:text-blue-600 hover:underline" 90 + > 91 + @{profile.handle} 92 + </a> 93 </div> 94 </div> 95 96 + {/* Description */} 97 + {profile.description && ( 98 + <p className="text-gray-700 mb-4 whitespace-pre-wrap">{profile.description}</p> 99 + )} 100 + 101 + {/* Location */} 102 + {profile.location && ( 103 + <div className="flex items-center gap-1 text-sm text-gray-600 mb-4"> 104 + <svg 105 + className="w-4 h-4" 106 + fill="none" 107 + stroke="currentColor" 108 + viewBox="0 0 24 24" 109 + > 110 + <path 111 + strokeLinecap="round" 112 + strokeLinejoin="round" 113 + strokeWidth={2} 114 + d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" 115 + /> 116 + <path 117 + strokeLinecap="round" 118 + strokeLinejoin="round" 119 + strokeWidth={2} 120 + d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" 121 + /> 122 + </svg> 123 + <span> 124 + {profile.location.city && `${profile.location.city}, `} 125 + {profile.location.country} 126 + </span> 127 + </div> 128 + )} 129 + 130 + {/* Current Affiliation */} 131 + {primaryAffiliation && ( 132 + <div className="text-sm text-gray-600 mb-4"> 133 + <p className="font-medium">{primaryAffiliation.organization.name}</p> 134 + {primaryAffiliation.role && <p>{primaryAffiliation.role}</p>} 135 + </div> 136 + )} 137 + 138 + {/* Primary Action - Edit Profile or Follow on Bluesky */} 139 + {isOwner ? ( 140 + <a 141 + href="/dashboard/profile/edit" 142 + className="block w-full bg-blue-600 text-white text-center py-3 px-6 rounded-lg font-medium hover:bg-blue-700 transition-colors mb-2" 143 + > 144 + Edit Profile 145 + </a> 146 + ) : blueskyProfile ? ( 147 <a 148 href={blueskyProfile.url} 149 target="_blank" ··· 152 > 153 Follow on Bluesky 154 </a> 155 + ) : null} 156 157 {/* QR Code Button */} 158 + <div> 159 <QRCodeButton 160 url={`${typeof window !== 'undefined' ? window.location.origin : ''}/${profile.handle}`} 161 handle={profile.handle} 162 /> 163 </div> 164 + </div> 165 </div> 166 </div> 167 ··· 232 </section> 233 )} 234 235 + {/* Research */} 236 {works.length > 0 && ( 237 <section className="bg-white rounded-lg p-4 shadow-sm"> 238 <h2 className="text-lg font-semibold mb-3"> 239 + Research 240 </h2> 241 <div className="space-y-4"> 242 {works.map((work, idx) => (
+49
src/components/research/EditResearchClient.tsx
···
··· 1 + 'use client'; 2 + 3 + import ResearchForm from './ResearchForm'; 4 + import Link from 'next/link'; 5 + import type { Work } from '@/types'; 6 + 7 + interface EditResearchClientProps { 8 + work: Work & { rkey: string }; 9 + } 10 + 11 + export default function EditResearchClient({ work }: EditResearchClientProps) { 12 + return ( 13 + <main className="min-h-screen bg-gray-50"> 14 + {/* Header */} 15 + <div className="bg-white border-b border-gray-200"> 16 + <div className="max-w-2xl mx-auto px-4 py-4"> 17 + <Link 18 + href="/dashboard/research" 19 + className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-2" 20 + > 21 + <svg 22 + className="w-4 h-4 mr-1" 23 + fill="none" 24 + stroke="currentColor" 25 + viewBox="0 0 24 24" 26 + > 27 + <path 28 + strokeLinecap="round" 29 + strokeLinejoin="round" 30 + strokeWidth={2} 31 + d="M15 19l-7-7 7-7" 32 + /> 33 + </svg> 34 + Back 35 + </Link> 36 + <h1 className="text-xl font-bold">Edit Research</h1> 37 + <p className="text-sm text-gray-600 mt-1"> 38 + Update research details 39 + </p> 40 + </div> 41 + </div> 42 + 43 + {/* Content */} 44 + <div className="max-w-2xl mx-auto px-4 py-6"> 45 + <ResearchForm mode="edit" initialData={work} /> 46 + </div> 47 + </main> 48 + ); 49 + }
+121 -17
src/components/research/ResearchForm.tsx
··· 6 7 interface ResearchFormProps { 8 mode: 'create' | 'edit'; 9 - initialData?: Work; 10 } 11 12 const WORK_TYPES: { value: WorkType; label: string }[] = [ ··· 28 const [resolvingDOI, setResolvingDOI] = useState(false); 29 const [error, setError] = useState(''); 30 const [resolvedMetadata, setResolvedMetadata] = useState<any>(null); 31 32 const [formData, setFormData] = useState<{ 33 doi: string; 34 type: WorkType | ''; 35 }>({ 36 doi: initialData?.doi || '', 37 type: initialData?.type || '', 38 }); 39 40 const handleResolveDOI = async () => { ··· 62 } 63 }; 64 65 - const handleSubmit = async (e: React.FormEvent) => { 66 e.preventDefault(); 67 setLoading(true); 68 setError(''); 69 70 try { 71 const response = await fetch('/api/profile/works', { 72 method: mode === 'create' ? 'POST' : 'PUT', 73 headers: { 74 'Content-Type': 'application/json', 75 }, 76 - body: JSON.stringify(formData), 77 }); 78 79 if (!response.ok) { 80 - const data = await response.json(); 81 - throw new Error(data.error || `Failed to ${mode} work`); 82 } 83 84 router.push('/dashboard/research'); ··· 89 } 90 }; 91 92 const handleDelete = async () => { 93 - if (!confirm('Are you sure you want to delete this publication?')) { 94 return; 95 } 96 97 setLoading(true); 98 - setError(''); 99 100 try { 101 const response = await fetch( 102 - `/api/profile/works?doi=${encodeURIComponent(formData.doi)}`, 103 { 104 method: 'DELETE', 105 } ··· 107 108 if (!response.ok) { 109 const data = await response.json(); 110 - throw new Error(data.error || 'Failed to delete work'); 111 } 112 113 router.push('/dashboard/research'); 114 router.refresh(); 115 } catch (err) { 116 - setError(err instanceof Error ? err.message : 'An error occurred'); 117 setLoading(false); 118 } 119 }; 120 121 return ( 122 - <form onSubmit={handleSubmit} className="bg-white rounded-lg p-6 shadow-sm"> 123 <div className="space-y-4"> 124 <div> 125 <label className="block text-sm font-medium text-gray-700 mb-2"> ··· 166 </p> 167 )} 168 {resolvedMetadata.authors && ( 169 - <p className="text-sm text-blue-800"> 170 <strong>Authors:</strong> {resolvedMetadata.authors.join(', ')} 171 </p> 172 )} 173 </div> 174 )} 175 ··· 196 </div> 197 198 {error && ( 199 - <div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm"> 200 - {error} 201 </div> 202 )} 203 ··· 212 ? 'Adding...' 213 : 'Saving...' 214 : mode === 'create' 215 - ? 'Add Publication' 216 : 'Save Changes'} 217 </button> 218 219 <button 220 type="button" 221 onClick={() => router.push('/dashboard/research')} 222 - className="w-full py-3 px-6 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" 223 > 224 Cancel 225 </button> ··· 231 disabled={loading} 232 className="w-full text-red-600 py-2 hover:text-red-700 disabled:text-gray-400" 233 > 234 - Delete Publication 235 </button> 236 )} 237 </div>
··· 6 7 interface ResearchFormProps { 8 mode: 'create' | 'edit'; 9 + initialData?: Work & { rkey?: string }; 10 } 11 12 const WORK_TYPES: { value: WorkType; label: string }[] = [ ··· 28 const [resolvingDOI, setResolvingDOI] = useState(false); 29 const [error, setError] = useState(''); 30 const [resolvedMetadata, setResolvedMetadata] = useState<any>(null); 31 + const [showDuplicateWarning, setShowDuplicateWarning] = useState(false); 32 33 const [formData, setFormData] = useState<{ 34 doi: string; 35 type: WorkType | ''; 36 + rkey?: string; 37 }>({ 38 doi: initialData?.doi || '', 39 type: initialData?.type || '', 40 + rkey: initialData?.rkey, 41 }); 42 43 const handleResolveDOI = async () => { ··· 65 } 66 }; 67 68 + const handleSubmit = async (e: React.FormEvent, bypassDuplicateCheck = false) => { 69 e.preventDefault(); 70 setLoading(true); 71 setError(''); 72 + setShowDuplicateWarning(false); 73 74 try { 75 + const payload = mode === 'create' 76 + ? { doi: formData.doi, type: formData.type, bypassDuplicateCheck } 77 + : { rkey: formData.rkey, type: formData.type }; 78 + 79 const response = await fetch('/api/profile/works', { 80 method: mode === 'create' ? 'POST' : 'PUT', 81 headers: { 82 'Content-Type': 'application/json', 83 }, 84 + body: JSON.stringify(payload), 85 }); 86 87 + const data = await response.json(); 88 + 89 if (!response.ok) { 90 + // Handle duplicate error specially 91 + if (data.error === 'DUPLICATE_DOI' && !bypassDuplicateCheck) { 92 + setShowDuplicateWarning(true); 93 + setError(data.message); 94 + setLoading(false); 95 + return; 96 + } 97 + throw new Error(data.error || `Failed to ${mode} research`); 98 } 99 100 router.push('/dashboard/research'); ··· 105 } 106 }; 107 108 + const handleAddDuplicate = (e: React.FormEvent) => { 109 + handleSubmit(e, true); 110 + }; 111 + 112 const handleDelete = async () => { 113 + if (!confirm('Are you sure you want to delete this research item?')) { 114 return; 115 } 116 117 setLoading(true); 118 119 try { 120 const response = await fetch( 121 + `/api/profile/works?rkey=${encodeURIComponent(formData.rkey || '')}`, 122 { 123 method: 'DELETE', 124 } ··· 126 127 if (!response.ok) { 128 const data = await response.json(); 129 + throw new Error(data.error || 'Failed to delete research'); 130 } 131 132 router.push('/dashboard/research'); 133 router.refresh(); 134 } catch (err) { 135 + setError(err instanceof Error ? err.message : 'Failed to delete research'); 136 setLoading(false); 137 } 138 }; 139 140 return ( 141 + <form id="research-form" onSubmit={handleSubmit} className="bg-white rounded-lg p-6 shadow-sm"> 142 <div className="space-y-4"> 143 <div> 144 <label className="block text-sm font-medium text-gray-700 mb-2"> ··· 185 </p> 186 )} 187 {resolvedMetadata.authors && ( 188 + <p className="text-sm text-blue-800 mb-1"> 189 <strong>Authors:</strong> {resolvedMetadata.authors.join(', ')} 190 </p> 191 )} 192 + {resolvedMetadata.journal && ( 193 + <p className="text-sm text-blue-800 mb-1"> 194 + <strong>Venue:</strong> {resolvedMetadata.journal} 195 + </p> 196 + )} 197 + {resolvedMetadata.publicationDate && ( 198 + <p className="text-sm text-blue-800 mb-1"> 199 + <strong>Published:</strong> {new Date(resolvedMetadata.publicationDate).getFullYear()} 200 + </p> 201 + )} 202 + {resolvedMetadata.abstract && ( 203 + <p className="text-sm text-blue-800 mb-1"> 204 + <strong>Abstract:</strong> {resolvedMetadata.abstract.substring(0, 200)}... 205 + </p> 206 + )} 207 + {resolvedMetadata.url && ( 208 + <p className="text-sm text-blue-800"> 209 + <strong>URL:</strong>{' '} 210 + <a 211 + href={resolvedMetadata.url} 212 + target="_blank" 213 + rel="noopener noreferrer" 214 + className="underline hover:text-blue-900" 215 + > 216 + {resolvedMetadata.url} 217 + </a> 218 + </p> 219 + )} 220 + </div> 221 + )} 222 + 223 + {mode === 'edit' && initialData && ( 224 + <div className="p-4 bg-gray-50 border border-gray-200 rounded-lg"> 225 + <p className="text-sm font-medium text-gray-900 mb-2"> 226 + Work Metadata 227 + </p> 228 + {initialData.title && ( 229 + <p className="text-sm text-gray-800 mb-1"> 230 + <strong>Title:</strong> {initialData.title} 231 + </p> 232 + )} 233 + {initialData.authors && initialData.authors.length > 0 && ( 234 + <p className="text-sm text-gray-800 mb-1"> 235 + <strong>Authors:</strong> {initialData.authors.join(', ')} 236 + </p> 237 + )} 238 + {initialData.venue && ( 239 + <p className="text-sm text-gray-800 mb-1"> 240 + <strong>Venue:</strong> {initialData.venue} 241 + </p> 242 + )} 243 + {initialData.publicationDate && ( 244 + <p className="text-sm text-gray-800 mb-1"> 245 + <strong>Published:</strong> {new Date(initialData.publicationDate).getFullYear()} 246 + </p> 247 + )} 248 + {('abstract' in initialData && initialData.abstract && typeof initialData.abstract === 'string') ? ( 249 + <p className="text-sm text-gray-800 mb-1"> 250 + <strong>Abstract:</strong> {initialData.abstract.substring(0, 200)}{initialData.abstract.length > 200 ? '...' : ''} 251 + </p> 252 + ) : null} 253 + {('url' in initialData && initialData.url && typeof initialData.url === 'string') ? ( 254 + <p className="text-sm text-gray-800"> 255 + <strong>URL:</strong>{' '} 256 + <a 257 + href={initialData.url} 258 + target="_blank" 259 + rel="noopener noreferrer" 260 + className="underline hover:text-gray-900" 261 + > 262 + {initialData.url} 263 + </a> 264 + </p> 265 + ) : null} 266 </div> 267 )} 268 ··· 289 </div> 290 291 {error && ( 292 + <div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg"> 293 + <p className="text-red-700 text-sm mb-2">{error}</p> 294 + {showDuplicateWarning && ( 295 + <button 296 + type="button" 297 + onClick={handleAddDuplicate} 298 + disabled={loading} 299 + className="text-sm text-red-700 underline hover:text-red-800 disabled:opacity-50" 300 + > 301 + Add anyway 302 + </button> 303 + )} 304 </div> 305 )} 306 ··· 315 ? 'Adding...' 316 : 'Saving...' 317 : mode === 'create' 318 + ? 'Add Research' 319 : 'Save Changes'} 320 </button> 321 322 <button 323 type="button" 324 onClick={() => router.push('/dashboard/research')} 325 + disabled={loading} 326 + className="w-full py-3 px-6 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50" 327 > 328 Cancel 329 </button> ··· 335 disabled={loading} 336 className="w-full text-red-600 py-2 hover:text-red-700 disabled:text-gray-400" 337 > 338 + Delete Research 339 </button> 340 )} 341 </div>
+5 -2
src/lib/auth/app-password.ts
··· 14 15 export async function loginWithAppPassword( 16 identifier: string, 17 - password: string 18 ): Promise<{ 19 did: string; 20 handle: string; 21 accessJwt: string; 22 refreshJwt: string; 23 }> { 24 const agent = new AtpAgent({ 25 - service: process.env.PDS_URL || 'https://bsky.social', 26 }); 27 28 const response = await agent.login({
··· 14 15 export async function loginWithAppPassword( 16 identifier: string, 17 + password: string, 18 + pdsUrl?: string 19 ): Promise<{ 20 did: string; 21 handle: string; 22 accessJwt: string; 23 refreshJwt: string; 24 }> { 25 + const serviceUrl = pdsUrl || process.env.PDS_URL || 'https://bsky.social'; 26 + 27 const agent = new AtpAgent({ 28 + service: serviceUrl, 29 }); 30 31 const response = await agent.login({
+60
src/lib/auth/server-agent.ts
···
··· 1 + /** 2 + * Server-side authenticated agent utilities 3 + * 4 + * Provides authenticated AtpAgent instances for server-side operations 5 + * using the configured app password credentials. 6 + */ 7 + 8 + import { AtpAgent } from '@atproto/api'; 9 + import { getConfiguredCredentials } from './app-password'; 10 + 11 + let cachedAgent: AtpAgent | null = null; 12 + let cacheTime: number = 0; 13 + const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes 14 + 15 + /** 16 + * Gets an authenticated AtpAgent for server-side use 17 + * Uses app password authentication configured in environment variables 18 + * 19 + * @returns Authenticated AtpAgent 20 + * @throws Error if credentials are not configured or login fails 21 + */ 22 + export async function getServerAgent(): Promise<AtpAgent> { 23 + // Return cached agent if still valid 24 + if (cachedAgent && Date.now() - cacheTime < CACHE_DURATION) { 25 + return cachedAgent; 26 + } 27 + 28 + const credentials = await getConfiguredCredentials(); 29 + if (!credentials) { 30 + throw new Error( 31 + 'Server authentication not configured. Set BLUESKY_HANDLE and BLUESKY_APP_PASSWORD in .env' 32 + ); 33 + } 34 + 35 + const agent = new AtpAgent({ 36 + service: process.env.PDS_URL || 'https://bsky.social', 37 + }); 38 + 39 + await agent.login({ 40 + identifier: credentials.handle, 41 + password: credentials.password, 42 + }); 43 + 44 + cachedAgent = agent; 45 + cacheTime = Date.now(); 46 + 47 + return agent; 48 + } 49 + 50 + /** 51 + * Creates a public (unauthenticated) AtpAgent 52 + * Use this for operations that don't require authentication 53 + * 54 + * @returns Unauthenticated AtpAgent 55 + */ 56 + export function getPublicAgent(): AtpAgent { 57 + return new AtpAgent({ 58 + service: process.env.PDS_URL || 'https://bsky.social', 59 + }); 60 + }
+64
src/lib/client/factory.ts
···
··· 1 + /** 2 + * Factory for creating Lanyard API clients with authentication 3 + * Bridges AtpAgent session management with XrpcClient 4 + */ 5 + 6 + import { AtpAgent } from '@atproto/api'; 7 + import { AtpBaseClient } from '@/types/generated'; 8 + 9 + /** 10 + * Creates a Lanyard API client from an authenticated AtpAgent 11 + * 12 + * @param agent - Authenticated AtpAgent with active session 13 + * @returns Configured AtpBaseClient ready to make authenticated requests 14 + * @throws Error if agent has no active session 15 + * 16 + * @example 17 + * ```typescript 18 + * const agent = new AtpAgent({ service: 'https://bsky.social' }); 19 + * await agent.login({ identifier: 'user', password: 'pass' }); 20 + * 21 + * const client = createLanyardClient(agent); 22 + * const result = await client.at.lanyard.profile.get({ 23 + * repo: agent.session.did, 24 + * rkey: 'self' 25 + * }); 26 + * ``` 27 + */ 28 + export function createLanyardClient(agent: AtpAgent): AtpBaseClient { 29 + if (!agent.session) { 30 + throw new Error('AtpAgent must have an active session to create client'); 31 + } 32 + 33 + const session = agent.session; 34 + 35 + return new AtpBaseClient({ 36 + service: agent.service.toString(), 37 + headers: { 38 + authorization: `Bearer ${session.accessJwt}`, 39 + }, 40 + }); 41 + } 42 + 43 + /** 44 + * Creates a Lanyard API client for unauthenticated read operations 45 + * 46 + * @param serviceUrl - AT Protocol service URL (e.g., 'https://bsky.social') 47 + * @returns AtpBaseClient without authentication 48 + * 49 + * @example 50 + * ```typescript 51 + * const client = createPublicLanyardClient('https://bsky.social'); 52 + * const profile = await client.at.lanyard.profile.get({ 53 + * repo: 'did:plc:abc123', 54 + * rkey: 'self' 55 + * }); 56 + * ``` 57 + */ 58 + export function createPublicLanyardClient( 59 + serviceUrl: string = 'https://bsky.social' 60 + ): AtpBaseClient { 61 + return new AtpBaseClient({ 62 + service: serviceUrl, 63 + }); 64 + }
+7
src/lib/client/index.ts
···
··· 1 + /** 2 + * Lanyard API Client 3 + * Re-exports generated client and factory functions 4 + */ 5 + 6 + export { AtpBaseClient } from '@/types/generated'; 7 + export { createLanyardClient, createPublicLanyardClient } from './factory';
+12 -1
src/lib/data/doi.ts
··· 3 * Fetches metadata from CrossRef and DataCite 4 */ 5 6 export interface DOIMetadata { 7 title?: string; 8 authors?: string[]; ··· 45 journal: 46 work['container-title']?.[0] || work.publisher || work.institution, 47 type: work.type, 48 - abstract: work.abstract, 49 url: work.URL, 50 }; 51 } catch (error) {
··· 3 * Fetches metadata from CrossRef and DataCite 4 */ 5 6 + /** 7 + * Strip JATS XML tags from text 8 + * JATS (Journal Article Tag Suite) is commonly used in academic abstracts 9 + */ 10 + function stripJATSTags(text: string): string { 11 + if (!text) return text; 12 + 13 + // Remove all XML/HTML tags 14 + return text.replace(/<[^>]*>/g, '').trim(); 15 + } 16 + 17 export interface DOIMetadata { 18 title?: string; 19 authors?: string[]; ··· 56 journal: 57 work['container-title']?.[0] || work.publisher || work.institution, 58 type: work.type, 59 + abstract: work.abstract ? stripJATSTags(work.abstract) : undefined, 60 url: work.URL, 61 }; 62 } catch (error) {
+56 -26
src/lib/data/repository.ts
··· 1 /** 2 - * Repository pattern for managing researcher data in PDS 3 * This provides an abstraction layer for CRUD operations on AT Protocol records 4 */ 5 6 import { AtpAgent } from '@atproto/api'; 7 import { TID } from '@atproto/common'; 8 import type { 9 - Researcher, 10 Affiliation, 11 Link as WebLink, 12 - 13 Work, 14 Event, 15 } from '@/types'; 16 17 const LEXICON_PREFIX = 'at.lanyard'; 18 19 - export class ResearcherRepository { 20 constructor(private agent: AtpAgent) {} 21 22 // Profile operations 23 - async getProfile(did: string): Promise<Researcher | null> { 24 try { 25 const response = await this.agent.com.atproto.repo.getRecord({ 26 repo: did, 27 - collection: `${LEXICON_PREFIX}.actor.profile`, 28 rkey: 'self', 29 }); 30 - return response.data.value as unknown as Researcher; 31 } catch { 32 return null; 33 } 34 } 35 36 - async createProfile(profile: Omit<Researcher, 'createdAt'>) { 37 return this.agent.com.atproto.repo.putRecord({ 38 repo: this.agent.session?.did || '', 39 - collection: `${LEXICON_PREFIX}.actor.profile`, 40 rkey: 'self', 41 record: { 42 ...profile, 43 createdAt: new Date().toISOString(), 44 }, 45 }); 46 } 47 48 - async updateProfile(updates: Partial<Researcher>) { 49 const current = await this.getProfile(this.agent.session?.did || ''); 50 if (!current) { 51 throw new Error('Profile not found'); ··· 53 54 return this.agent.com.atproto.repo.putRecord({ 55 repo: this.agent.session?.did || '', 56 - collection: `${LEXICON_PREFIX}.actor.profile`, 57 rkey: 'self', 58 record: { 59 ...current, 60 ...updates, 61 updatedAt: new Date().toISOString(), 62 }, 63 }); ··· 67 async listAffiliations(did: string): Promise<Affiliation[]> { 68 const response = await this.agent.com.atproto.repo.listRecords({ 69 repo: did, 70 - collection: `${LEXICON_PREFIX}.actor.affiliation`, 71 }); 72 return response.data.records.map((r) => r.value as unknown as Affiliation); 73 } ··· 78 const rkey = TID.nextStr(); 79 await this.agent.com.atproto.repo.putRecord({ 80 repo: this.agent.session?.did || '', 81 - collection: `${LEXICON_PREFIX}.actor.affiliation`, 82 rkey, 83 record: { 84 ...affiliation, 85 createdAt: new Date().toISOString(), 86 }, ··· 91 async updateAffiliation(rkey: string, updates: Partial<Affiliation>) { 92 const record = await this.agent.com.atproto.repo.getRecord({ 93 repo: this.agent.session?.did || '', 94 - collection: `${LEXICON_PREFIX}.actor.affiliation`, 95 rkey, 96 }); 97 98 return this.agent.com.atproto.repo.putRecord({ 99 repo: this.agent.session?.did || '', 100 - collection: `${LEXICON_PREFIX}.actor.affiliation`, 101 rkey, 102 record: { 103 ...record.data.value, 104 ...updates, 105 }, 106 }); 107 } ··· 109 async deleteAffiliation(rkey: string) { 110 return this.agent.com.atproto.repo.deleteRecord({ 111 repo: this.agent.session?.did || '', 112 - collection: `${LEXICON_PREFIX}.actor.affiliation`, 113 rkey, 114 }); 115 } ··· 130 collection: `${LEXICON_PREFIX}.link`, 131 rkey, 132 record: { 133 ...link, 134 createdAt: new Date().toISOString(), 135 }, ··· 151 record: { 152 ...record.data.value, 153 ...updates, 154 }, 155 }); 156 } ··· 164 } 165 166 // Work operations 167 - async listWorks(did: string): Promise<Work[]> { 168 const response = await this.agent.com.atproto.repo.listRecords({ 169 repo: did, 170 - collection: `${LEXICON_PREFIX}.document.work`, 171 }); 172 - return response.data.records.map((r) => r.value as unknown as Work); 173 } 174 175 async createWork(work: Omit<Work, 'createdAt'>): Promise<string> { 176 const rkey = TID.nextStr(); 177 await this.agent.com.atproto.repo.putRecord({ 178 repo: this.agent.session?.did || '', 179 - collection: `${LEXICON_PREFIX}.document.work`, 180 rkey, 181 record: { 182 ...work, 183 createdAt: new Date().toISOString(), 184 }, ··· 186 return rkey; 187 } 188 189 async deleteWork(rkey: string) { 190 return this.agent.com.atproto.repo.deleteRecord({ 191 repo: this.agent.session?.did || '', 192 - collection: `${LEXICON_PREFIX}.document.work`, 193 rkey, 194 }); 195 } ··· 198 async listEvents(did: string): Promise<Event[]> { 199 const response = await this.agent.com.atproto.repo.listRecords({ 200 repo: did, 201 - collection: `${LEXICON_PREFIX}.event.academic`, 202 }); 203 return response.data.records.map((r) => r.value as unknown as Event); 204 } ··· 207 const rkey = TID.nextStr(); 208 await this.agent.com.atproto.repo.putRecord({ 209 repo: this.agent.session?.did || '', 210 - collection: `${LEXICON_PREFIX}.event.academic`, 211 rkey, 212 record: { 213 ...event, 214 createdAt: new Date().toISOString(), 215 }, ··· 220 async updateEvent(rkey: string, updates: Partial<Event>) { 221 const record = await this.agent.com.atproto.repo.getRecord({ 222 repo: this.agent.session?.did || '', 223 - collection: `${LEXICON_PREFIX}.event.academic`, 224 rkey, 225 }); 226 227 return this.agent.com.atproto.repo.putRecord({ 228 repo: this.agent.session?.did || '', 229 - collection: `${LEXICON_PREFIX}.event.academic`, 230 rkey, 231 record: { 232 ...record.data.value, 233 ...updates, 234 }, 235 }); 236 } ··· 238 async deleteEvent(rkey: string) { 239 return this.agent.com.atproto.repo.deleteRecord({ 240 repo: this.agent.session?.did || '', 241 - collection: `${LEXICON_PREFIX}.event.academic`, 242 rkey, 243 }); 244 }
··· 1 /** 2 + * Repository pattern for managing profile data in PDS 3 * This provides an abstraction layer for CRUD operations on AT Protocol records 4 */ 5 6 import { AtpAgent } from '@atproto/api'; 7 import { TID } from '@atproto/common'; 8 import type { 9 + Profile, 10 Affiliation, 11 Link as WebLink, 12 Work, 13 Event, 14 } from '@/types'; 15 16 const LEXICON_PREFIX = 'at.lanyard'; 17 18 + export class ProfileRepository { 19 constructor(private agent: AtpAgent) {} 20 21 // Profile operations 22 + async getProfile(did: string): Promise<Profile | null> { 23 try { 24 const response = await this.agent.com.atproto.repo.getRecord({ 25 repo: did, 26 + collection: `${LEXICON_PREFIX}.profile`, 27 rkey: 'self', 28 }); 29 + return response.data.value as unknown as Profile; 30 } catch { 31 return null; 32 } 33 } 34 35 + async createProfile(profile: Omit<Profile, 'createdAt'>) { 36 return this.agent.com.atproto.repo.putRecord({ 37 repo: this.agent.session?.did || '', 38 + collection: `${LEXICON_PREFIX}.profile`, 39 rkey: 'self', 40 record: { 41 + $type: `${LEXICON_PREFIX}.profile`, 42 ...profile, 43 createdAt: new Date().toISOString(), 44 }, 45 }); 46 } 47 48 + async updateProfile(updates: Partial<Profile>) { 49 const current = await this.getProfile(this.agent.session?.did || ''); 50 if (!current) { 51 throw new Error('Profile not found'); ··· 53 54 return this.agent.com.atproto.repo.putRecord({ 55 repo: this.agent.session?.did || '', 56 + collection: `${LEXICON_PREFIX}.profile`, 57 rkey: 'self', 58 record: { 59 ...current, 60 ...updates, 61 + $type: `${LEXICON_PREFIX}.profile`, 62 updatedAt: new Date().toISOString(), 63 }, 64 }); ··· 68 async listAffiliations(did: string): Promise<Affiliation[]> { 69 const response = await this.agent.com.atproto.repo.listRecords({ 70 repo: did, 71 + collection: `${LEXICON_PREFIX}.affiliation`, 72 }); 73 return response.data.records.map((r) => r.value as unknown as Affiliation); 74 } ··· 79 const rkey = TID.nextStr(); 80 await this.agent.com.atproto.repo.putRecord({ 81 repo: this.agent.session?.did || '', 82 + collection: `${LEXICON_PREFIX}.affiliation`, 83 rkey, 84 record: { 85 + $type: `${LEXICON_PREFIX}.affiliation`, 86 ...affiliation, 87 createdAt: new Date().toISOString(), 88 }, ··· 93 async updateAffiliation(rkey: string, updates: Partial<Affiliation>) { 94 const record = await this.agent.com.atproto.repo.getRecord({ 95 repo: this.agent.session?.did || '', 96 + collection: `${LEXICON_PREFIX}.affiliation`, 97 rkey, 98 }); 99 100 return this.agent.com.atproto.repo.putRecord({ 101 repo: this.agent.session?.did || '', 102 + collection: `${LEXICON_PREFIX}.affiliation`, 103 rkey, 104 record: { 105 ...record.data.value, 106 ...updates, 107 + $type: `${LEXICON_PREFIX}.affiliation`, 108 }, 109 }); 110 } ··· 112 async deleteAffiliation(rkey: string) { 113 return this.agent.com.atproto.repo.deleteRecord({ 114 repo: this.agent.session?.did || '', 115 + collection: `${LEXICON_PREFIX}.affiliation`, 116 rkey, 117 }); 118 } ··· 133 collection: `${LEXICON_PREFIX}.link`, 134 rkey, 135 record: { 136 + $type: `${LEXICON_PREFIX}.link`, 137 ...link, 138 createdAt: new Date().toISOString(), 139 }, ··· 155 record: { 156 ...record.data.value, 157 ...updates, 158 + $type: `${LEXICON_PREFIX}.link`, 159 }, 160 }); 161 } ··· 169 } 170 171 // Work operations 172 + async listWorks(did: string): Promise<(Work & { rkey: string })[]> { 173 const response = await this.agent.com.atproto.repo.listRecords({ 174 repo: did, 175 + collection: `${LEXICON_PREFIX}.work`, 176 }); 177 + return response.data.records.map((r) => ({ 178 + ...(r.value as unknown as Work), 179 + rkey: r.uri.split('/').pop() || '', 180 + })); 181 } 182 183 async createWork(work: Omit<Work, 'createdAt'>): Promise<string> { 184 const rkey = TID.nextStr(); 185 await this.agent.com.atproto.repo.putRecord({ 186 repo: this.agent.session?.did || '', 187 + collection: `${LEXICON_PREFIX}.work`, 188 rkey, 189 record: { 190 + $type: `${LEXICON_PREFIX}.work`, 191 ...work, 192 createdAt: new Date().toISOString(), 193 }, ··· 195 return rkey; 196 } 197 198 + async updateWork(rkey: string, updates: Partial<Work>) { 199 + const record = await this.agent.com.atproto.repo.getRecord({ 200 + repo: this.agent.session?.did || '', 201 + collection: `${LEXICON_PREFIX}.work`, 202 + rkey, 203 + }); 204 + 205 + return this.agent.com.atproto.repo.putRecord({ 206 + repo: this.agent.session?.did || '', 207 + collection: `${LEXICON_PREFIX}.work`, 208 + rkey, 209 + record: { 210 + ...record.data.value, 211 + ...updates, 212 + $type: `${LEXICON_PREFIX}.work`, 213 + }, 214 + }); 215 + } 216 + 217 async deleteWork(rkey: string) { 218 return this.agent.com.atproto.repo.deleteRecord({ 219 repo: this.agent.session?.did || '', 220 + collection: `${LEXICON_PREFIX}.work`, 221 rkey, 222 }); 223 } ··· 226 async listEvents(did: string): Promise<Event[]> { 227 const response = await this.agent.com.atproto.repo.listRecords({ 228 repo: did, 229 + collection: `${LEXICON_PREFIX}.event`, 230 }); 231 return response.data.records.map((r) => r.value as unknown as Event); 232 } ··· 235 const rkey = TID.nextStr(); 236 await this.agent.com.atproto.repo.putRecord({ 237 repo: this.agent.session?.did || '', 238 + collection: `${LEXICON_PREFIX}.event`, 239 rkey, 240 record: { 241 + $type: `${LEXICON_PREFIX}.event`, 242 ...event, 243 createdAt: new Date().toISOString(), 244 }, ··· 249 async updateEvent(rkey: string, updates: Partial<Event>) { 250 const record = await this.agent.com.atproto.repo.getRecord({ 251 repo: this.agent.session?.did || '', 252 + collection: `${LEXICON_PREFIX}.event`, 253 rkey, 254 }); 255 256 return this.agent.com.atproto.repo.putRecord({ 257 repo: this.agent.session?.did || '', 258 + collection: `${LEXICON_PREFIX}.event`, 259 rkey, 260 record: { 261 ...record.data.value, 262 ...updates, 263 + $type: `${LEXICON_PREFIX}.event`, 264 }, 265 }); 266 } ··· 268 async deleteEvent(rkey: string) { 269 return this.agent.com.atproto.repo.deleteRecord({ 270 repo: this.agent.session?.did || '', 271 + collection: `${LEXICON_PREFIX}.event`, 272 rkey, 273 }); 274 }
+5 -6
src/types/index.ts
··· 4 */ 5 6 import type { 7 - AtLanyardResearcher, 8 AtLanyardWork, 9 AtLanyardEvent, 10 AtLanyardLink, ··· 14 } from './generated'; 15 16 // Main record types 17 - export type Researcher = AtLanyardResearcher.Record; 18 export type Work = AtLanyardWork.Record; 19 export type Event = AtLanyardEvent.Record; 20 export type Link = AtLanyardLink.Record; ··· 22 export type Publication = AtLanyardPublication.Main; 23 export type Location = AtLanyardLocation.Main; 24 25 - // Nested types 26 - export type Affiliation = AtLanyardResearcher.Affiliation; 27 - 28 // Convenience type aliases for enums and unions 29 - export type Honorific = 'Dr' | 'Prof'; 30 export type WorkType = Work['type']; 31 export type EventType = Event['type']; 32 export type LinkType = Link['type'];
··· 4 */ 5 6 import type { 7 + AtLanyardProfile, 8 + AtLanyardAffiliation, 9 AtLanyardWork, 10 AtLanyardEvent, 11 AtLanyardLink, ··· 15 } from './generated'; 16 17 // Main record types 18 + export type Profile = AtLanyardProfile.Record; 19 + export type Affiliation = AtLanyardAffiliation.Record; 20 export type Work = AtLanyardWork.Record; 21 export type Event = AtLanyardEvent.Record; 22 export type Link = AtLanyardLink.Record; ··· 24 export type Publication = AtLanyardPublication.Main; 25 export type Location = AtLanyardLocation.Main; 26 27 // Convenience type aliases for enums and unions 28 + export type Honorific = 'none' | 'Dr' | 'Prof'; 29 export type WorkType = Work['type']; 30 export type EventType = Event['type']; 31 export type LinkType = Link['type'];