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

Stashing WIP for the weekend...

Changed files
+1448 -542
.docs
lexicons
affiliation
profile
researcher
work
scripts
src
app
[handle]
api
auth
login
profile
affiliations
basics
events
links
works
auth
dashboard
events
links
profile
edit
research
create
edit
components
lib
types
+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 42 *~ 43 43 44 44 # mine 45 - .docs 45 + # .docs 46 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 51 "maxLength": 200, 52 52 "description": "Journal or publication venue (fetched from DOI metadata)" 53 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 + }, 54 64 "publication": { 55 65 "type": "ref", 56 66 "ref": "at.lanyard.publication#main",
+2
next.config.ts
··· 16 16 'thread-stream', 17 17 'sonic-boom', 18 18 '@atproto/common', 19 + '@atproto/xrpc', 20 + '@atproto/lexicon', 19 21 'multiformats', 20 22 ], 21 23 typescript: {
+2 -2
package.json
··· 3 3 "version": "0.1.0", 4 4 "private": true, 5 5 "scripts": { 6 - "dev": "next dev", 6 + "dev": "npm run lex:gen && next dev", 7 7 "build": "npm run lex:gen && next build", 8 8 "start": "next start", 9 9 "lint": "next lint", 10 10 "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"", 11 - "lex:gen": "lex gen-api ./src/types/generated ./lexicons/**/*.json", 11 + "lex:gen": "lex gen-api ./src/types/generated ./lexicons/**/*.json && node scripts/fix-generated-imports.js", 12 12 "lex:watch": "lex gen-api --watch ./src/types/generated ./lexicons/**/*.json" 13 13 }, 14 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'; 1 + import { ProfileRepository } from '@/lib/data/repository'; 3 2 import ProfileView from '@/components/profile/ProfileView'; 3 + import { getServerAgent, getPublicAgent } from '@/lib/auth/server-agent'; 4 + import { getSession } from '@/lib/auth/session'; 4 5 5 6 interface PageProps { 6 7 params: Promise<{ ··· 11 12 export default async function ProfilePage({ params }: PageProps) { 12 13 const { handle } = await params; 13 14 14 - // Resolve handle to DID 15 - const agent = new AtpAgent({ service: 'https://bsky.social' }); 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 + } 16 38 17 39 try { 18 - const resolved = await agent.resolveHandle({ handle }); 40 + // Use public agent for resolving handle (doesn't require auth) 41 + const publicAgent = getPublicAgent(); 42 + const resolved = await publicAgent.resolveHandle({ handle }); 19 43 const did = resolved.data.did; 20 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 + 21 52 // Get Bluesky profile 22 53 const bskyProfile = await agent.getProfile({ actor: did }); 23 54 24 55 // Get Lanyards profile 25 - const repo = new ResearcherRepository(agent); 56 + const repo = new ProfileRepository(agent); 26 57 const lanyardProfile = await repo.getProfile(did); 27 58 28 59 if (!lanyardProfile) { ··· 55 86 ...lanyardProfile, 56 87 displayName: bskyProfile.data.displayName, 57 88 avatar: bskyProfile.data.avatar, 89 + banner: bskyProfile.data.banner, 58 90 description: bskyProfile.data.description, 59 91 }} 60 92 affiliations={affiliations} 61 93 webLinks={webLinks} 62 94 works={works} 63 95 events={events} 96 + isOwner={isOwner} 64 97 /> 65 98 ); 66 99 } catch (error) {
+28 -54
src/app/api/auth/login/route.ts
··· 1 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'; 2 + import { loginWithAppPassword } from '@/lib/auth/app-password'; 8 3 import { createSession } from '@/lib/auth/session'; 9 4 10 5 export async function POST(request: NextRequest) { 11 6 try { 12 - const authMethod = getAuthMethod(); 7 + const body = await request.json(); 8 + const { identifier, password, pdsUrl } = body; 13 9 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 10 + // Validate required fields 11 + if (!identifier || !password) { 12 + return NextResponse.json( 13 + { error: 'Username and password are required' }, 14 + { status: 400 } 32 15 ); 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 - }); 16 + } 42 17 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; 18 + // Login with app password 19 + const session = await loginWithAppPassword( 20 + identifier, 21 + password, 22 + pdsUrl || 'https://bsky.social' 23 + ); 51 24 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); 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 + }); 61 33 62 - return NextResponse.json({ authUrl }); 63 - } 34 + return NextResponse.json({ 35 + success: true, 36 + redirect: '/dashboard', 37 + }); 64 38 } catch (error) { 65 39 console.error('Login error:', error); 66 40 return NextResponse.json( 67 41 { 68 42 error: 69 - error instanceof Error ? error.message : 'Failed to initiate login', 43 + error instanceof Error ? error.message : 'Failed to login', 70 44 }, 71 45 { status: 500 } 72 46 );
+2 -2
src/app/api/profile/affiliations/route.ts
··· 1 1 import { NextRequest, NextResponse } from 'next/server'; 2 2 import { getAgent } from '@/lib/auth/atproto'; 3 - import { ResearcherRepository } from '@/lib/data/repository'; 3 + import { ProfileRepository } from '@/lib/data/repository'; 4 4 5 5 export async function POST(request: NextRequest) { 6 6 try { ··· 12 12 13 13 const affiliation = await request.json(); 14 14 15 - const repo = new ResearcherRepository(agent); 15 + const repo = new ProfileRepository(agent); 16 16 const rkey = await repo.createAffiliation(affiliation); 17 17 18 18 return NextResponse.json({ success: true, rkey });
+4 -4
src/app/api/profile/basics/route.ts
··· 1 1 import { NextRequest, NextResponse } from 'next/server'; 2 2 import { getAgent } from '@/lib/auth/atproto'; 3 - import { ResearcherRepository } from '@/lib/data/repository'; 3 + import { ProfileRepository } from '@/lib/data/repository'; 4 4 5 5 export async function PUT(request: NextRequest) { 6 6 try { ··· 11 11 } 12 12 13 13 const body = await request.json(); 14 - const { honorifics, location } = body; 14 + const { honorific, location } = body; 15 15 16 - const repo = new ResearcherRepository(agent); 16 + const repo = new ProfileRepository(agent); 17 17 await repo.updateProfile({ 18 - honorifics, 18 + honorific, 19 19 location, 20 20 }); 21 21
+57 -2
src/app/api/profile/events/route.ts
··· 1 1 import { NextRequest, NextResponse } from 'next/server'; 2 2 import { getAgent } from '@/lib/auth/atproto'; 3 - import { ResearcherRepository } from '@/lib/data/repository'; 3 + import { ProfileRepository } from '@/lib/data/repository'; 4 4 5 5 export async function POST(request: NextRequest) { 6 6 try { ··· 12 12 13 13 const event = await request.json(); 14 14 15 - const repo = new ResearcherRepository(agent); 15 + const repo = new ProfileRepository(agent); 16 16 const rkey = await repo.createEvent(event); 17 17 18 18 return NextResponse.json({ success: true, rkey }); ··· 24 24 ); 25 25 } 26 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 1 import { NextRequest, NextResponse } from 'next/server'; 2 2 import { getAgent } from '@/lib/auth/atproto'; 3 - import { ResearcherRepository } from '@/lib/data/repository'; 3 + import { ProfileRepository } from '@/lib/data/repository'; 4 4 5 5 export async function POST(request: NextRequest) { 6 6 try { ··· 12 12 13 13 const link = await request.json(); 14 14 15 - const repo = new ResearcherRepository(agent); 15 + const repo = new ProfileRepository(agent); 16 16 const rkey = await repo.createWebLink(link); 17 17 18 18 return NextResponse.json({ success: true, rkey }); ··· 23 23 return NextResponse.json({ error: message }, { status: 500 }); 24 24 } 25 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 1 import { NextRequest, NextResponse } from 'next/server'; 2 2 import { getAgent } from '@/lib/auth/atproto'; 3 - import { ResearcherRepository } from '@/lib/data/repository'; 3 + import { ProfileRepository } from '@/lib/data/repository'; 4 4 import { resolveDOI } from '@/lib/data/doi'; 5 5 6 6 export async function POST(request: NextRequest) { ··· 11 11 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 12 12 } 13 13 14 - const { doi, type } = await request.json(); 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 + } 15 33 16 34 // Resolve DOI metadata 17 35 const metadata = await resolveDOI(doi); ··· 22 40 title: metadata?.title, 23 41 authors: metadata?.authors, 24 42 publicationDate: metadata?.publicationDate, 25 - journal: metadata?.journal, 26 - metadata: metadata as Record<string, unknown> | undefined, 43 + venue: metadata?.journal, 44 + abstract: metadata?.abstract, 45 + url: metadata?.url, 27 46 }; 28 47 29 - const repo = new ResearcherRepository(agent); 30 48 const rkey = await repo.createWork(work); 31 49 32 50 return NextResponse.json({ success: true, rkey }); ··· 38 56 ); 39 57 } 40 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 5 <main className="flex min-h-screen flex-col items-center justify-center p-6 bg-gray-50"> 6 6 <div className="w-full max-w-md"> 7 7 <div className="text-center mb-8"> 8 - <h1 className="text-4xl font-bold mb-2">Lanyard</h1> 8 + <h1 className="text-4xl font-bold mb-2">Lanyards</h1> 9 9 <p className="text-gray-600"> 10 - Sign in with your Bluesky account to get started 10 + Sign in with your Bluesky app password 11 11 </p> 12 12 </div> 13 13 ··· 25 25 className="text-blue-600 hover:underline" 26 26 > 27 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 28 39 </a> 29 40 </p> 30 41 </div>
+8 -7
src/app/dashboard/events/edit/page.tsx
··· 1 1 import { redirect } from 'next/navigation'; 2 2 import { getSession } from '@/lib/auth/session'; 3 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 5 import EventForm from '@/components/events/EventForm'; 6 6 import Link from 'next/link'; 7 7 8 8 export default async function EditEventPage({ 9 9 searchParams, 10 10 }: { 11 - searchParams: { id?: string }; 11 + searchParams: Promise<{ rkey?: string }>; 12 12 }) { 13 13 const session = await getSession(); 14 14 ··· 16 16 redirect('/auth'); 17 17 } 18 18 19 - if (searchParams.id === undefined) { 19 + const params = await searchParams; 20 + 21 + if (!params.rkey) { 20 22 redirect('/dashboard/events'); 21 23 } 22 24 ··· 26 28 redirect('/auth'); 27 29 } 28 30 29 - const repo = new ResearcherRepository(agent); 31 + const repo = new ProfileRepository(agent); 30 32 const events = await repo.listEvents(session.did); 31 33 32 - const eventIndex = parseInt(searchParams.id, 10); 33 - const event = events[eventIndex]; 34 + const event = events.find((e) => e.rkey === params.rkey); 34 35 35 36 if (!event) { 36 37 redirect('/dashboard/events'); ··· 67 68 68 69 {/* Content */} 69 70 <div className="max-w-2xl mx-auto px-4 py-6"> 70 - <EventForm mode="edit" initialData={event} eventIndex={eventIndex} /> 71 + <EventForm mode="edit" initialData={event} /> 71 72 </div> 72 73 </main> 73 74 );
+19 -17
src/app/dashboard/events/page.tsx
··· 1 1 import { redirect } from 'next/navigation'; 2 2 import { getSession } from '@/lib/auth/session'; 3 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 5 import Link from 'next/link'; 6 6 7 7 export default async function EventsPage() { ··· 17 17 redirect('/auth'); 18 18 } 19 19 20 - const repo = new ResearcherRepository(agent); 20 + const repo = new ProfileRepository(agent); 21 21 const events = await repo.listEvents(session.did); 22 22 23 23 return ( ··· 44 44 </svg> 45 45 Back 46 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> 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> 51 61 </div> 52 62 </div> 53 63 ··· 86 96 ) : ( 87 97 // Events list 88 98 <div className="space-y-4"> 89 - {events.map((event, index) => { 99 + {events.map((event) => { 90 100 const startDate = new Date(event.startDate); 91 101 const endDate = event.endDate ? new Date(event.endDate) : null; 92 102 const isUpcoming = startDate > new Date(); 93 103 94 104 return ( 95 105 <div 96 - key={index} 106 + key={event.rkey} 97 107 className="bg-white rounded-lg p-4 shadow-sm" 98 108 > 99 109 <div className="flex justify-between items-start gap-3"> ··· 175 185 </div> 176 186 </div> 177 187 <Link 178 - href={`/dashboard/events/edit?id=${index}`} 188 + href={`/dashboard/events/edit?rkey=${encodeURIComponent(event.rkey)}`} 179 189 className="text-sm text-gray-600 hover:text-gray-900" 180 190 > 181 191 Edit ··· 184 194 </div> 185 195 ); 186 196 })} 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 197 </div> 196 198 )} 197 199 </div>
+2 -2
src/app/dashboard/links/edit/page.tsx
··· 1 1 import { redirect } from 'next/navigation'; 2 2 import { getSession } from '@/lib/auth/session'; 3 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 5 import LinkForm from '@/components/links/LinkForm'; 6 6 import Link from 'next/link'; 7 7 ··· 26 26 redirect('/auth'); 27 27 } 28 28 29 - const repo = new ResearcherRepository(agent); 29 + const repo = new ProfileRepository(agent); 30 30 const links = await repo.listWebLinks(session.did); 31 31 32 32 const linkIndex = parseInt(searchParams.id, 10);
+19 -17
src/app/dashboard/links/page.tsx
··· 1 1 import { redirect } from 'next/navigation'; 2 2 import { getSession } from '@/lib/auth/session'; 3 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 5 import Link from 'next/link'; 6 6 7 7 const PLATFORM_ICONS: Record<string, string> = { ··· 39 39 redirect('/auth'); 40 40 } 41 41 42 - const repo = new ResearcherRepository(agent); 42 + const repo = new ProfileRepository(agent); 43 43 const links = await repo.listWebLinks(session.did); 44 44 45 45 return ( ··· 66 66 </svg> 67 67 Back 68 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> 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> 73 83 </div> 74 84 </div> 75 85 ··· 108 118 ) : ( 109 119 // Links list 110 120 <div className="space-y-4"> 111 - {links.map((link, index) => { 121 + {links.map((link) => { 112 122 const platformIcon = 113 123 PLATFORM_ICONS[link.platform || 'custom'] || '🔗'; 114 124 const platformName = ··· 116 126 117 127 return ( 118 128 <div 119 - key={index} 129 + key={link.rkey} 120 130 className="bg-white rounded-lg p-4 shadow-sm" 121 131 > 122 132 <div className="flex justify-between items-start gap-3"> ··· 168 178 </div> 169 179 {!link.isLocked && ( 170 180 <Link 171 - href={`/dashboard/links/edit?id=${index}`} 181 + href={`/dashboard/links/edit?rkey=${encodeURIComponent(link.rkey)}`} 172 182 className="text-sm text-gray-600 hover:text-gray-900" 173 183 > 174 184 Edit ··· 178 188 </div> 179 189 ); 180 190 })} 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 191 </div> 190 192 )} 191 193 </div>
+2 -2
src/app/dashboard/page.tsx
··· 1 1 import { redirect } from 'next/navigation'; 2 2 import { getSession } from '@/lib/auth/session'; 3 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 5 import Link from 'next/link'; 6 6 import ShareProfileButton from '@/components/profile/ShareProfileButton'; 7 7 ··· 18 18 redirect('/auth'); 19 19 } 20 20 21 - const repo = new ResearcherRepository(agent); 21 + const repo = new ProfileRepository(agent); 22 22 23 23 try { 24 24 // Get profile
+16 -6
src/app/dashboard/profile/edit/page.tsx
··· 1 1 import { redirect } from 'next/navigation'; 2 2 import { getSession } from '@/lib/auth/session'; 3 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 5 import ProfileForm from '@/components/profile/ProfileForm'; 6 6 import Link from 'next/link'; 7 7 ··· 18 18 redirect('/auth'); 19 19 } 20 20 21 - const repo = new ResearcherRepository(agent); 21 + const repo = new ProfileRepository(agent); 22 22 const profile = await repo.getProfile(session.did); 23 23 24 24 if (!profile) { ··· 49 49 </svg> 50 50 Back 51 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> 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> 56 66 </div> 57 67 </div> 58 68
+2 -2
src/app/dashboard/research/create/page.tsx
··· 34 34 </svg> 35 35 Back 36 36 </Link> 37 - <h1 className="text-xl font-bold">Add Publication</h1> 37 + <h1 className="text-xl font-bold">Add Research</h1> 38 38 <p className="text-sm text-gray-600 mt-1"> 39 - Enter a DOI to add a publication to your profile 39 + Enter a DOI to add research to your profile 40 40 </p> 41 41 </div> 42 42 </div>
+10 -45
src/app/dashboard/research/edit/page.tsx
··· 1 1 import { redirect } from 'next/navigation'; 2 2 import { getSession } from '@/lib/auth/session'; 3 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'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 + import EditResearchClient from '@/components/research/EditResearchClient'; 7 6 8 7 export default async function EditResearchPage({ 9 8 searchParams, 10 9 }: { 11 - searchParams: { doi?: string }; 10 + searchParams: Promise<{ rkey?: string }>; 12 11 }) { 13 12 const session = await getSession(); 14 13 ··· 16 15 redirect('/auth'); 17 16 } 18 17 19 - if (!searchParams.doi) { 18 + const params = await searchParams; 19 + 20 + if (!params.rkey) { 20 21 redirect('/dashboard/research'); 21 22 } 22 23 ··· 26 27 redirect('/auth'); 27 28 } 28 29 29 - const repo = new ResearcherRepository(agent); 30 + const repo = new ProfileRepository(agent); 30 31 const works = await repo.listWorks(session.did); 31 32 32 - // Find the work with the matching DOI 33 - const work = works.find((w) => w.doi === searchParams.doi); 33 + // Find the work with the matching rkey 34 + const work = works.find((w) => w.rkey === params.rkey); 34 35 35 36 if (!work) { 36 37 redirect('/dashboard/research'); 37 38 } 38 39 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 - ); 40 + return <EditResearchClient work={work} />; 76 41 }
+22 -20
src/app/dashboard/research/page.tsx
··· 1 1 import { redirect } from 'next/navigation'; 2 2 import { getSession } from '@/lib/auth/session'; 3 3 import { getAgent } from '@/lib/auth/atproto'; 4 - import { ResearcherRepository } from '@/lib/data/repository'; 4 + import { ProfileRepository } from '@/lib/data/repository'; 5 5 import Link from 'next/link'; 6 6 7 7 export default async function ResearchPage() { ··· 17 17 redirect('/auth'); 18 18 } 19 19 20 - const repo = new ResearcherRepository(agent); 20 + const repo = new ProfileRepository(agent); 21 21 const works = await repo.listWorks(session.did); 22 22 23 23 return ( ··· 44 44 </svg> 45 45 Back 46 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> 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> 51 61 </div> 52 62 </div> 53 63 ··· 71 81 /> 72 82 </svg> 73 83 </div> 74 - <h2 className="text-lg font-semibold mb-2">No publications yet</h2> 84 + <h2 className="text-lg font-semibold mb-2">No research yet</h2> 75 85 <p className="text-gray-600 text-sm mb-6"> 76 - Add your first publication by entering its DOI. We&apos;ll 86 + Add your first research item by entering its DOI. We&apos;ll 77 87 automatically fetch the metadata from CrossRef. 78 88 </p> 79 89 <Link 80 90 href="/dashboard/research/create" 81 91 className="inline-block bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 transition-colors" 82 92 > 83 - Add Publication 93 + Add Research 84 94 </Link> 85 95 </div> 86 96 ) : ( ··· 88 98 <div className="space-y-4"> 89 99 {works.map((work) => ( 90 100 <div 91 - key={work.doi} 101 + key={work.rkey} 92 102 className="bg-white rounded-lg p-4 shadow-sm" 93 103 > 94 104 <div className="flex justify-between items-start gap-3"> ··· 112 122 )} 113 123 {work.publicationDate && ( 114 124 <span className="text-xs text-gray-500"> 115 - {new Date(work.publicationDate).getFullYear()} 125 + Published: {new Date(work.publicationDate).getFullYear()} 116 126 </span> 117 127 )} 118 128 </div> ··· 126 136 </a> 127 137 </div> 128 138 <Link 129 - href={`/dashboard/research/edit?doi=${encodeURIComponent(work.doi)}`} 139 + href={`/dashboard/research/edit?rkey=${encodeURIComponent(work.rkey)}`} 130 140 className="text-sm text-gray-600 hover:text-gray-900" 131 141 > 132 142 Edit ··· 134 144 </div> 135 145 </div> 136 146 ))} 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 147 </div> 146 148 )} 147 149 </div>
+80 -56
src/components/auth/LoginForm.tsx
··· 1 1 'use client'; 2 2 3 - import { useState, useEffect } from 'react'; 3 + import { useState } from 'react'; 4 4 5 5 export default function LoginForm() { 6 - const [handle, setHandle] = useState(''); 6 + const [identifier, setIdentifier] = useState(''); 7 + const [password, setPassword] = useState(''); 8 + const [pdsUrl, setPdsUrl] = useState('https://bsky.social'); 7 9 const [loading, setLoading] = useState(false); 8 10 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 11 24 12 const handleSubmit = async (e: React.FormEvent) => { 25 13 e.preventDefault(); ··· 32 20 headers: { 33 21 'Content-Type': 'application/json', 34 22 }, 35 - body: JSON.stringify({ handle }), 23 + body: JSON.stringify({ identifier, password, pdsUrl }), 36 24 }); 37 25 38 26 const data = await response.json(); ··· 41 29 throw new Error(data.error || 'Failed to login'); 42 30 } 43 31 44 - // Handle different response types 32 + // Redirect to dashboard on successful login 45 33 if (data.redirect) { 46 - // App password mode - direct redirect 47 34 window.location.href = data.redirect; 48 - } else if (data.authUrl) { 49 - // OAuth mode - redirect to authorization URL 50 - window.location.href = data.authUrl; 51 35 } 52 36 } catch (err) { 53 37 setError(err instanceof Error ? err.message : 'An error occurred'); ··· 57 41 58 42 return ( 59 43 <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" 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" 65 92 > 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 - )} 93 + Bluesky settings 94 + </a> 95 + </p> 96 + </div> 83 97 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 - )} 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> 94 118 95 119 {error && ( 96 120 <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm"> ··· 100 124 101 125 <button 102 126 type="submit" 103 - disabled={loading || (authMethod === 'oauth' && !handle)} 127 + disabled={loading || !identifier || !password} 104 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" 105 129 > 106 - {loading ? 'Connecting...' : 'Sign in with Bluesky'} 130 + {loading ? 'Signing in...' : 'Sign in'} 107 131 </button> 108 132 109 133 <p className="text-xs text-gray-500 text-center">
+32 -23
src/components/events/EventForm.tsx
··· 6 6 7 7 interface EventFormProps { 8 8 mode: 'create' | 'edit'; 9 - initialData?: Event; 10 - eventIndex?: number; 9 + initialData?: Event & { rkey?: string }; 11 10 } 12 11 13 12 const EVENT_TYPES: { value: EventType; label: string }[] = [ ··· 24 23 export default function EventForm({ 25 24 mode, 26 25 initialData, 27 - eventIndex, 28 26 }: EventFormProps) { 29 27 const router = useRouter(); 30 28 const [loading, setLoading] = useState(false); ··· 42 40 city: initialData?.location?.city || '', 43 41 country: initialData?.location?.country || '', 44 42 url: initialData?.url || '', 43 + rkey: initialData?.rkey, 45 44 }); 46 45 47 46 const handleSubmit = async (e: React.FormEvent) => { ··· 50 49 setError(''); 51 50 52 51 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 - }; 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 + }; 66 80 67 - const url = 68 - mode === 'edit' 69 - ? `/api/profile/events?index=${eventIndex}` 70 - : '/api/profile/events'; 71 - 72 - const response = await fetch(url, { 81 + const response = await fetch('/api/profile/events', { 73 82 method: mode === 'create' ? 'POST' : 'PUT', 74 83 headers: { 75 84 'Content-Type': 'application/json', ··· 100 109 101 110 try { 102 111 const response = await fetch( 103 - `/api/profile/events?index=${eventIndex}`, 112 + `/api/profile/events?rkey=${encodeURIComponent(formData.rkey || '')}`, 104 113 { 105 114 method: 'DELETE', 106 115 }
+78 -59
src/components/profile/ProfileForm.tsx
··· 2 2 3 3 import { useState } from 'react'; 4 4 import { useRouter } from 'next/navigation'; 5 - import type { Researcher } from '@/types'; 5 + import Image from 'next/image'; 6 + import type { Profile, Honorific } from '@/types'; 6 7 7 8 interface ProfileFormProps { 8 - profile: Researcher; 9 + profile: Profile; 9 10 } 10 11 11 - const HONORIFIC_OPTIONS = [ 12 - { value: 'Dr', label: 'Dr' }, 13 - { value: 'Prof', label: 'Prof' }, 12 + const HONORIFIC_OPTIONS: { value: Honorific; label: string }[] = [ 13 + { value: 'none', label: 'None' }, 14 + { value: 'Dr', label: 'Dr.' }, 15 + { value: 'Prof', label: 'Prof.' }, 14 16 ]; 15 17 16 18 export default function ProfileForm({ profile }: ProfileFormProps) { ··· 19 21 const [error, setError] = useState(''); 20 22 21 23 const [formData, setFormData] = useState({ 22 - honorifics: profile.honorifics || [], 24 + honorific: profile.honorific || 'none', 23 25 city: profile.location?.city || '', 24 26 country: profile.location?.country || '', 25 27 }); 26 28 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 29 const handleSubmit = async (e: React.FormEvent) => { 43 30 e.preventDefault(); 44 31 setLoading(true); ··· 46 33 47 34 try { 48 35 const payload = { 49 - honorifics: formData.honorifics.length > 0 ? formData.honorifics : undefined, 36 + honorific: formData.honorific, 50 37 location: 51 38 formData.city || formData.country 52 39 ? { ··· 80 67 return ( 81 68 <div className="space-y-4"> 82 69 {/* 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> 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> 86 73 <span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded flex items-center gap-1"> 87 74 <svg 88 75 className="w-3 h-3" ··· 99 86 </svg> 100 87 Locked 101 88 </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'} 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> 110 115 </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} 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> 118 126 </div> 119 - </div> 120 - {profile.description && ( 121 127 <div> 122 128 <label className="block text-sm font-medium text-gray-700 mb-1"> 123 - Bio 129 + Handle 124 130 </label> 125 131 <div className="text-gray-900 bg-gray-50 px-4 py-2 rounded-lg"> 126 - {profile.description} 132 + @{profile.handle} 127 133 </div> 128 134 </div> 129 - )} 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> 130 150 </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 151 </div> 136 152 137 153 {/* Editable Fields Form */} ··· 141 157 <div className="space-y-4"> 142 158 <div> 143 159 <label className="block text-sm font-medium text-gray-700 mb-2"> 144 - Honorifics 160 + Honorific 145 161 </label> 146 - <div className="flex gap-3"> 162 + <div className="flex gap-4"> 147 163 {HONORIFIC_OPTIONS.map((option) => ( 148 164 <label 149 165 key={option.value} 150 166 className="flex items-center gap-2 cursor-pointer" 151 167 > 152 168 <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') 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 + }) 159 178 } 160 - className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" 179 + className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500" 161 180 /> 162 181 <span className="text-sm text-gray-700">{option.label}</span> 163 182 </label> 164 183 ))} 165 184 </div> 166 185 <p className="mt-1 text-sm text-gray-500"> 167 - Select all that apply 186 + Choose one honorific (tradition: use Dr. or Prof., never both) 168 187 </p> 169 188 </div> 170 189
+109 -51
src/components/profile/ProfileView.tsx
··· 3 3 import Image from 'next/image'; 4 4 import QRCodeButton from './QRCodeButton'; 5 5 import type { 6 - Researcher, 6 + Profile, 7 7 Affiliation, 8 8 Link as WebLink, 9 9 Work, ··· 11 11 } from '@/types'; 12 12 13 13 interface ProfileViewProps { 14 - profile: Researcher; 14 + profile: Profile; 15 15 affiliations: Affiliation[]; 16 16 webLinks: WebLink[]; 17 17 works: Work[]; 18 18 events: Event[]; 19 + isOwner?: boolean; 19 20 } 20 21 21 22 export default function ProfileView({ ··· 24 25 webLinks, 25 26 works, 26 27 events, 28 + isOwner = false, 27 29 }: ProfileViewProps) { 28 30 const primaryAffiliation = affiliations.find((a) => a.isPrimary); 29 31 const currentAffiliations = affiliations.filter((a) => !a.endDate); ··· 33 35 const customLinks = webLinks.filter((l) => l.type === 'web'); 34 36 const blueskyProfile = socialLinks.find((s) => s.platform === 'bluesky'); 35 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 + 36 47 return ( 37 48 <main className="min-h-screen bg-gray-50"> 38 49 {/* Header Section - Mobile-first */} 39 50 <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 && ( 51 + <div className="max-w-2xl mx-auto"> 52 + {/* Banner */} 53 + {profile.banner && ( 54 + <div className="relative w-full h-48 bg-gray-200"> 44 55 <Image 45 - src={profile.avatar} 46 - alt={profile.displayName || profile.handle} 47 - width={80} 48 - height={80} 49 - className="rounded-full" 56 + src={profile.banner} 57 + alt="Profile banner" 58 + fill 59 + className="object-cover" 60 + priority 50 61 /> 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 - )} 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> 59 93 </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 94 </div> 65 - </div> 66 95 67 - {/* Primary Action - Follow on Bluesky */} 68 - {blueskyProfile && ( 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 ? ( 69 147 <a 70 148 href={blueskyProfile.url} 71 149 target="_blank" ··· 74 152 > 75 153 Follow on Bluesky 76 154 </a> 77 - )} 155 + ) : null} 78 156 79 157 {/* QR Code Button */} 80 - <div className="mb-4"> 158 + <div> 81 159 <QRCodeButton 82 160 url={`${typeof window !== 'undefined' ? window.location.origin : ''}/${profile.handle}`} 83 161 handle={profile.handle} 84 162 /> 85 163 </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 - )} 164 + </div> 107 165 </div> 108 166 </div> 109 167 ··· 174 232 </section> 175 233 )} 176 234 177 - {/* Works */} 235 + {/* Research */} 178 236 {works.length > 0 && ( 179 237 <section className="bg-white rounded-lg p-4 shadow-sm"> 180 238 <h2 className="text-lg font-semibold mb-3"> 181 - Scholarly Contributions 239 + Research 182 240 </h2> 183 241 <div className="space-y-4"> 184 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 6 7 7 interface ResearchFormProps { 8 8 mode: 'create' | 'edit'; 9 - initialData?: Work; 9 + initialData?: Work & { rkey?: string }; 10 10 } 11 11 12 12 const WORK_TYPES: { value: WorkType; label: string }[] = [ ··· 28 28 const [resolvingDOI, setResolvingDOI] = useState(false); 29 29 const [error, setError] = useState(''); 30 30 const [resolvedMetadata, setResolvedMetadata] = useState<any>(null); 31 + const [showDuplicateWarning, setShowDuplicateWarning] = useState(false); 31 32 32 33 const [formData, setFormData] = useState<{ 33 34 doi: string; 34 35 type: WorkType | ''; 36 + rkey?: string; 35 37 }>({ 36 38 doi: initialData?.doi || '', 37 39 type: initialData?.type || '', 40 + rkey: initialData?.rkey, 38 41 }); 39 42 40 43 const handleResolveDOI = async () => { ··· 62 65 } 63 66 }; 64 67 65 - const handleSubmit = async (e: React.FormEvent) => { 68 + const handleSubmit = async (e: React.FormEvent, bypassDuplicateCheck = false) => { 66 69 e.preventDefault(); 67 70 setLoading(true); 68 71 setError(''); 72 + setShowDuplicateWarning(false); 69 73 70 74 try { 75 + const payload = mode === 'create' 76 + ? { doi: formData.doi, type: formData.type, bypassDuplicateCheck } 77 + : { rkey: formData.rkey, type: formData.type }; 78 + 71 79 const response = await fetch('/api/profile/works', { 72 80 method: mode === 'create' ? 'POST' : 'PUT', 73 81 headers: { 74 82 'Content-Type': 'application/json', 75 83 }, 76 - body: JSON.stringify(formData), 84 + body: JSON.stringify(payload), 77 85 }); 78 86 87 + const data = await response.json(); 88 + 79 89 if (!response.ok) { 80 - const data = await response.json(); 81 - throw new Error(data.error || `Failed to ${mode} work`); 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`); 82 98 } 83 99 84 100 router.push('/dashboard/research'); ··· 89 105 } 90 106 }; 91 107 108 + const handleAddDuplicate = (e: React.FormEvent) => { 109 + handleSubmit(e, true); 110 + }; 111 + 92 112 const handleDelete = async () => { 93 - if (!confirm('Are you sure you want to delete this publication?')) { 113 + if (!confirm('Are you sure you want to delete this research item?')) { 94 114 return; 95 115 } 96 116 97 117 setLoading(true); 98 - setError(''); 99 118 100 119 try { 101 120 const response = await fetch( 102 - `/api/profile/works?doi=${encodeURIComponent(formData.doi)}`, 121 + `/api/profile/works?rkey=${encodeURIComponent(formData.rkey || '')}`, 103 122 { 104 123 method: 'DELETE', 105 124 } ··· 107 126 108 127 if (!response.ok) { 109 128 const data = await response.json(); 110 - throw new Error(data.error || 'Failed to delete work'); 129 + throw new Error(data.error || 'Failed to delete research'); 111 130 } 112 131 113 132 router.push('/dashboard/research'); 114 133 router.refresh(); 115 134 } catch (err) { 116 - setError(err instanceof Error ? err.message : 'An error occurred'); 135 + setError(err instanceof Error ? err.message : 'Failed to delete research'); 117 136 setLoading(false); 118 137 } 119 138 }; 120 139 121 140 return ( 122 - <form onSubmit={handleSubmit} className="bg-white rounded-lg p-6 shadow-sm"> 141 + <form id="research-form" onSubmit={handleSubmit} className="bg-white rounded-lg p-6 shadow-sm"> 123 142 <div className="space-y-4"> 124 143 <div> 125 144 <label className="block text-sm font-medium text-gray-700 mb-2"> ··· 166 185 </p> 167 186 )} 168 187 {resolvedMetadata.authors && ( 169 - <p className="text-sm text-blue-800"> 188 + <p className="text-sm text-blue-800 mb-1"> 170 189 <strong>Authors:</strong> {resolvedMetadata.authors.join(', ')} 171 190 </p> 172 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} 173 266 </div> 174 267 )} 175 268 ··· 196 289 </div> 197 290 198 291 {error && ( 199 - <div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm"> 200 - {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 + )} 201 304 </div> 202 305 )} 203 306 ··· 212 315 ? 'Adding...' 213 316 : 'Saving...' 214 317 : mode === 'create' 215 - ? 'Add Publication' 318 + ? 'Add Research' 216 319 : 'Save Changes'} 217 320 </button> 218 321 219 322 <button 220 323 type="button" 221 324 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" 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" 223 327 > 224 328 Cancel 225 329 </button> ··· 231 335 disabled={loading} 232 336 className="w-full text-red-600 py-2 hover:text-red-700 disabled:text-gray-400" 233 337 > 234 - Delete Publication 338 + Delete Research 235 339 </button> 236 340 )} 237 341 </div>
+5 -2
src/lib/auth/app-password.ts
··· 14 14 15 15 export async function loginWithAppPassword( 16 16 identifier: string, 17 - password: string 17 + password: string, 18 + pdsUrl?: string 18 19 ): Promise<{ 19 20 did: string; 20 21 handle: string; 21 22 accessJwt: string; 22 23 refreshJwt: string; 23 24 }> { 25 + const serviceUrl = pdsUrl || process.env.PDS_URL || 'https://bsky.social'; 26 + 24 27 const agent = new AtpAgent({ 25 - service: process.env.PDS_URL || 'https://bsky.social', 28 + service: serviceUrl, 26 29 }); 27 30 28 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 3 * Fetches metadata from CrossRef and DataCite 4 4 */ 5 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 + 6 17 export interface DOIMetadata { 7 18 title?: string; 8 19 authors?: string[]; ··· 45 56 journal: 46 57 work['container-title']?.[0] || work.publisher || work.institution, 47 58 type: work.type, 48 - abstract: work.abstract, 59 + abstract: work.abstract ? stripJATSTags(work.abstract) : undefined, 49 60 url: work.URL, 50 61 }; 51 62 } catch (error) {
+56 -26
src/lib/data/repository.ts
··· 1 1 /** 2 - * Repository pattern for managing researcher data in PDS 2 + * Repository pattern for managing profile data in PDS 3 3 * This provides an abstraction layer for CRUD operations on AT Protocol records 4 4 */ 5 5 6 6 import { AtpAgent } from '@atproto/api'; 7 7 import { TID } from '@atproto/common'; 8 8 import type { 9 - Researcher, 9 + Profile, 10 10 Affiliation, 11 11 Link as WebLink, 12 - 13 12 Work, 14 13 Event, 15 14 } from '@/types'; 16 15 17 16 const LEXICON_PREFIX = 'at.lanyard'; 18 17 19 - export class ResearcherRepository { 18 + export class ProfileRepository { 20 19 constructor(private agent: AtpAgent) {} 21 20 22 21 // Profile operations 23 - async getProfile(did: string): Promise<Researcher | null> { 22 + async getProfile(did: string): Promise<Profile | null> { 24 23 try { 25 24 const response = await this.agent.com.atproto.repo.getRecord({ 26 25 repo: did, 27 - collection: `${LEXICON_PREFIX}.actor.profile`, 26 + collection: `${LEXICON_PREFIX}.profile`, 28 27 rkey: 'self', 29 28 }); 30 - return response.data.value as unknown as Researcher; 29 + return response.data.value as unknown as Profile; 31 30 } catch { 32 31 return null; 33 32 } 34 33 } 35 34 36 - async createProfile(profile: Omit<Researcher, 'createdAt'>) { 35 + async createProfile(profile: Omit<Profile, 'createdAt'>) { 37 36 return this.agent.com.atproto.repo.putRecord({ 38 37 repo: this.agent.session?.did || '', 39 - collection: `${LEXICON_PREFIX}.actor.profile`, 38 + collection: `${LEXICON_PREFIX}.profile`, 40 39 rkey: 'self', 41 40 record: { 41 + $type: `${LEXICON_PREFIX}.profile`, 42 42 ...profile, 43 43 createdAt: new Date().toISOString(), 44 44 }, 45 45 }); 46 46 } 47 47 48 - async updateProfile(updates: Partial<Researcher>) { 48 + async updateProfile(updates: Partial<Profile>) { 49 49 const current = await this.getProfile(this.agent.session?.did || ''); 50 50 if (!current) { 51 51 throw new Error('Profile not found'); ··· 53 53 54 54 return this.agent.com.atproto.repo.putRecord({ 55 55 repo: this.agent.session?.did || '', 56 - collection: `${LEXICON_PREFIX}.actor.profile`, 56 + collection: `${LEXICON_PREFIX}.profile`, 57 57 rkey: 'self', 58 58 record: { 59 59 ...current, 60 60 ...updates, 61 + $type: `${LEXICON_PREFIX}.profile`, 61 62 updatedAt: new Date().toISOString(), 62 63 }, 63 64 }); ··· 67 68 async listAffiliations(did: string): Promise<Affiliation[]> { 68 69 const response = await this.agent.com.atproto.repo.listRecords({ 69 70 repo: did, 70 - collection: `${LEXICON_PREFIX}.actor.affiliation`, 71 + collection: `${LEXICON_PREFIX}.affiliation`, 71 72 }); 72 73 return response.data.records.map((r) => r.value as unknown as Affiliation); 73 74 } ··· 78 79 const rkey = TID.nextStr(); 79 80 await this.agent.com.atproto.repo.putRecord({ 80 81 repo: this.agent.session?.did || '', 81 - collection: `${LEXICON_PREFIX}.actor.affiliation`, 82 + collection: `${LEXICON_PREFIX}.affiliation`, 82 83 rkey, 83 84 record: { 85 + $type: `${LEXICON_PREFIX}.affiliation`, 84 86 ...affiliation, 85 87 createdAt: new Date().toISOString(), 86 88 }, ··· 91 93 async updateAffiliation(rkey: string, updates: Partial<Affiliation>) { 92 94 const record = await this.agent.com.atproto.repo.getRecord({ 93 95 repo: this.agent.session?.did || '', 94 - collection: `${LEXICON_PREFIX}.actor.affiliation`, 96 + collection: `${LEXICON_PREFIX}.affiliation`, 95 97 rkey, 96 98 }); 97 99 98 100 return this.agent.com.atproto.repo.putRecord({ 99 101 repo: this.agent.session?.did || '', 100 - collection: `${LEXICON_PREFIX}.actor.affiliation`, 102 + collection: `${LEXICON_PREFIX}.affiliation`, 101 103 rkey, 102 104 record: { 103 105 ...record.data.value, 104 106 ...updates, 107 + $type: `${LEXICON_PREFIX}.affiliation`, 105 108 }, 106 109 }); 107 110 } ··· 109 112 async deleteAffiliation(rkey: string) { 110 113 return this.agent.com.atproto.repo.deleteRecord({ 111 114 repo: this.agent.session?.did || '', 112 - collection: `${LEXICON_PREFIX}.actor.affiliation`, 115 + collection: `${LEXICON_PREFIX}.affiliation`, 113 116 rkey, 114 117 }); 115 118 } ··· 130 133 collection: `${LEXICON_PREFIX}.link`, 131 134 rkey, 132 135 record: { 136 + $type: `${LEXICON_PREFIX}.link`, 133 137 ...link, 134 138 createdAt: new Date().toISOString(), 135 139 }, ··· 151 155 record: { 152 156 ...record.data.value, 153 157 ...updates, 158 + $type: `${LEXICON_PREFIX}.link`, 154 159 }, 155 160 }); 156 161 } ··· 164 169 } 165 170 166 171 // Work operations 167 - async listWorks(did: string): Promise<Work[]> { 172 + async listWorks(did: string): Promise<(Work & { rkey: string })[]> { 168 173 const response = await this.agent.com.atproto.repo.listRecords({ 169 174 repo: did, 170 - collection: `${LEXICON_PREFIX}.document.work`, 175 + collection: `${LEXICON_PREFIX}.work`, 171 176 }); 172 - return response.data.records.map((r) => r.value as unknown as Work); 177 + return response.data.records.map((r) => ({ 178 + ...(r.value as unknown as Work), 179 + rkey: r.uri.split('/').pop() || '', 180 + })); 173 181 } 174 182 175 183 async createWork(work: Omit<Work, 'createdAt'>): Promise<string> { 176 184 const rkey = TID.nextStr(); 177 185 await this.agent.com.atproto.repo.putRecord({ 178 186 repo: this.agent.session?.did || '', 179 - collection: `${LEXICON_PREFIX}.document.work`, 187 + collection: `${LEXICON_PREFIX}.work`, 180 188 rkey, 181 189 record: { 190 + $type: `${LEXICON_PREFIX}.work`, 182 191 ...work, 183 192 createdAt: new Date().toISOString(), 184 193 }, ··· 186 195 return rkey; 187 196 } 188 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 + 189 217 async deleteWork(rkey: string) { 190 218 return this.agent.com.atproto.repo.deleteRecord({ 191 219 repo: this.agent.session?.did || '', 192 - collection: `${LEXICON_PREFIX}.document.work`, 220 + collection: `${LEXICON_PREFIX}.work`, 193 221 rkey, 194 222 }); 195 223 } ··· 198 226 async listEvents(did: string): Promise<Event[]> { 199 227 const response = await this.agent.com.atproto.repo.listRecords({ 200 228 repo: did, 201 - collection: `${LEXICON_PREFIX}.event.academic`, 229 + collection: `${LEXICON_PREFIX}.event`, 202 230 }); 203 231 return response.data.records.map((r) => r.value as unknown as Event); 204 232 } ··· 207 235 const rkey = TID.nextStr(); 208 236 await this.agent.com.atproto.repo.putRecord({ 209 237 repo: this.agent.session?.did || '', 210 - collection: `${LEXICON_PREFIX}.event.academic`, 238 + collection: `${LEXICON_PREFIX}.event`, 211 239 rkey, 212 240 record: { 241 + $type: `${LEXICON_PREFIX}.event`, 213 242 ...event, 214 243 createdAt: new Date().toISOString(), 215 244 }, ··· 220 249 async updateEvent(rkey: string, updates: Partial<Event>) { 221 250 const record = await this.agent.com.atproto.repo.getRecord({ 222 251 repo: this.agent.session?.did || '', 223 - collection: `${LEXICON_PREFIX}.event.academic`, 252 + collection: `${LEXICON_PREFIX}.event`, 224 253 rkey, 225 254 }); 226 255 227 256 return this.agent.com.atproto.repo.putRecord({ 228 257 repo: this.agent.session?.did || '', 229 - collection: `${LEXICON_PREFIX}.event.academic`, 258 + collection: `${LEXICON_PREFIX}.event`, 230 259 rkey, 231 260 record: { 232 261 ...record.data.value, 233 262 ...updates, 263 + $type: `${LEXICON_PREFIX}.event`, 234 264 }, 235 265 }); 236 266 } ··· 238 268 async deleteEvent(rkey: string) { 239 269 return this.agent.com.atproto.repo.deleteRecord({ 240 270 repo: this.agent.session?.did || '', 241 - collection: `${LEXICON_PREFIX}.event.academic`, 271 + collection: `${LEXICON_PREFIX}.event`, 242 272 rkey, 243 273 }); 244 274 }
+5 -6
src/types/index.ts
··· 4 4 */ 5 5 6 6 import type { 7 - AtLanyardResearcher, 7 + AtLanyardProfile, 8 + AtLanyardAffiliation, 8 9 AtLanyardWork, 9 10 AtLanyardEvent, 10 11 AtLanyardLink, ··· 14 15 } from './generated'; 15 16 16 17 // Main record types 17 - export type Researcher = AtLanyardResearcher.Record; 18 + export type Profile = AtLanyardProfile.Record; 19 + export type Affiliation = AtLanyardAffiliation.Record; 18 20 export type Work = AtLanyardWork.Record; 19 21 export type Event = AtLanyardEvent.Record; 20 22 export type Link = AtLanyardLink.Record; ··· 22 24 export type Publication = AtLanyardPublication.Main; 23 25 export type Location = AtLanyardLocation.Main; 24 26 25 - // Nested types 26 - export type Affiliation = AtLanyardResearcher.Affiliation; 27 - 28 27 // Convenience type aliases for enums and unions 29 - export type Honorific = 'Dr' | 'Prof'; 28 + export type Honorific = 'none' | 'Dr' | 'Prof'; 30 29 export type WorkType = Work['type']; 31 30 export type EventType = Event['type']; 32 31 export type LinkType = Link['type'];