+146
.docs/brief.md
+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
+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
+
+46
lexicons/affiliation/affiliation.json
+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
+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
-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
+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
+2
next.config.ts
+2
-2
package.json
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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'll
86
+
Add your first research item by entering its DOI. We'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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+7
src/lib/client/index.ts
+12
-1
src/lib/data/doi.ts
+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
+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
+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'];