diff --git a/docs/contracts/_ui.json b/docs/contracts/_ui.json index 00f6f704a..fde9635e9 100644 --- a/docs/contracts/_ui.json +++ b/docs/contracts/_ui.json @@ -13,8 +13,8 @@ "Context Tabs": "* Not used for course, section, or teacher catalog detail pages (see Detail Two-Column Stream).\n* May still appear on workspace surfaces that switch distinct task contexts (for example dashboard calendar/homework branches).\n* When present, context navigation is a horizontal, touch-scrollable row with an overflow affordance on mobile and a vertical sidebar on larger screens; the active destination remains programmatically identified and scrolls into view.", "Global Search Results": "* Catalog group order is sections → teachers → courses → links, then signed-in homeworks and todos.\n* Section items use title `course.namePrimary · teachers` (fall back to course name · section code when teachers are empty) and description `semester · campus · section.code` with code last.\n* Course items use title `course.namePrimary` and description `course.code`.\n* Teacher items use title `teacher.namePrimary` and description department or code.", "Calendar Export Dialog": "* Used to display single-object iCal and personal subscription iCal.\n* Presented as a copyable link; users are not required to manually construct the URL.", - "Shell Layout": "* Desktop sidebar and main content scroll independently.\n* Signed-in desktop navigation keeps task links in Workspace; Explore and Admin tools are progressive groups.\n* A destination row is either a link or a disclosure, never both.\n* Signed-in mobile navigation fixes Today, Calendar, Tasks, and Explore above content and reserves its height and safe area.\n* Language and theme stay in the topbar; signed-in profile actions stay in the sidebar footer.\n* Anonymous topbars retain Sign in.\n* Mobile topbar/navigation controls are at least 44 by 44 CSS pixels.\n* Selected system/light/dark themes apply before first paint; system follows live OS changes with a no-JavaScript fallback.", - "Workspace Footer Policy": "* Public discovery, content, policy, and marketing pages retain the public footer.\n* Signed-in workspace roots and their settings/admin branches do not append the public footer.\n* A signed-in user visiting a public content route still sees the public footer.", + "Shell Layout": "* Desktop sidebar and main content scroll independently.\n* Signed-in desktop navigation keeps task links in Workspace; Explore and Admin tools are progressive groups.\n* A destination row is either a link or a disclosure, never both.\n* Signed-in mobile navigation pins Today, Calendar, Tasks, and Explore and reserves that height.\n* Language and theme stay in the topbar; signed-in profile actions stay in the sidebar footer.\n* Welcome hides sidebar, search, and nav.\n* Anonymous topbars retain Sign in.\n* Mobile topbar/navigation controls are at least 44 by 44 CSS pixels.\n* Selected system/light/dark themes apply before first paint; system follows live OS changes with a no-JavaScript fallback.", + "Workspace Footer Policy": "* Public discovery, content, policy, and marketing pages retain the public footer.\n* Signed-in workspace roots, onboarding (`/account/welcome`), and their settings/admin branches do not append the public footer.\n* A signed-in user visiting a public content route still sees the public footer.", "Workspace Empty States": "* Filter-caused empty results explain the active filter and provide a direct clear-filter action.\n* Setup-caused empty states pair their explanation with the existing setup or create action.\n* Routine zero-item states remain compact and do not grow merely to fill the workspace.", "Settings Navigation": "* Settings use concise labels in a desktop rail and scrollable mobile row.\n* `/account/settings/profile` is the default; legacy tab inputs only redirect.\n* Legacy appearance, language, and preferences values redirect to `/account/settings/preferences`.\n* Preferences reuse the pre-paint/localStorage theme mechanism and `/api/account/preferences`; URLs have no locale segments.\n* Authorizations remain distinct from linked sign-in accounts and require confirmation before revocation.\n* The active section owns its copy, exposes one aria-current page link, and scrolls into view on mobile.\n* Danger stays separate from routine settings.", "Contribution Heatmap": "* Public profile contribution history keeps a readable minimum cell size and scrolls horizontally when needed.\n* Each contribution day exposes its localized date/count to keyboard, touch, and assistive-technology users.", diff --git a/docs/contracts/user.json b/docs/contracts/user.json index 5a69e17c7..f319e0742 100644 --- a/docs/contracts/user.json +++ b/docs/contracts/user.json @@ -13,6 +13,9 @@ "debug-password-boundary": "Email/password authentication is enabled only for local development or the explicit non-production E2E debug mode; production does not expose debug password authentication.", "oauth-callback-integrity": "On a canonical same-origin deployment, OAuth login callbacks must complete state validation and session creation directly, without losing state due to proxy wrapping or container-internal host differences.", "welcome-flow-required": "New users who have not set a name or username on first login must complete the welcome flow before proceeding.", + "welcome-staged-steps": "Welcome renders one URL step at a time: required profile, skippable subscriptions, then orientation. Incomplete profiles always land on profile; completed ones never return to it. Only the last step leaves for the callback. Welcome hides sidebar, search, and mobile primary nav. Subscriptions cannot read official enrollment; they inline undergraduate and graduate academic-system links and match pasted course codes.", + "welcome-avatar-upload": "During welcome, signed-in users may upload a JPG, PNG, WebP, or AVIF avatar up to 5 MB. The Worker uses the Cloudflare Images binding to auto-crop it to a 256-by-256 WebP before storing it in private R2; public avatar delivery is limited to URLs currently referenced by that user's profile.", + "welcome-oauth-refresh": "A user may reauthorize an already linked OAuth provider during welcome to fetch current upstream name and avatar options. Refreshed upstream values fill missing data and add avatar choices without overwriting profile values the user entered.", "welcome-completion-resume": "If the welcome flow was reached from an app-relative callbackUrl, successful completion returns to that page; otherwise completion returns home.", "auth-callback-not-intercepted": "Authorization callback continuation requests carrying OAuth code/error and state must be allowed to complete the protocol redirect; the welcome page interceptor must not break the authorization result redirect.", "post-login-redirect": "After successful login, the user should be redirected back to the originally requested app-relative page whenever possible; unsafe external callbacks fall back to the home page.", @@ -62,8 +65,12 @@ "user.username (Current value display)", "user.image (Current avatar)", "user.profilePictures[] (Avatar selector grid)", - "semesters[] (Semester dropdown options)", - "defaultSemesterId (Preselected semester)" + "Custom avatar upload (server-cropped 256x256 WebP in R2)", + "Linked OAuth providers (reauthorize to refresh upstream profile options)", + "Step progress indicator with back, skip, and finish controls", + "semesters[] (Semester dropdown options, subscriptions step)", + "defaultSemesterId (Preselected semester)", + "Platform guidance for subscriptions, workspace, planning, and account security (final step)" ] } }, diff --git a/messages/en-us.json b/messages/en-us.json index 0bc82b36a..0b43ec694 100644 --- a/messages/en-us.json +++ b/messages/en-us.json @@ -1718,6 +1718,8 @@ "editProfile": "Edit Profile", "editProfileDescription": "Make changes to your profile here. Click save when you're done.", "profilePicture": "Profile Picture", + "avatarUpload": "Upload your own avatar", + "avatarUploadHint": "JPG, PNG, WebP, or AVIF up to 5 MB. Cloudflare center-crops it to 256×256 and converts it to WebP.", "name": "Name", "namePlaceholder": "Your name", "nameRequired": "Name is required.", @@ -2406,14 +2408,41 @@ "description": "Set your name and username to continue.", "firstSignIn": "First sign-in", "continue": "Continue", + "stepProgress": "Step {current} of {total}", + "stepProfile": "Your profile", + "stepSubscriptions": "Section subscriptions", + "stepFinish": "Get started", + "back": "Back", + "skipForNow": "Skip for now", + "startUsing": "Go to workspace", + "finishTitle": "Next", + "finishDescription": "Life @ USTC covers a lot of last-mile work. You can review USTC information in one place here — try the features below!", "success": "Profile completed", "successDescription": "Welcome! Your profile has been set up.", - "nextStepsTitle": "Build your workspace", - "nextStepsDescription": "After saving your profile, you can browse sections, jump in from courses, or match section codes in bulk.", - "browseSections": "Browse Sections", - "browseCourses": "Browse Courses", + "nextStepsTitle": "Import your course selection from the academic system", + "nextStepsDescriptionBefore": "Because of technical limits, before using this platform you still need to copy this semester's courses from the academic system (undergraduate: ", + "nextStepsDescriptionBetween": " / graduate: ", + "nextStepsDescriptionAfter": "). Audited courses and courses you TA can go here too.", + "nextStepsPasteHint": "Paste anything that includes course codes (for example COMP3001.01) below, then click Import.", + "subscriptionsUndergraduatePortal": "Undergraduate academic system", + "subscriptionsGraduatePortal": "Graduate academic system", + "importButton": "Import", "avatarLater": "You can add uploaded avatars later from settings.", - "bulkImportCta": "Bulk Add Subscriptions", + "avatarUploadInvalid": "Choose a valid JPG, PNG, WebP, or AVIF image.", + "avatarUploadTooLarge": "Avatar files must be 5 MB or smaller.", + "avatarUploadUnavailable": "Avatar processing is temporarily unavailable. Try again later or use an upstream avatar.", + "oauthRefreshTitle": "Refresh from a sign-in account", + "oauthRefreshDescription": "Reauthorizing fetches the latest upstream name and avatar as options without overwriting details you entered yourself.", + "oauthRefreshAction": "Refresh from {provider}", + "oauthRefreshSuccess": "Latest upstream details received. Review your name and avatar, then continue.", + "oauthRefreshFailed": "We couldn't refresh details from that sign-in account. Please try again.", + "oauthRefreshNotLinked": "That sign-in account is not linked to the current user.", + "guideWorkspaceTitle": "Review your daily items in the workspace", + "guideWorkspaceDescription": "Timetable, homework, exams, and todos are all gathered for you.", + "guideProfileTitle": "Update your profile and use the platform as your personal homepage", + "guideProfileDescription": "If you're a TA, you can also use it as a course homepage.", + "guideAppsTitle": "Use Life @ USTC from other apps", + "guideAppsDescription": "Try exporting a CalDAV link, connecting MCP, and more.", "sectionCodesLabel": "Section codes", "confirmImportTitle": "Confirm Section Subscriptions", "matchedSummary": "{matched} matched, {unmatched} unmatched.", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 5e086d20b..9d8aa79d8 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -1588,6 +1588,8 @@ "editProfile": "编辑资料", "editProfileDescription": "在此处更改您的个人资料。完成后点击保存。", "profilePicture": "头像", + "avatarUpload": "上传自己的头像", + "avatarUploadHint": "支持 JPG、PNG、WebP 或 AVIF,最大 5 MB。图片会在 Cloudflare 中心裁切为 256×256 并转换为 WebP。", "name": "姓名", "namePlaceholder": "您的姓名", "nameRequired": "请输入姓名。", @@ -2276,14 +2278,41 @@ "description": "请设置您的姓名和用户名以继续使用。", "firstSignIn": "首次登录", "continue": "继续", + "stepProgress": "第 {current} 步,共 {total} 步", + "stepProfile": "个人资料", + "stepSubscriptions": "教学班订阅", + "stepFinish": "开始使用", + "back": "上一步", + "skipForNow": "暂时跳过", + "startUsing": "进入工作区", + "finishTitle": "接下来", + "finishDescription": "Life @ USTC 上提供了很多最后一公里的工作!你可以在这里集中查看来自科大的各项信息,先尝试一下下面的功能吧!", "success": "信息已完善", "successDescription": "欢迎!您的个人信息已设置完成。", - "nextStepsTitle": "继续完善你的学习工作区", - "nextStepsDescription": "保存资料后,你可以先浏览班级、从课程进入,或批量匹配班级代码。", - "browseSections": "浏览班级", - "browseCourses": "浏览课程", + "nextStepsTitle": "从教务系统导入选课", + "nextStepsDescriptionBefore": "由于技术限制,在使用本平台之前,还需要你去教务系统(本科:", + "nextStepsDescriptionBetween": " / 研究生:", + "nextStepsDescriptionAfter": ")上复制一下这学期你都选了哪些课程(其实旁听的课程、带助教的课程也都可以整理到这里哦)", + "nextStepsPasteHint": "请将包含课程代码(例如 COMP3001.01)的信息粘贴到下面,点击导入即可", + "subscriptionsUndergraduatePortal": "本科生教务", + "subscriptionsGraduatePortal": "研究生教务", + "importButton": "导入", "avatarLater": "你可以稍后在设置中添加上传过的头像。", - "bulkImportCta": "批量添加订阅", + "avatarUploadInvalid": "请选择有效的 JPG、PNG、WebP 或 AVIF 图片。", + "avatarUploadTooLarge": "头像文件不能超过 5 MB。", + "avatarUploadUnavailable": "头像处理服务暂时不可用,请稍后重试或先使用上游头像。", + "oauthRefreshTitle": "从登录账户更新资料", + "oauthRefreshDescription": "重新授权后会获取最新的上游姓名和头像作为可选资料,不会覆盖你已经手动填写的内容。", + "oauthRefreshAction": "从 {provider} 重新获取", + "oauthRefreshSuccess": "已获取最新的上游资料,请检查姓名和头像后继续。", + "oauthRefreshFailed": "暂时无法从该登录账户更新资料,请稍后重试。", + "oauthRefreshNotLinked": "该登录账户未与当前用户关联。", + "guideWorkspaceTitle": "在工作区集中查看你的每日事项", + "guideWorkspaceDescription": "课表、作业、考试、待办都为你整理在一起了", + "guideProfileTitle": "更新一下你的个人信息,把平台作为你的个人主页来使用", + "guideProfileDescription": "如果你是助教的话也可以当成课程主页来使用", + "guideAppsTitle": "通过其他应用使用 Life @ USTC", + "guideAppsDescription": "可以尝试导出 CalDAV 链接、接入 MCP 等在其他平台使用 Life @ USTC", "sectionCodesLabel": "班级代码", "confirmImportTitle": "确认添加教学班订阅", "matchedSummary": "已匹配 {matched} 个,未匹配 {unmatched} 个。", diff --git a/prisma/migrations/20260808153000_grant_profile_picture_app_runtime/migration.sql b/prisma/migrations/20260808153000_grant_profile_picture_app_runtime/migration.sql new file mode 100644 index 000000000..b96853daa --- /dev/null +++ b/prisma/migrations/20260808153000_grant_profile_picture_app_runtime/migration.sql @@ -0,0 +1,17 @@ +-- OAuth hooks and profile completion append trusted, server-derived avatar +-- URLs. Keep Better Auth's role read-only for this field; only the app runtime +-- receives the narrow column-level write privilege. + +DO $grant_profile_picture_app_runtime$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'life_ustc_runtime' + ) THEN + RAISE NOTICE 'Skipping profilePictures grant; role life_ustc_runtime does not exist.'; + RETURN; + END IF; + + EXECUTE + 'GRANT UPDATE ("profilePictures") ON TABLE "User" TO life_ustc_runtime'; +END +$grant_profile_picture_app_runtime$; diff --git a/prisma/roles/app-runtime-table-grants.sql b/prisma/roles/app-runtime-table-grants.sql index b7793e959..a0a91ffee 100644 --- a/prisma/roles/app-runtime-table-grants.sql +++ b/prisma/roles/app-runtime-table-grants.sql @@ -112,5 +112,5 @@ GRANT INSERT, UPDATE ON TABLE "BusCampus", "BusRoute" TO life_ustc_runtime; GRANT INSERT, DELETE ON TABLE "BusRouteStop", "BusTrip" TO life_ustc_runtime; -GRANT UPDATE ("name", "username", "isAdmin", "calendarFeedToken", "updatedAt") ON TABLE "User" +GRANT UPDATE ("name", "username", "profilePictures", "isAdmin", "calendarFeedToken", "updatedAt") ON TABLE "User" TO life_ustc_runtime; diff --git a/public/openapi.generated.json b/public/openapi.generated.json index f4e133e5b..7c4a22f5f 100644 --- a/public/openapi.generated.json +++ b/public/openapi.generated.json @@ -2185,6 +2185,25 @@ } } } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } diff --git a/src/features/profile/server/profile-avatar-service.ts b/src/features/profile/server/profile-avatar-service.ts new file mode 100644 index 000000000..ff8bdc8dc --- /dev/null +++ b/src/features/profile/server/profile-avatar-service.ts @@ -0,0 +1,129 @@ +import { getCloudflareImagesBinding } from "@/lib/adapters/cloudflare-runtime"; +import { prisma } from "@/lib/db/prisma"; +import { + deleteStorageObject, + getStorageObjectResponse, + putStorageObject, +} from "@/lib/storage/r2-object"; + +export const PROFILE_AVATAR_DIMENSION = 256; +export const PROFILE_AVATAR_MAX_BYTES = 5 * 1024 * 1024; +export const PROFILE_AVATAR_OUTPUT_TYPE = "image/webp"; + +const PROFILE_AVATAR_INPUT_TYPES = new Set([ + "image/avif", + "image/jpeg", + "image/png", + "image/webp", +]); +const AVATAR_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type ProfileAvatarUploadFailure = + | "empty" + | "invalid_image" + | "too_large" + | "unavailable"; + +export class ProfileAvatarUploadError extends Error { + constructor(readonly reason: ProfileAvatarUploadFailure) { + super(`Profile avatar upload failed: ${reason}`); + this.name = "ProfileAvatarUploadError"; + } +} + +function avatarUrl(userId: string, avatarId: string) { + return `/media/avatars/${encodeURIComponent(userId)}/${avatarId}.webp`; +} + +function avatarKey(userId: string, avatarId: string) { + return `avatars/${userId}/${avatarId}.webp`; +} + +export async function processProfileAvatarUpload(input: { + file: File; + userId: string; +}) { + if (input.file.size <= 0) { + throw new ProfileAvatarUploadError("empty"); + } + if (input.file.size > PROFILE_AVATAR_MAX_BYTES) { + throw new ProfileAvatarUploadError("too_large"); + } + if (!PROFILE_AVATAR_INPUT_TYPES.has(input.file.type.toLowerCase())) { + throw new ProfileAvatarUploadError("invalid_image"); + } + + const images = getCloudflareImagesBinding(); + if (!images) { + throw new ProfileAvatarUploadError("unavailable"); + } + + const avatarId = crypto.randomUUID(); + const key = avatarKey(input.userId, avatarId); + try { + const transformed = await images + .input(input.file.stream()) + .transform({ + width: PROFILE_AVATAR_DIMENSION, + height: PROFILE_AVATAR_DIMENSION, + fit: "cover", + gravity: "auto", + }) + .output({ + format: PROFILE_AVATAR_OUTPUT_TYPE, + quality: 85, + }); + const response = transformed.response(); + if (!response.ok || !response.body) { + throw new ProfileAvatarUploadError("invalid_image"); + } + await putStorageObject({ + body: response.body, + contentType: PROFILE_AVATAR_OUTPUT_TYPE, + key, + }); + } catch (error) { + if (error instanceof ProfileAvatarUploadError) throw error; + const code = + error && typeof error === "object" && "code" in error + ? Number(error.code) + : null; + throw new ProfileAvatarUploadError( + code === 9412 ? "invalid_image" : "unavailable", + ); + } + + return { + key, + url: avatarUrl(input.userId, avatarId), + }; +} + +export async function deleteProcessedProfileAvatar(key: string) { + await deleteStorageObject(key); +} + +export async function getPublicProfileAvatar(input: { + avatarId: string; + userId: string; +}) { + if (!AVATAR_ID_PATTERN.test(input.avatarId)) return null; + const url = avatarUrl(input.userId, input.avatarId); + const user = await prisma.user.findUnique({ + where: { id: input.userId }, + select: { image: true, profilePictures: true }, + }); + if (!user || (user.image !== url && !user.profilePictures.includes(url))) { + return null; + } + + const response = await getStorageObjectResponse({ + contentDisposition: 'inline; filename="avatar.webp"', + contentType: PROFILE_AVATAR_OUTPUT_TYPE, + key: avatarKey(input.userId, input.avatarId), + }); + if (!response) return null; + response.headers.set("Cache-Control", "public, max-age=31536000, immutable"); + return response; +} diff --git a/src/features/profile/server/profile-update-service.ts b/src/features/profile/server/profile-update-service.ts index 47f156cf8..eb737c5cd 100644 --- a/src/features/profile/server/profile-update-service.ts +++ b/src/features/profile/server/profile-update-service.ts @@ -1,12 +1,14 @@ import { authApi } from "@/lib/auth/core"; import { prisma } from "@/lib/db/prisma"; import { isPrismaUniqueConstraintError } from "@/lib/db/prisma-errors"; +import { logAppEvent } from "@/lib/log/app-logger"; import { isValidProfileUsername } from "../lib/profile-username"; type ProfileUpdateInput = { headers: Headers; image: string | null; name: string; + trustedImageUrl?: string | null; userId: string; username: string; }; @@ -38,6 +40,7 @@ export async function updateOwnProfile( if ( input.image && input.image !== current.image && + input.image !== input.trustedImageUrl && !current.profilePictures.includes(input.image) ) { return { ok: false, reason: "avatar_invalid" }; @@ -66,6 +69,29 @@ export async function updateOwnProfile( headers: input.headers, returnHeaders: true, }); + if ( + input.trustedImageUrl && + !current.profilePictures.includes(input.trustedImageUrl) + ) { + try { + await prisma.user.update({ + where: { id: input.userId }, + data: { + profilePictures: { push: input.trustedImageUrl }, + }, + select: { id: true }, + }); + } catch (error) { + // The current image already points at the processed object, so profile + // completion can succeed even if persisting the reusable option fails. + logAppEvent( + "warn", + "Failed to persist processed avatar as a profile option", + { source: "profile" }, + error, + ); + } + } return { headers: response.headers, ok: true }; } catch (error) { if (!isPrismaUniqueConstraintError(error)) throw error; diff --git a/src/features/welcome/components/WelcomeGuideCard.svelte b/src/features/welcome/components/WelcomeGuideCard.svelte new file mode 100644 index 000000000..d08cffa0d --- /dev/null +++ b/src/features/welcome/components/WelcomeGuideCard.svelte @@ -0,0 +1,58 @@ + + +
+
+

{welcomeCopy.finishTitle}

+

{welcomeCopy.finishDescription}

+
+ +
+ {#each guides as guide (guide.title)} + + {/each} +
+ + +
diff --git a/src/features/welcome/components/WelcomeGuideRow.svelte b/src/features/welcome/components/WelcomeGuideRow.svelte new file mode 100644 index 000000000..61f03a15b --- /dev/null +++ b/src/features/welcome/components/WelcomeGuideRow.svelte @@ -0,0 +1,20 @@ + + +
+ + + +
+

{title}

+ {#if description} +

{description}

+ {/if} + +
+
diff --git a/src/features/welcome/components/WelcomeImportDialogs.svelte b/src/features/welcome/components/WelcomeImportDialogs.svelte deleted file mode 100644 index 740571d7e..000000000 --- a/src/features/welcome/components/WelcomeImportDialogs.svelte +++ /dev/null @@ -1,109 +0,0 @@ - - - - - diff --git a/src/features/welcome/components/WelcomeNextStepsCard.svelte b/src/features/welcome/components/WelcomeNextStepsCard.svelte deleted file mode 100644 index d4c2691f0..000000000 --- a/src/features/welcome/components/WelcomeNextStepsCard.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - - {welcomeCopy.nextStepsTitle} - - {welcomeCopy.nextStepsDescription} - - - - {#if importMessage} - - {importMessage} - - {/if} - - - - - diff --git a/src/features/welcome/components/WelcomeOAuthProfileCard.svelte b/src/features/welcome/components/WelcomeOAuthProfileCard.svelte new file mode 100644 index 000000000..f82d63c6a --- /dev/null +++ b/src/features/welcome/components/WelcomeOAuthProfileCard.svelte @@ -0,0 +1,40 @@ + + +{#if oauthProviders.length > 0} + + + {welcomeCopy.oauthRefreshTitle} + {welcomeCopy.oauthRefreshDescription} + + + {#if oauthRefreshed} + + {welcomeCopy.oauthRefreshSuccess} + + {/if} +
+ {#each oauthProviders as provider} +
+ + + +
+ {/each} +
+
+
+{/if} diff --git a/src/features/welcome/components/WelcomePageController.svelte b/src/features/welcome/components/WelcomePageController.svelte index 35ba12101..2082d881e 100644 --- a/src/features/welcome/components/WelcomePageController.svelte +++ b/src/features/welcome/components/WelcomePageController.svelte @@ -1,7 +1,9 @@ -
+ @@ -62,7 +77,7 @@ $: avatarFallback = (user.name ?? user.username ?? "U") {profileCopy.profilePicture}
- + {avatarFallback} {#if avatarOptions.length > 0} @@ -94,6 +109,17 @@ $: avatarFallback = (user.name ?? user.username ?? "U") {/if}
+ + {profileCopy.avatarUpload} + + {profileCopy.avatarUploadHint} + diff --git a/src/features/welcome/components/WelcomeStepper.svelte b/src/features/welcome/components/WelcomeStepper.svelte new file mode 100644 index 000000000..c509b6910 --- /dev/null +++ b/src/features/welcome/components/WelcomeStepper.svelte @@ -0,0 +1,37 @@ + + + diff --git a/src/features/welcome/components/WelcomeSubscriptionsStep.svelte b/src/features/welcome/components/WelcomeSubscriptionsStep.svelte new file mode 100644 index 000000000..b5d0b8249 --- /dev/null +++ b/src/features/welcome/components/WelcomeSubscriptionsStep.svelte @@ -0,0 +1,211 @@ + + +
+
+

{welcomeCopy.nextStepsTitle}

+

+ {welcomeCopy.nextStepsDescriptionBefore}{UNDERGRADUATE_ACADEMIC_PORTAL_URL}{welcomeCopy.nextStepsDescriptionBetween}{GRADUATE_ACADEMIC_PORTAL_URL}{welcomeCopy.nextStepsDescriptionAfter} +

+

{welcomeCopy.nextStepsPasteHint}

+
+ +
+ {#if importError} + + {importError} + + {/if} + {#if importMessage} + + {importMessage} + + {/if} + + + + {bulkCopy.semesterLabel} + + {#if !selectedSemesterId} + + {bulkCopy.semesterPlaceholder} + + {/if} + {#each semesterOptions as option (option.value)} + {option.label} + {/each} + + + + + {welcomeCopy.sectionCodesLabel} + +