Skip to content

Commit 003a9b0

Browse files
Add reliable Community post and profile links
1 parent 928f5e1 commit 003a9b0

9 files changed

Lines changed: 804 additions & 45 deletions

File tree

.well-known/apple-app-site-association

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
},
1313
{
1414
"/": "/community/*",
15-
"comment": "Open a Community V2 post or comment."
15+
"comment": "Open a Community V2 post, comment, or profile."
1616
},
1717
{
1818
"/": "/import",

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ This directory is the static GitHub Pages origin for:
44

55
- `https://link.pythonide.xin/s/{scriptID}` community work shares
66
- `https://link.pythonide.xin/community/{postID}` Community V2 post and comment shares
7+
- `https://link.pythonide.xin/community/user/{userID}` stable Community V2 creator profiles
78
- `https://link.pythonide.xin/l/{code}` reserved short links
89
- `https://link.pythonide.xin/import?url=...` remote import links
910
- `https://link.pythonide.xin/.well-known/apple-app-site-association` iOS Universal Links
@@ -62,7 +63,11 @@ applinks:link.pythonide.xin
6263
webcredentials:link.pythonide.xin
6364
```
6465

65-
The AASA file registers `/s/*`, `/community/*`, and `/import`. Keep `/l/*` out of AASA until the short-link resolver backend is connected, so unfinished short links still open the web fallback instead of launching the app with no resolved target.
66+
The AASA file registers `/s/*`, `/community/*`, and `/import`. The
67+
`/community/*` contract includes both posts and stable user-ID profile links.
68+
Keep `/l/*` out of AASA until the short-link resolver backend is connected, so
69+
unfinished short links still open the web fallback instead of launching the app
70+
with no resolved target.
6671

6772
The AASA file must be reachable without redirects:
6873

assets/share-page.js

Lines changed: 254 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
'use strict';
33

44
const API_BASE = 'https://community-api-axuaystczl.cn-hangzhou.fcapp.run';
5+
const COMMUNITY_API_BASE = 'https://community-api.pythonide.xin/v2/community';
56
const APP_STORE_URL = 'https://apps.apple.com/app/id6753987304';
67
const SITE_ORIGIN = 'https://link.pythonide.xin';
78
const DEFAULT_TITLE = 'Python IDE 社区作品';
@@ -74,7 +75,13 @@
7475
community: '社区作品',
7576
communityPost: '社区帖子',
7677
communityPostSummary: '在 PythonIDE 中查看完整内容、评论和互动。',
78+
communityProfile: '社区个人主页',
79+
communityProfileSummary: '在 PythonIDE 中查看创作者的作品、回复和社区动态。',
7780
targetComment: '目标评论',
81+
comments: '评论',
82+
followers: '粉丝',
83+
following: '关注',
84+
works: '作品',
7885
remoteImport: '远程导入',
7986
importRemoteProject: '导入远程项目',
8087
missingProjectURL: '缺少项目地址',
@@ -161,7 +168,13 @@
161168
community: 'Community work',
162169
communityPost: 'Community post',
163170
communityPostSummary: 'View the full post, comments, and interactions in PythonIDE.',
171+
communityProfile: 'Community profile',
172+
communityProfileSummary: 'View this creator’s work, replies, and community activity in PythonIDE.',
164173
targetComment: 'Target comment',
174+
comments: 'Comments',
175+
followers: 'Followers',
176+
following: 'Following',
177+
works: 'Works',
165178
remoteImport: 'Remote import',
166179
importRemoteProject: 'Import remote project',
167180
missingProjectURL: 'Project URL missing',
@@ -248,7 +261,26 @@
248261
function parseCommunityRoute(url) {
249262
if (url.hash) return null;
250263
const pathParts = url.pathname.split('/');
251-
if (pathParts.length !== 3 || pathParts[0] !== '' || pathParts[1] !== 'community') return null;
264+
if (pathParts[0] !== '' || pathParts[1] !== 'community') return null;
265+
266+
if (pathParts.length === 4 && pathParts[2] === 'user') {
267+
const userID = normalizedCommunityIdentifier(decodeSegment(pathParts[3]));
268+
if (!userID) return null;
269+
270+
const allowedQueryNames = new Set(['lang', 'v']);
271+
for (const name of url.searchParams.keys()) {
272+
if (!allowedQueryNames.has(name) || url.searchParams.getAll(name).length !== 1) return null;
273+
}
274+
if (url.searchParams.has('lang') && !['zh', 'en'].includes(url.searchParams.get('lang'))) return null;
275+
276+
return {
277+
type: 'profile',
278+
userID,
279+
path: `/community/user/${encodeURIComponent(userID)}`,
280+
};
281+
}
282+
283+
if (pathParts.length !== 3 || pathParts[2] === 'user') return null;
252284

253285
const postID = normalizedCommunityIdentifier(decodeSegment(pathParts[2]));
254286
if (!postID) return null;
@@ -306,6 +338,9 @@
306338
}
307339

308340
function customURLFor(route) {
341+
if (route.type === 'profile') {
342+
return `pythonide://community/user/${encodeURIComponent(route.userID)}`;
343+
}
309344
if (route.type === 'community') {
310345
const path = `pythonide://community/post/${encodeURIComponent(route.postID)}`;
311346
return route.commentID
@@ -534,6 +569,8 @@
534569
let toastTimer = null;
535570
let launchTimer = null;
536571
let currentScript = null;
572+
let currentCommunityPost = null;
573+
let currentProfile = null;
537574
let currentView = route.type;
538575

539576
function setLoading(isLoading) {
@@ -783,6 +820,46 @@
783820
}
784821
}
785822

823+
function communityAvatarURL(user) {
824+
const explicitAvatar = safeImageURL(
825+
user?.avatarURL || user?.avatarUrl || user?.avatar_url || user?.imageURL,
826+
);
827+
const avatarSeed = String(user?.avatarSeed || user?.avatarID || '').trim();
828+
return explicitAvatar || (avatarSeed
829+
? `https://api.dicebear.com/9.x/adventurer/png?seed=${encodeURIComponent(avatarSeed)}&size=160`
830+
: '');
831+
}
832+
833+
function renderCommunityAuthor(user, timestamp = '') {
834+
const fallbackAuthor = language === 'en' ? 'Community creator' : '社区创作者';
835+
const author = String(user?.name || fallbackAuthor).trim() || fallbackAuthor;
836+
const handle = String(user?.handle || '').trim();
837+
const date = formatDate(timestamp);
838+
const detail = [handle ? `@${handle}` : '', date].filter(Boolean).join(' · ')
839+
|| fallbackAuthor;
840+
841+
setText(el.authorName, author);
842+
setText(el.authorDetail, detail);
843+
setText(el.authorInitial, Array.from(author)[0] || 'P');
844+
el.authorRow.hidden = false;
845+
el.authorAvatar.hidden = true;
846+
el.authorInitial.hidden = false;
847+
848+
const avatarURL = communityAvatarURL(user);
849+
if (avatarURL) {
850+
el.authorAvatar.onload = () => {
851+
el.authorAvatar.hidden = false;
852+
el.authorInitial.hidden = true;
853+
};
854+
el.authorAvatar.onerror = () => {
855+
el.authorAvatar.hidden = true;
856+
el.authorInitial.hidden = false;
857+
};
858+
el.authorAvatar.src = avatarURL;
859+
el.authorAvatar.alt = language === 'en' ? `${author}'s avatar` : `${author}的头像`;
860+
}
861+
}
862+
786863
function renderProject(script, presentation) {
787864
const facts = [
788865
script.entry_file ? `${language === 'en' ? 'Entry' : '入口'} ${script.entry_file}` : '',
@@ -915,6 +992,19 @@
915992
}
916993
}
917994

995+
function initialCommunityData(type, identifier) {
996+
const node = document.getElementById('initial-community-data');
997+
if (!node) return null;
998+
try {
999+
const payload = JSON.parse(node.textContent || '{}');
1000+
if (payload?.type !== type || !payload?.data) return null;
1001+
const value = payload.data;
1002+
return String(value.id || '') === String(identifier || '') ? value : null;
1003+
} catch {
1004+
return null;
1005+
}
1006+
}
1007+
9181008
async function loadScript(initialScript = null) {
9191009
currentView = 'script-loading';
9201010
hideStatus();
@@ -984,29 +1074,174 @@
9841074
setLoading(false);
9851075
}
9861076

987-
function loadCommunity() {
1077+
function communityPostDescription(post) {
1078+
return String(post?.translatedBody || post?.body || tr('communityPostSummary'))
1079+
.replace(/\s+/g, ' ')
1080+
.trim()
1081+
.slice(0, 280) || tr('communityPostSummary');
1082+
}
1083+
1084+
function renderCommunityPost(post) {
9881085
currentView = 'community';
9891086
currentScript = null;
1087+
currentProfile = null;
1088+
currentCommunityPost = post;
1089+
el.openApp.disabled = false;
1090+
hideStatus();
1091+
setText(el.openAppLabel, tr('openApp'));
1092+
1093+
const description = communityPostDescription(post);
1094+
const title = String(post?.translatedTitle || post?.title || description || tr('communityPost'))
1095+
.trim()
1096+
.slice(0, 120) || tr('communityPost');
1097+
setText(el.eyebrow, `PythonIDE · ${tr('communityPost')}`);
1098+
setText(el.title, title);
1099+
setText(el.summary, description);
1100+
renderCommunityAuthor(post?.author, post?.updatedAt || post?.createdAt);
1101+
renderGeneric(`PythonIDE · ${tr('communityPost')}`, '#');
1102+
1103+
const locale = language === 'en' ? 'en-US' : 'zh-CN';
1104+
renderStats([
1105+
{ value: formatCount(post?.likeCount, locale), label: tr('likes') },
1106+
{ value: formatCount(post?.commentCount, locale), label: tr('comments') },
1107+
{ value: formatCount(post?.runCount, locale), label: tr('runs') },
1108+
{ value: formatCount(post?.viewCount, locale), label: tr('views') },
1109+
]);
1110+
setText(el.actionTitle, tr('openApp'));
1111+
setText(el.actionDescription, tr('communityPostSummary'));
1112+
updatePageMetadata(title, description, DEFAULT_SHARE_IMAGE);
1113+
setLoading(false);
1114+
}
1115+
1116+
function renderCommunityPostError() {
1117+
currentView = 'community-error';
1118+
currentCommunityPost = null;
1119+
currentProfile = null;
9901120
el.authorRow.hidden = true;
9911121
el.openApp.disabled = false;
9921122
setText(el.openAppLabel, tr('openApp'));
993-
hideStatus();
9941123
setText(el.eyebrow, `PythonIDE · ${tr('community')}`);
9951124
setText(el.title, tr('communityPost'));
9961125
setText(el.summary, tr('communityPostSummary'));
9971126
renderGeneric(`PythonIDE · ${tr('communityPost')}`, '#');
9981127
renderStats([
999-
{ value: tr('readOnlyValue'), label: tr('preview') },
1000-
{ value: 'App', label: tr('openMethod') },
1001-
{ value: route.commentID ? '1' : '—', label: tr('targetComment') },
1002-
{ value: tr('safeValue'), label: tr('linkStructure') },
1128+
{ value: '—', label: tr('likes') }, { value: '—', label: tr('comments') },
1129+
{ value: '—', label: tr('runs') }, { value: '—', label: tr('views') },
10031130
]);
1004-
setText(el.actionTitle, tr('openApp'));
1005-
setText(el.actionDescription, tr('communityPostSummary'));
10061131
updatePageMetadata(tr('communityPost'), tr('communityPostSummary'), DEFAULT_SHARE_IMAGE);
1132+
showStatus(tr('statusUnavailable'), tr('statusUnavailableBody'), true);
10071133
setLoading(false);
10081134
}
10091135

1136+
async function loadCommunity(initialPost = null) {
1137+
currentView = 'community-loading';
1138+
hideStatus();
1139+
const hasInitialPost = Boolean(initialPost);
1140+
if (hasInitialPost) renderCommunityPost(initialPost);
1141+
else setLoading(true);
1142+
retryAction = () => loadCommunity(initialPost);
1143+
1144+
const controller = new AbortController();
1145+
const timeout = setTimeout(() => controller.abort(), 20000);
1146+
try {
1147+
const response = await fetch(
1148+
`${COMMUNITY_API_BASE}/posts/${encodeURIComponent(route.postID)}`,
1149+
{ headers: { Accept: 'application/json' }, signal: controller.signal },
1150+
);
1151+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
1152+
const payload = await response.json();
1153+
const post = payload?.data;
1154+
if (!post || String(post.id || '') !== route.postID) throw new Error('Missing post');
1155+
renderCommunityPost(post);
1156+
} catch {
1157+
if (!hasInitialPost) renderCommunityPostError();
1158+
} finally {
1159+
clearTimeout(timeout);
1160+
}
1161+
}
1162+
1163+
function renderProfile(user) {
1164+
currentView = 'profile';
1165+
currentScript = null;
1166+
currentCommunityPost = null;
1167+
currentProfile = user;
1168+
el.openApp.disabled = false;
1169+
hideStatus();
1170+
setText(el.openAppLabel, tr('openApp'));
1171+
1172+
const fallbackName = tr('communityProfile');
1173+
const name = String(user?.name || fallbackName).trim().slice(0, 120) || fallbackName;
1174+
const handle = String(user?.handle || '').trim();
1175+
const description = String(user?.translatedBio || user?.bio || tr('communityProfileSummary'))
1176+
.replace(/\s+/g, ' ')
1177+
.trim()
1178+
.slice(0, 280) || tr('communityProfileSummary');
1179+
setText(el.eyebrow, `PythonIDE · ${tr('communityProfile')}`);
1180+
setText(el.title, name);
1181+
setText(el.summary, description);
1182+
renderCommunityAuthor(user, user?.updatedAt || user?.createdAt);
1183+
renderGeneric(`PythonIDE · ${tr('communityProfile')}`, '@');
1184+
1185+
const locale = language === 'en' ? 'en-US' : 'zh-CN';
1186+
renderStats([
1187+
{ value: formatCount(user?.followerCount, locale), label: tr('followers') },
1188+
{ value: formatCount(user?.followingCount, locale), label: tr('following') },
1189+
{ value: formatCount(user?.workCount, locale), label: tr('works') },
1190+
{ value: user?.isPro ? 'PRO' : '—', label: handle ? `@${handle}` : tr('community') },
1191+
]);
1192+
setText(el.actionTitle, tr('openApp'));
1193+
setText(el.actionDescription, tr('communityProfileSummary'));
1194+
updatePageMetadata(name, description, communityAvatarURL(user) || DEFAULT_SHARE_IMAGE);
1195+
setLoading(false);
1196+
}
1197+
1198+
function renderProfileError() {
1199+
currentView = 'profile-error';
1200+
currentProfile = null;
1201+
currentCommunityPost = null;
1202+
el.authorRow.hidden = true;
1203+
el.openApp.disabled = false;
1204+
setText(el.openAppLabel, tr('openApp'));
1205+
setText(el.eyebrow, `PythonIDE · ${tr('community')}`);
1206+
setText(el.title, tr('communityProfile'));
1207+
setText(el.summary, tr('communityProfileSummary'));
1208+
renderGeneric(`PythonIDE · ${tr('communityProfile')}`, '@');
1209+
renderStats([
1210+
{ value: '—', label: tr('followers') }, { value: '—', label: tr('following') },
1211+
{ value: '—', label: tr('works') }, { value: '—', label: tr('community') },
1212+
]);
1213+
updatePageMetadata(tr('communityProfile'), tr('communityProfileSummary'), DEFAULT_SHARE_IMAGE);
1214+
showStatus(tr('statusUnavailable'), tr('statusUnavailableBody'), true);
1215+
setLoading(false);
1216+
}
1217+
1218+
async function loadProfile(initialProfile = null) {
1219+
currentView = 'profile-loading';
1220+
hideStatus();
1221+
const hasInitialProfile = Boolean(initialProfile);
1222+
if (hasInitialProfile) renderProfile(initialProfile);
1223+
else setLoading(true);
1224+
retryAction = () => loadProfile(initialProfile);
1225+
1226+
const controller = new AbortController();
1227+
const timeout = setTimeout(() => controller.abort(), 20000);
1228+
try {
1229+
const response = await fetch(
1230+
`${COMMUNITY_API_BASE}/users/${encodeURIComponent(route.userID)}`,
1231+
{ headers: { Accept: 'application/json' }, signal: controller.signal },
1232+
);
1233+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
1234+
const payload = await response.json();
1235+
const user = payload?.data?.user;
1236+
if (!user || String(user.id || '') !== route.userID) throw new Error('Missing user');
1237+
renderProfile(user);
1238+
} catch {
1239+
if (!hasInitialProfile) renderProfileError();
1240+
} finally {
1241+
clearTimeout(timeout);
1242+
}
1243+
}
1244+
10101245
function loadHome() {
10111246
currentView = 'home';
10121247
currentScript = null;
@@ -1097,7 +1332,11 @@
10971332
if (currentView === 'script-error') renderScriptError();
10981333
else if (currentView === 'import') loadImport();
10991334
else if (currentView === 'short') loadShort();
1100-
else if (currentView === 'community') loadCommunity();
1335+
else if (currentView === 'community' && currentCommunityPost) {
1336+
renderCommunityPost(currentCommunityPost);
1337+
} else if (currentView === 'community-error') renderCommunityPostError();
1338+
else if (currentView === 'profile' && currentProfile) renderProfile(currentProfile);
1339+
else if (currentView === 'profile-error') renderProfileError();
11011340
else if (currentView === 'home') loadHome();
11021341
}
11031342

@@ -1135,6 +1374,10 @@
11351374
if (route.type === 'script') loadScript(initialScriptData(route.id));
11361375
else if (route.type === 'import') loadImport();
11371376
else if (route.type === 'short') loadShort();
1138-
else if (route.type === 'community') loadCommunity();
1377+
else if (route.type === 'community') {
1378+
loadCommunity(initialCommunityData('community', route.postID));
1379+
} else if (route.type === 'profile') {
1380+
loadProfile(initialCommunityData('profile', route.userID));
1381+
}
11391382
else loadHome();
11401383
}(typeof globalThis !== 'undefined' ? globalThis : this));

0 commit comments

Comments
 (0)