Enhance audience management features with new components and API integrations
- Added `AssignCoursesModal`, `AudienceMemberRow`, `AudienceTable`, and related components for improved audience management UI. - Implemented audience invite actions including resend and revoke functionality. - Updated audience API to support importing members and assigning courses. - Refactored audience data handling to include status and invite management. - Improved overall user experience with pagination and selection features in the audience table.
This commit is contained in:
@@ -177,6 +177,23 @@ You can bind form controls directly to a writable store’s properties. Svelte c
|
||||
<Select.Root type="single" bind:value={$inviteSettingsStore.preset}>
|
||||
```
|
||||
|
||||
### Reactive built-in collections
|
||||
|
||||
In Svelte 5, the built-in `Set` and `Map` classes are **not** reactive. Use `SvelteSet` and `SvelteMap` from `svelte/reactivity` instead. They are already reactive on their own — do **not** wrap them in `$state()`.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
let selectedIds = new SvelteSet<string>();
|
||||
|
||||
// Mutations are reactive — no clone-and-reassign needed
|
||||
selectedIds.add('abc');
|
||||
selectedIds.delete('abc');
|
||||
selectedIds.clear();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Server-Side API Calls
|
||||
|
||||
Use `.server.ts` files for server-side code to isolate API keys.
|
||||
@@ -205,6 +222,7 @@ Use `.server.ts` files for server-side code to isolate API keys.
|
||||
- Use `.server.ts` for server-side API calls with API keys
|
||||
- Use non-null assertion (`!`) for `user` when `authMiddleware` is present
|
||||
- Put all user-facing copy in translations and reference by key
|
||||
- Use `SvelteSet`/`SvelteMap` from `svelte/reactivity` for reactive collections (not `new Set`/`new Map`)
|
||||
|
||||
### ❌ DON'T
|
||||
- Put business logic in routes or queries
|
||||
@@ -217,6 +235,8 @@ Use `.server.ts` files for server-side code to isolate API keys.
|
||||
- Call RPC client directly in server files (use `.server.ts` classes)
|
||||
- Add unnecessary null checks for `user` when `authMiddleware` is present
|
||||
- Write plain English strings in components
|
||||
- Use `new Set()`/`new Map()` for mutable reactive state — use `SvelteSet`/`SvelteMap` instead
|
||||
- Wrap `SvelteSet`/`SvelteMap` in `$state()` — they are already reactive
|
||||
- **Use inline type imports** (e.g. `import('Package').Type` in type positions) — use top-level `import type` instead
|
||||
|
||||
## Checklist for New Routes
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getUserOrgRole } from '@cio/db/queries/organization';
|
||||
export const orgMemberMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
console.log('orgMemberMiddleware user', user);
|
||||
if (!user) {
|
||||
return c.json(
|
||||
{
|
||||
@@ -24,7 +23,6 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => {
|
||||
}
|
||||
|
||||
const orgId = c.req.header('cio-org-id');
|
||||
console.log('orgMemberMiddleware orgId', orgId);
|
||||
if (!orgId) {
|
||||
return c.json(
|
||||
{
|
||||
@@ -37,7 +35,6 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => {
|
||||
}
|
||||
|
||||
const roleId = await getUserOrgRole(orgId, user.id);
|
||||
console.log('orgMemberMiddleware roleId', orgId);
|
||||
if (roleId === null) {
|
||||
return c.json(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
ZAssignAudienceCourses,
|
||||
ZAudienceInviteByEmail,
|
||||
ZCancelOrgPlan,
|
||||
ZCreateOrgPlan,
|
||||
ZCreateOrganization,
|
||||
@@ -7,12 +9,20 @@ import {
|
||||
ZGetOrganizationCoursesQuery,
|
||||
ZGetOrganizations,
|
||||
ZGetUserAnalytics,
|
||||
ZImportAudienceMembers,
|
||||
ZInviteTeamMembers,
|
||||
ZLMSExercisesParam,
|
||||
ZRemoveTeamMember,
|
||||
ZUpdateOrgPlan,
|
||||
ZUpdateOrganization
|
||||
} from '@cio/utils/validation/organization';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import {
|
||||
assignAudienceToCourses,
|
||||
importAudienceMembers,
|
||||
resendAudienceInvite,
|
||||
revokeAudiencePendingInvite
|
||||
} from '@api/services/organization/audience';
|
||||
import {
|
||||
cancelOrgPlan,
|
||||
createOrg,
|
||||
@@ -33,13 +43,13 @@ import {
|
||||
} from '@api/services/organization';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { TOrganization } from '@db/types';
|
||||
import { automationRouter } from '@api/routes/organization/automation';
|
||||
import { assetsRouter } from '@api/routes/organization/assets';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { automationRouter } from '@api/routes/organization/automation';
|
||||
import { courseImportRouter } from '@api/routes/organization/course-import';
|
||||
import { getLMSExercisesService } from '@api/services/exercise';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
@@ -47,9 +57,9 @@ import { inviteTeamMembers } from '@api/services/organization/invite';
|
||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
|
||||
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
|
||||
import { quizRouter } from '@api/routes/organization/quiz';
|
||||
import { tagsRouter } from '@api/routes/organization/tags';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const organizationRouter = new Hono()
|
||||
@@ -100,7 +110,7 @@ export const organizationRouter = new Hono()
|
||||
* Gets organization team members (non-students)
|
||||
* Requires authentication
|
||||
*/
|
||||
.get('/team', authMiddleware, orgMemberMiddleware, async (c) => {
|
||||
.get('/team', authMiddleware, orgTeamMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const team = await getOrgTeam(orgId);
|
||||
@@ -168,7 +178,7 @@ export const organizationRouter = new Hono()
|
||||
* Gets organization audience (students)
|
||||
* Requires authentication
|
||||
*/
|
||||
.get('/audience', authMiddleware, orgMemberMiddleware, async (c) => {
|
||||
.get('/audience', authMiddleware, orgTeamMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const audience = await getOrgAudience(orgId);
|
||||
@@ -184,6 +194,48 @@ export const organizationRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to fetch organization audience');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /organization/audience/resend-invite
|
||||
* Resends a student org invite email (new token; optional course metadata from last invite)
|
||||
*/
|
||||
.post(
|
||||
'/audience/resend-invite',
|
||||
authMiddleware,
|
||||
orgTeamMemberMiddleware,
|
||||
zValidator('json', ZAudienceInviteByEmail),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const user = c.get('user')!;
|
||||
const data = c.req.valid('json');
|
||||
const result = await resendAudienceInvite(orgId, data, user.id);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to resend audience invite');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* POST /organization/audience/revoke-invite
|
||||
* Revokes an active (non-expired) pending student org invite
|
||||
*/
|
||||
.post(
|
||||
'/audience/revoke-invite',
|
||||
authMiddleware,
|
||||
orgTeamMemberMiddleware,
|
||||
zValidator('json', ZAudienceInviteByEmail),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const user = c.get('user')!;
|
||||
const data = c.req.valid('json');
|
||||
const result = await revokeAudiencePendingInvite(orgId, data, user.id);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to revoke audience invite');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* GET /organization/audience/:userId/analytics
|
||||
* Gets user analytics for a specific user in an organization
|
||||
@@ -192,7 +244,7 @@ export const organizationRouter = new Hono()
|
||||
.get(
|
||||
'/audience/:userId/analytics',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
orgTeamMemberMiddleware,
|
||||
zValidator('param', ZGetUserAnalytics),
|
||||
async (c) => {
|
||||
try {
|
||||
@@ -458,7 +510,6 @@ export const organizationRouter = new Hono()
|
||||
*/
|
||||
.put('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZUpdateOrganization), async (c) => {
|
||||
try {
|
||||
console.log('updateOrg');
|
||||
console.log(c.req.valid('json'));
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
|
||||
@@ -515,6 +566,65 @@ export const organizationRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to fetch LMS exercises');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /organization/audience/import
|
||||
* Imports users into the organization as students with optional course assignment
|
||||
* Requires authentication and admin role
|
||||
*/
|
||||
.post(
|
||||
'/audience/import',
|
||||
authMiddleware,
|
||||
orgTeamMemberMiddleware,
|
||||
zValidator('json', ZImportAudienceMembers),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const user = c.get('user')!;
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const result = await importAudienceMembers(orgId, data, user.id);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: result
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to import audience members');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* POST /organization/audience/assign-courses
|
||||
* Assigns existing audience members to one or more courses
|
||||
* Requires authentication and admin role
|
||||
*/
|
||||
.post(
|
||||
'/audience/assign-courses',
|
||||
authMiddleware,
|
||||
orgAdminMiddleware,
|
||||
zValidator('json', ZAssignAudienceCourses),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const result = await assignAudienceToCourses(orgId, data);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: result
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to assign audience to courses');
|
||||
}
|
||||
}
|
||||
)
|
||||
.route('/automation', automationRouter)
|
||||
.route('/course-import', courseImportRouter)
|
||||
.route('/tags', tagsRouter)
|
||||
|
||||
@@ -23,7 +23,7 @@ import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import { getProfileByEmail } from '@cio/db/queries/auth';
|
||||
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
|
||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||
@@ -948,6 +948,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
.limit(1);
|
||||
|
||||
if (existingMember) {
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
await recordInviteAudit(invite.id, invite.courseId, 'ACCEPTED', {
|
||||
actorProfileId: user.id,
|
||||
targetEmail: normalizedEmail,
|
||||
@@ -1029,6 +1031,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
email: normalizedEmail
|
||||
});
|
||||
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
const [consumeInvite] = await tx
|
||||
.update(schema.courseInvite)
|
||||
.set({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash';
|
||||
|
||||
import { OrganisationAnalytics } from '@api/types';
|
||||
import { getOrgIdBySiteName } from '@db/queries';
|
||||
import { getOrgIdBySiteName } from '@cio/db/queries';
|
||||
|
||||
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
|
||||
const analytics: OrganisationAnalytics = {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan, TPlan } from '@db/types';
|
||||
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan } from '@db/types';
|
||||
import {
|
||||
cancelOrganizationPlan,
|
||||
createOrganizationPlan,
|
||||
deleteOrganizationMember,
|
||||
getOrgIdBySiteName,
|
||||
getFirstOrganizationWithPlans,
|
||||
getLatestOrgInvitesByEmails,
|
||||
getOrganizationAudience,
|
||||
getOrganizationBySiteName,
|
||||
getOrganizationTeam,
|
||||
@@ -32,6 +33,8 @@ import { ROLE } from '@cio/utils/constants';
|
||||
import { createOrganizationWithOwner } from '@api/services/onboarding';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
|
||||
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
|
||||
import type { OrgAudienceMember } from '@api/types/org';
|
||||
|
||||
/**
|
||||
* Creates a new organization with the current user as owner
|
||||
@@ -116,12 +119,26 @@ export async function getOrgTeam(orgId: string) {
|
||||
/**
|
||||
* Gets organization audience (students)
|
||||
* @param orgId - The organization ID
|
||||
* @returns Array of student profiles
|
||||
* @returns Audience members with invite status for profile-less rows
|
||||
*/
|
||||
export async function getOrgAudience(orgId: string) {
|
||||
export async function getOrgAudience(orgId: string): Promise<OrgAudienceMember[]> {
|
||||
try {
|
||||
const audience = await getOrganizationAudience(orgId);
|
||||
return audience;
|
||||
|
||||
const emailsWithoutProfile = audience.filter((m) => !m.profileId && m.email).map((m) => m.email.toLowerCase());
|
||||
|
||||
const invites = await getLatestOrgInvitesByEmails(orgId, emailsWithoutProfile);
|
||||
const inviteByEmail = new Map(invites.map((i) => [i.email.toLowerCase(), i]));
|
||||
|
||||
return audience.map(
|
||||
(member): OrgAudienceMember => ({
|
||||
...member,
|
||||
status: deriveAudienceMemberStatus(
|
||||
member.profileId,
|
||||
member.email ? inviteByEmail.get(member.email.toLowerCase()) : undefined
|
||||
)
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch organization audience',
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type {
|
||||
TAssignAudienceCourses,
|
||||
TAudienceInviteByEmail,
|
||||
TImportAudienceMembers
|
||||
} from '@cio/utils/validation/organization';
|
||||
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
|
||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import {
|
||||
checkEmailsExistInOrg,
|
||||
createOrganizationInvite,
|
||||
createOrganizationInviteAudits,
|
||||
createOrganizationInvites,
|
||||
createOrganizationMembers,
|
||||
getLatestOrganizationInviteRowByOrgAndEmail,
|
||||
getOrgMembersByProfileIds,
|
||||
getOrganizationById,
|
||||
getStudentOrganizationMemberByOrgAndEmail,
|
||||
hasActiveOrganizationInviteForEmail,
|
||||
revokeActiveOrganizationInvitesByEmails
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseGroupIds, getOrgCourseGroups, getOrgCourses } from '@cio/db/queries/course';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import crypto from 'node:crypto';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getProfilesByEmails } from '@cio/db/queries/auth';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/** Parallel outbound invite emails; avoids sequential SMTP/API latency per recipient. */
|
||||
const EMAIL_SEND_CONCURRENCY = 5;
|
||||
|
||||
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const results: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
const workerCount = Math.min(concurrency, items.length);
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= items.length) break;
|
||||
results[i] = await fn(items[i]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function buildInviteLink(token: string): string {
|
||||
return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function getExpiryLabel(expiresAtIso: string): string {
|
||||
return new Date(expiresAtIso).toLocaleString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
|
||||
function parseRecipientCsv(recipientCsv: string): string[] {
|
||||
return recipientCsv
|
||||
.split(/[\n,;\t ]+/g)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getNormalizedRecipients(recipientCsv: string): {
|
||||
valid: string[];
|
||||
invalid: string[];
|
||||
duplicates: string[];
|
||||
} {
|
||||
const raw = parseRecipientCsv(recipientCsv);
|
||||
const seen = new Set<string>();
|
||||
const valid: string[] = [];
|
||||
const invalid: string[] = [];
|
||||
const duplicates: string[] = [];
|
||||
|
||||
for (const recipient of raw) {
|
||||
const normalized = recipient.toLowerCase().trim();
|
||||
if (!normalized) continue;
|
||||
|
||||
if (!EMAIL_REGEX.test(normalized)) {
|
||||
invalid.push(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(normalized)) {
|
||||
duplicates.push(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(normalized);
|
||||
valid.push(normalized);
|
||||
}
|
||||
|
||||
return { valid, invalid, duplicates };
|
||||
}
|
||||
|
||||
export async function importAudienceMembers(orgId: string, data: TImportAudienceMembers, invitedByProfileId: string) {
|
||||
const organization = await getOrganizationById(orgId);
|
||||
if (!organization || !organization.siteName) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const recipients = getNormalizedRecipients(data.recipientCsv);
|
||||
|
||||
if (recipients.invalid.length > 0) {
|
||||
throw new AppError(
|
||||
`Invalid emails found: ${recipients.invalid.slice(0, 5).join(', ')}`,
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'recipientCsv'
|
||||
);
|
||||
}
|
||||
|
||||
if (recipients.valid.length === 0) {
|
||||
throw new AppError('No valid emails provided', ErrorCodes.VALIDATION_ERROR, 400, 'recipientCsv');
|
||||
}
|
||||
|
||||
const existingEmails = await checkEmailsExistInOrg(orgId, recipients.valid);
|
||||
const emailsToImport = recipients.valid.filter((email) => !existingEmails.includes(email));
|
||||
|
||||
if (emailsToImport.length === 0) {
|
||||
return {
|
||||
imported: 0,
|
||||
skipped: existingEmails.length,
|
||||
duplicates: recipients.duplicates.length,
|
||||
emailsSent: 0,
|
||||
emailsFailed: 0
|
||||
};
|
||||
}
|
||||
|
||||
await createOrganizationMembers(
|
||||
emailsToImport.map((email) => ({
|
||||
organizationId: orgId,
|
||||
email,
|
||||
roleId: ROLE.STUDENT,
|
||||
verified: false
|
||||
}))
|
||||
);
|
||||
|
||||
let courseIds: string[] = [];
|
||||
let courseNames: string[] = [];
|
||||
|
||||
if (data.allCourses) {
|
||||
const courses = await getOrgCourses({ orgId });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
} else if (data.courseIds && data.courseIds.length > 0) {
|
||||
const courses = await getOrgCourses({ orgId, courseIds: data.courseIds });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
}
|
||||
if (courseIds.length > 0) {
|
||||
const courseGroupMappings = await getCourseGroupIds(courseIds);
|
||||
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
|
||||
|
||||
if (validGroupIds.length > 0) {
|
||||
const profiles = await getProfilesByEmails(emailsToImport);
|
||||
if (profiles.length > 0) {
|
||||
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
|
||||
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await revokeActiveOrganizationInvitesByEmails(orgId, emailsToImport, invitedByProfileId);
|
||||
|
||||
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
|
||||
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
|
||||
|
||||
const inviteInputs = emailsToImport.map((email) => {
|
||||
const token = generateToken();
|
||||
return {
|
||||
email,
|
||||
token,
|
||||
row: {
|
||||
organizationId: orgId,
|
||||
roleId: ROLE.STUDENT,
|
||||
email,
|
||||
tokenHash: hashToken(token),
|
||||
createdByProfileId: invitedByProfileId,
|
||||
expiresAt,
|
||||
isRevoked: false,
|
||||
metadata: {
|
||||
source: 'AUDIENCE_IMPORT',
|
||||
courseIds: courseIds.length > 0 ? courseIds : undefined
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const invites = await createOrganizationInvites(inviteInputs.map((i) => i.row));
|
||||
const inviteByEmail = new Map(invites.map((inv) => [inv.email.toLowerCase(), inv]));
|
||||
const tokenByEmail = new Map(inviteInputs.map((i) => [i.email.toLowerCase(), i.token]));
|
||||
|
||||
await createOrganizationInviteAudits(
|
||||
emailsToImport.map((email) => {
|
||||
const invite = inviteByEmail.get(email)!;
|
||||
return {
|
||||
inviteId: invite.id,
|
||||
organizationId: orgId,
|
||||
eventType: 'CREATED' as const,
|
||||
actorProfileId: invitedByProfileId,
|
||||
targetEmail: email,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: {
|
||||
roleId: ROLE.STUDENT,
|
||||
roleName: 'Student',
|
||||
expiresAt,
|
||||
courseIds
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
let emailsSent = 0;
|
||||
let emailsFailed = 0;
|
||||
|
||||
if (data.sendEmail) {
|
||||
const emailOutcomes = await mapWithConcurrency(emailsToImport, EMAIL_SEND_CONCURRENCY, async (email) => {
|
||||
const invite = inviteByEmail.get(email)!;
|
||||
const token = tokenByEmail.get(email)!;
|
||||
try {
|
||||
const inviteLink = buildInviteLink(token);
|
||||
const responses = await sendEmail('studentOrgInvite', {
|
||||
to: email,
|
||||
fields: {
|
||||
email,
|
||||
orgName: organization.name,
|
||||
inviteLink,
|
||||
expiresAt: getExpiryLabel(expiresAt),
|
||||
courseNames: courseNamesLabel
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
||||
});
|
||||
|
||||
const allSuccessful = responses.every((r) => r.success);
|
||||
if (allSuccessful) {
|
||||
return {
|
||||
inviteId: invite.id,
|
||||
email,
|
||||
success: true as const,
|
||||
error: undefined as string | undefined
|
||||
};
|
||||
}
|
||||
return {
|
||||
inviteId: invite.id,
|
||||
email,
|
||||
success: false as const,
|
||||
error: responses
|
||||
.filter((r) => !r.success)
|
||||
.map((r) => r.error || 'Unknown')
|
||||
.join('; ')
|
||||
};
|
||||
} catch (emailError) {
|
||||
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
|
||||
return { inviteId: invite.id, email, success: false as const, error: message };
|
||||
}
|
||||
});
|
||||
|
||||
emailsSent = emailOutcomes.filter((o) => o.success).length;
|
||||
emailsFailed = emailOutcomes.length - emailsSent;
|
||||
|
||||
await createOrganizationInviteAudits(
|
||||
emailOutcomes.map((o) => ({
|
||||
inviteId: o.inviteId,
|
||||
organizationId: orgId,
|
||||
eventType: o.success ? ('EMAIL_SENT' as const) : ('EMAIL_FAILED' as const),
|
||||
actorProfileId: invitedByProfileId,
|
||||
targetEmail: o.email,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: o.success ? {} : { error: o.error ?? 'Unknown' }
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
imported: emailsToImport.length,
|
||||
skipped: existingEmails.length,
|
||||
duplicates: recipients.duplicates.length,
|
||||
emailsSent,
|
||||
emailsFailed
|
||||
};
|
||||
}
|
||||
|
||||
export async function resendAudienceInvite(orgId: string, data: TAudienceInviteByEmail, invitedByProfileId: string) {
|
||||
const organization = await getOrganizationById(orgId);
|
||||
if (!organization || !organization.siteName) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const member = await getStudentOrganizationMemberByOrgAndEmail(orgId, data.email);
|
||||
if (!member) {
|
||||
throw new AppError('Audience member not found', ErrorCodes.VALIDATION_ERROR, 404, 'email');
|
||||
}
|
||||
if (member.profileId) {
|
||||
throw new AppError('Member has already joined', ErrorCodes.VALIDATION_ERROR, 400, 'email');
|
||||
}
|
||||
|
||||
const emailToUse = member.email.toLowerCase().trim();
|
||||
|
||||
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse);
|
||||
const meta = latestInvite?.metadata as { courseIds?: string[] } | undefined;
|
||||
const courseIdsFromMetadata = meta?.courseIds?.filter(Boolean) ?? [];
|
||||
|
||||
let courseIds: string[] = [];
|
||||
let courseNames: string[] = [];
|
||||
if (courseIdsFromMetadata.length > 0) {
|
||||
const courses = await getOrgCourses({ orgId, courseIds: courseIdsFromMetadata });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
}
|
||||
|
||||
await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId);
|
||||
|
||||
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
|
||||
const token = generateToken();
|
||||
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
|
||||
|
||||
const invite = await createOrganizationInvite({
|
||||
organizationId: orgId,
|
||||
roleId: ROLE.STUDENT,
|
||||
email: emailToUse,
|
||||
tokenHash: hashToken(token),
|
||||
createdByProfileId: invitedByProfileId,
|
||||
expiresAt,
|
||||
isRevoked: false,
|
||||
metadata: {
|
||||
source: 'AUDIENCE_RESEND',
|
||||
courseIds: courseIds.length > 0 ? courseIds : undefined
|
||||
}
|
||||
});
|
||||
|
||||
await createOrganizationInviteAudits([
|
||||
{
|
||||
inviteId: invite.id,
|
||||
organizationId: orgId,
|
||||
eventType: 'CREATED',
|
||||
actorProfileId: invitedByProfileId,
|
||||
targetEmail: emailToUse,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: {
|
||||
roleId: ROLE.STUDENT,
|
||||
roleName: 'Student',
|
||||
expiresAt,
|
||||
courseIds
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
let emailSent = false;
|
||||
try {
|
||||
const inviteLink = buildInviteLink(token);
|
||||
const responses = await sendEmail('studentOrgInvite', {
|
||||
to: emailToUse,
|
||||
fields: {
|
||||
email: emailToUse,
|
||||
orgName: organization.name,
|
||||
inviteLink,
|
||||
expiresAt: getExpiryLabel(expiresAt),
|
||||
courseNames: courseNamesLabel
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
||||
});
|
||||
const allSuccessful = responses.every((r) => r.success);
|
||||
emailSent = allSuccessful;
|
||||
await createOrganizationInviteAudits([
|
||||
{
|
||||
inviteId: invite.id,
|
||||
organizationId: orgId,
|
||||
eventType: allSuccessful ? 'EMAIL_SENT' : 'EMAIL_FAILED',
|
||||
actorProfileId: invitedByProfileId,
|
||||
targetEmail: emailToUse,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: allSuccessful
|
||||
? {}
|
||||
: {
|
||||
error: responses
|
||||
.filter((r) => !r.success)
|
||||
.map((r) => r.error || 'Unknown')
|
||||
.join('; ')
|
||||
}
|
||||
}
|
||||
]);
|
||||
} catch (emailError) {
|
||||
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
|
||||
await createOrganizationInviteAudits([
|
||||
{
|
||||
inviteId: invite.id,
|
||||
organizationId: orgId,
|
||||
eventType: 'EMAIL_FAILED',
|
||||
actorProfileId: invitedByProfileId,
|
||||
targetEmail: emailToUse,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: { error: message }
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
return { emailSent };
|
||||
}
|
||||
|
||||
export async function revokeAudiencePendingInvite(
|
||||
orgId: string,
|
||||
data: TAudienceInviteByEmail,
|
||||
revokedByProfileId: string
|
||||
) {
|
||||
const member = await getStudentOrganizationMemberByOrgAndEmail(orgId, data.email);
|
||||
if (!member) {
|
||||
throw new AppError('Audience member not found', ErrorCodes.VALIDATION_ERROR, 404, 'email');
|
||||
}
|
||||
if (member.profileId) {
|
||||
throw new AppError('Member has already joined', ErrorCodes.VALIDATION_ERROR, 400, 'email');
|
||||
}
|
||||
|
||||
const emailToUse = member.email.toLowerCase().trim();
|
||||
|
||||
const hasActive = await hasActiveOrganizationInviteForEmail(orgId, emailToUse);
|
||||
if (!hasActive) {
|
||||
throw new AppError('No active pending invite to revoke', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const revoked = await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], revokedByProfileId);
|
||||
if (revoked.length === 0) {
|
||||
throw new AppError('No invite was revoked', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
await createOrganizationInviteAudits(
|
||||
revoked.map((inv) => ({
|
||||
inviteId: inv.id,
|
||||
organizationId: orgId,
|
||||
eventType: 'REVOKED' as const,
|
||||
actorProfileId: revokedByProfileId,
|
||||
targetEmail: emailToUse,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: { reason: 'audience_revoked_by_admin' }
|
||||
}))
|
||||
);
|
||||
|
||||
return { revoked: true };
|
||||
}
|
||||
|
||||
export async function assignAudienceToCourses(orgId: string, data: TAssignAudienceCourses) {
|
||||
const organization = await getOrganizationById(orgId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const orgMembers = await getOrgMembersByProfileIds(orgId, data.profileIds);
|
||||
|
||||
const validProfileIds = new Set(orgMembers.map((m) => m.profileId).filter(Boolean));
|
||||
const profileEmailMap = new Map(orgMembers.filter((m) => m.profileId).map((m) => [m.profileId!, m.email ?? '']));
|
||||
|
||||
const courseGroups = await getOrgCourseGroups(orgId, data.courseIds);
|
||||
|
||||
if (courseGroups.length === 0) {
|
||||
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
|
||||
}
|
||||
|
||||
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
|
||||
const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle]));
|
||||
const validProfiles = data.profileIds.filter((id) => validProfileIds.has(id));
|
||||
|
||||
const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId })));
|
||||
|
||||
const existingSet = await getExistingGroupMembers(pairs);
|
||||
|
||||
const toInsert = pairs.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`));
|
||||
const alreadyEnrolled = pairs.length - toInsert.length;
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await addGroupMembers(
|
||||
toInsert.map((p) => ({
|
||||
groupId: p.groupId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: p.profileId,
|
||||
email: profileEmailMap.get(p.profileId) || undefined
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
let emailsSent = 0;
|
||||
|
||||
if (data.sendEmail && toInsert.length > 0) {
|
||||
const emailPromises = toInsert
|
||||
.filter((p) => profileEmailMap.get(p.profileId))
|
||||
.map(async (p) => {
|
||||
const email = profileEmailMap.get(p.profileId)!;
|
||||
try {
|
||||
await sendEmail('studentCourseWelcome', {
|
||||
to: email,
|
||||
fields: {
|
||||
orgName: organization.name,
|
||||
courseName: courseTitleByGroupId.get(p.groupId) || 'Course'
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
||||
});
|
||||
emailsSent++;
|
||||
} catch (emailError) {
|
||||
console.error(`assignAudienceToCourses email error for ${email}:`, emailError);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(emailPromises);
|
||||
}
|
||||
|
||||
return {
|
||||
assigned: toInsert.length,
|
||||
alreadyEnrolled,
|
||||
emailsSent
|
||||
};
|
||||
}
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
getOrganizationInviteByTokenHash,
|
||||
revokeActiveOrganizationInvitesByEmails
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { parseCourseIdsFromInviteMetadata } from '@api/utils/org';
|
||||
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
||||
import { sendEmail } from '@cio/email';
|
||||
|
||||
@@ -46,12 +49,13 @@ function normalizeEmails(emails: string[]): string[] {
|
||||
}
|
||||
|
||||
function buildInviteLink(token: string): string {
|
||||
return `${getDashboardBaseUrl()}/invite/t/${encodeURIComponent(token)}`;
|
||||
return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function getRoleLabel(roleId: number): string {
|
||||
if (roleId === ROLE.ADMIN) return 'Admin';
|
||||
if (roleId === ROLE.TUTOR) return 'Tutor';
|
||||
if (roleId === ROLE.STUDENT) return 'Student';
|
||||
return `Role ${roleId}`;
|
||||
}
|
||||
|
||||
@@ -405,6 +409,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
const siteName = result.organization.siteName || '';
|
||||
|
||||
const invite = await getOrganizationInviteByTokenHash(tokenHash);
|
||||
|
||||
if (invite) {
|
||||
await recordOrganizationInviteAudit(invite.invite.id, invite.invite.organizationId, 'ACCEPTED', {
|
||||
actorProfileId: user.id,
|
||||
@@ -415,10 +420,35 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-enroll in courses if invite metadata contains courseIds (from audience import).
|
||||
// Run regardless of alreadyAccepted — enrollment is idempotent and the user may
|
||||
// have accepted the org invite previously without getting course access.
|
||||
if (invite) {
|
||||
const metadata = invite.invite.metadata;
|
||||
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
|
||||
|
||||
if (courseIds.length > 0) {
|
||||
try {
|
||||
// Same as importAudienceMembers after course ids are known: resolve groups from course rows only.
|
||||
const courseGroupMappings = await getCourseGroupIds(courseIds);
|
||||
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
|
||||
await enrollUsersInCourseGroups(
|
||||
validGroupIds,
|
||||
[{ profileId: user.id, email: normalizedEmail }],
|
||||
invite.invite.roleId
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('acceptOrganizationInvite course enrollment error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
|
||||
|
||||
return {
|
||||
organizationId: result.organization.id,
|
||||
roleId: result.roleId,
|
||||
alreadyAccepted: result.alreadyAccepted,
|
||||
redirectTo: siteName ? `/org/${siteName}` : '/org'
|
||||
redirectTo
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Re-export all types
|
||||
export * from './database';
|
||||
export * from './dash';
|
||||
export * from './org';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AudienceMemberStatus } from '@api/utils/audience-member-status';
|
||||
|
||||
export type OrgAudienceMember = {
|
||||
id: number;
|
||||
profileId: string | null;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
createdAt: string;
|
||||
status: AudienceMemberStatus;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export type AudienceMemberStatus = 'active' | 'pending' | 'expired' | 'revoked';
|
||||
|
||||
export function deriveAudienceMemberStatus(
|
||||
profileId: string | null,
|
||||
invite: { acceptedAt: string | null; isRevoked: boolean; expiresAt: string } | undefined
|
||||
): AudienceMemberStatus {
|
||||
if (profileId) {
|
||||
return 'active';
|
||||
}
|
||||
if (invite?.acceptedAt) {
|
||||
return 'active';
|
||||
}
|
||||
if (!invite) {
|
||||
return 'pending';
|
||||
}
|
||||
if (invite.isRevoked) {
|
||||
return 'revoked';
|
||||
}
|
||||
if (new Date(invite.expiresAt) <= new Date()) {
|
||||
return 'expired';
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Normalize JSONB organization-invite metadata for course ids (matches importAudienceMembers storage). */
|
||||
export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
|
||||
let obj: Record<string, unknown> | null = null;
|
||||
if (metadata == null) {
|
||||
return [];
|
||||
}
|
||||
if (typeof metadata === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata) as unknown;
|
||||
obj = typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} else if (typeof metadata === 'object') {
|
||||
obj = metadata as Record<string, unknown>;
|
||||
}
|
||||
if (!obj) {
|
||||
return [];
|
||||
}
|
||||
const raw = obj.courseIds ?? obj.course_ids;
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
}
|
||||
@@ -59,11 +59,11 @@ html .dark {
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
@apply mt-0 text-4xl font-extrabold;
|
||||
@apply mt-0 text-4xl font-normal;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
@apply mt-10 border-b pb-2 text-3xl font-semibold transition-colors;
|
||||
@apply mt-0 text-3xl font-normal transition-colors;
|
||||
}
|
||||
|
||||
.prose h2:first-child {
|
||||
@@ -71,11 +71,11 @@ html .dark {
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
@apply mt-8 text-2xl font-semibold;
|
||||
@apply mt-0 text-2xl font-normal;
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
@apply mt-8 text-xl font-semibold;
|
||||
@apply mt-0 text-xl font-normal;
|
||||
}
|
||||
|
||||
.prose p,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getSessionData } from '$lib/utils/services/auth/session';
|
||||
import { getCioCookieString } from '$lib/utils/functions/cookies';
|
||||
import { getHasCioCookies } from '$lib/utils/functions/cookies';
|
||||
import { applyCspExtensions } from '$lib/utils/csp';
|
||||
import { type Handle, redirect } from '@sveltejs/kit';
|
||||
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
||||
@@ -26,7 +26,7 @@ export const handle: Handle = async (args) => {
|
||||
|
||||
const handlePagesRoutes: Handle = async ({ event, resolve }) => {
|
||||
const { pathname } = event.url;
|
||||
const hasCioCookie = Boolean(getCioCookieString(event.cookies));
|
||||
const hasCioCookie = getHasCioCookies(event.cookies);
|
||||
|
||||
if (isPublicRoute(pathname)) {
|
||||
return resolve(event);
|
||||
|
||||
@@ -119,7 +119,8 @@ class AppInitApi extends BaseApi {
|
||||
}
|
||||
|
||||
// This allows you to be on the landing page of an organization site and not be redirected
|
||||
if (isOrgSite && isPublicRoute(window.location.pathname)) {
|
||||
const path = window.location.pathname;
|
||||
if (isPublicRoute(path) && (path !== '/' || isOrgSite)) {
|
||||
console.log('no redirect is needed');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { CheckboxField, MultiSelectList } from '@cio/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
selectedProfileIds: string[];
|
||||
courses: Course[];
|
||||
onclose: () => void;
|
||||
onassigned: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), selectedProfileIds, courses, onclose, onassigned }: Props = $props();
|
||||
|
||||
let fields = $state({
|
||||
selectedCourseIds: new SvelteSet<string>(),
|
||||
sendEmail: true
|
||||
});
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
function toggleCourse(courseId: string) {
|
||||
if (fields.selectedCourseIds.has(courseId)) {
|
||||
fields.selectedCourseIds.delete(courseId);
|
||||
} else {
|
||||
fields.selectedCourseIds.add(courseId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
fields.selectedCourseIds.clear();
|
||||
fields.sendEmail = true;
|
||||
onclose();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (fields.selectedCourseIds.size === 0) {
|
||||
snackbar.error('audience.assign.snackbar_no_courses');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
await orgApi.assignAudienceToCourses({
|
||||
profileIds: selectedProfileIds,
|
||||
courseIds: [...fields.selectedCourseIds],
|
||||
sendEmail: fields.sendEmail
|
||||
});
|
||||
|
||||
fields.selectedCourseIds.clear();
|
||||
fields.sendEmail = true;
|
||||
onassigned();
|
||||
} catch {
|
||||
snackbar.error('audience.assign.snackbar_failed');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(v) => !v && handleClose()}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('audience.assign.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('audience.assign.description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<MultiSelectList
|
||||
heading={$t('audience.assign.select_courses')}
|
||||
emptyMessage={$t('audience.assign.select_courses_placeholder')}
|
||||
items={courses.map((c) => ({ id: c.id, label: c.title || c.id }))}
|
||||
isSelected={(id) => fields.selectedCourseIds.has(id)}
|
||||
onToggle={toggleCourse}
|
||||
namePrefix="assign-course"
|
||||
/>
|
||||
|
||||
<CheckboxField name="assign-send-email" label={$t('audience.assign.send_email')} bind:checked={fields.sendEmail} />
|
||||
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
{$t('audience.assign.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || fields.selectedCourseIds.size === 0}
|
||||
>
|
||||
{$t('audience.assign.submit')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import type { OrganizationAudienceMember } from '$features/org/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
canResendAudienceInvite,
|
||||
canRevokeAudienceInvite,
|
||||
statusBadgeVariant,
|
||||
statusLabelKey
|
||||
} from '$features/audience/utils/audience-utils';
|
||||
|
||||
interface Props {
|
||||
row: OrganizationAudienceMember;
|
||||
memberDetailHref: string | null;
|
||||
selected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
inviteActionEmail: string | null;
|
||||
onResendInvite: (email: string) => void;
|
||||
onRevokeInvite: (email: string) => void;
|
||||
}
|
||||
|
||||
let { row, memberDetailHref, selected, onToggleSelect, inviteActionEmail, onResendInvite, onRevokeInvite }: Props =
|
||||
$props();
|
||||
|
||||
const showInviteActions = $derived(
|
||||
!row.profileId && (canResendAudienceInvite(row.status) || canRevokeAudienceInvite(row.status))
|
||||
);
|
||||
</script>
|
||||
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-10">
|
||||
<Checkbox disabled={!row.profileId} checked={selected} onCheckedChange={onToggleSelect} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if memberDetailHref}
|
||||
<a href={memberDetailHref} class="ui:text-primary flex items-center gap-2 hover:underline">
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
{#if row.avatarUrl}
|
||||
<Avatar.Image src={row.avatarUrl} alt={row.name ? row.name : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{row.name}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
{#if row.avatarUrl}
|
||||
<Avatar.Image src={row.avatarUrl} alt={row.name ? row.name : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{row.name}
|
||||
</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{row.email}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={statusBadgeVariant(row.status)}>{$t(statusLabelKey(row.status))}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{row.createdAt}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{#if showInviteActions}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="hover:ui:bg-muted inline-flex items-center justify-center rounded-md p-1.5"
|
||||
aria-label={$t('audience.invite.row_actions_aria')}
|
||||
disabled={inviteActionEmail !== null}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if canResendAudienceInvite(row.status)}
|
||||
<DropdownMenu.Item disabled={inviteActionEmail === row.email} onclick={() => onResendInvite(row.email)}>
|
||||
{$t('audience.invite.resend')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canRevokeAudienceInvite(row.status)}
|
||||
<DropdownMenu.Item
|
||||
class="ui:text-destructive focus:ui:text-destructive"
|
||||
disabled={inviteActionEmail === row.email}
|
||||
onclick={() => onRevokeInvite(row.email)}
|
||||
>
|
||||
{$t('audience.invite.revoke')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
perPage: number;
|
||||
page: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
let { count, perPage, page, onPageChange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Pagination.Root {count} {perPage} {page} {onPageChange}>
|
||||
{#snippet children({ pages, currentPage: activePage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as pageItem (pageItem.key)}
|
||||
{#if pageItem.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link page={pageItem} isActive={activePage === pageItem.value}>
|
||||
{pageItem.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
|
||||
interface Props {
|
||||
rowCount?: number;
|
||||
}
|
||||
|
||||
let { rowCount = 20 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each Array(rowCount) as _, i (i)}
|
||||
<Skeleton class="h-12 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
hasSelection: boolean;
|
||||
selectedCount: number;
|
||||
searchValue?: string;
|
||||
onOpenAssign: () => void;
|
||||
}
|
||||
|
||||
let { hasSelection, selectedCount, searchValue = $bindable(''), onOpenAssign }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Page.BodyHeader>
|
||||
{#if hasSelection}
|
||||
<div class="flex items-center gap-3 rounded-md border px-4 py-2">
|
||||
<span class="ui:text-muted-foreground text-sm">
|
||||
{$t('audience.selected_count', { count: selectedCount })}
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" onclick={onOpenAssign}>
|
||||
{$t('audience.assign_courses')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="" />
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import type { OrganizationAudienceMember } from '$features/org/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import AudienceMemberRow from './audience-member-row.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
interface Header {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
headers: Header[];
|
||||
rows: OrganizationAudienceMember[];
|
||||
allPageSelected: boolean;
|
||||
somePageSelected: boolean;
|
||||
onToggleSelectAll: () => void;
|
||||
isRowSelected: (rowId: string) => boolean;
|
||||
onToggleRow: (rowId: string) => void;
|
||||
inviteActionEmail: string | null;
|
||||
onResendInvite: (email: string) => void;
|
||||
onRevokeInvite: (email: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
headers,
|
||||
rows,
|
||||
allPageSelected,
|
||||
somePageSelected,
|
||||
onToggleSelectAll,
|
||||
isRowSelected,
|
||||
onToggleRow,
|
||||
inviteActionEmail,
|
||||
onResendInvite,
|
||||
onRevokeInvite
|
||||
}: Props = $props();
|
||||
|
||||
function memberHref(row: OrganizationAudienceMember): string | null {
|
||||
if (!row.profileId) return null;
|
||||
return resolve(`${window.location.pathname}/${row.profileId}`, {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-10">
|
||||
<Checkbox
|
||||
checked={allPageSelected}
|
||||
indeterminate={somePageSelected}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label={$t('audience.select_all')}
|
||||
/>
|
||||
</Table.Head>
|
||||
{#each headers as header (header)}
|
||||
<Table.Head>{header.value}</Table.Head>
|
||||
{/each}
|
||||
<Table.Head class="w-12 text-right">
|
||||
<span class="sr-only">{$t('audience.invite.row_actions_aria')}</span>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each rows as row (String(row.id))}
|
||||
<AudienceMemberRow
|
||||
{row}
|
||||
memberDetailHref={memberHref(row)}
|
||||
selected={isRowSelected(String(row.id))}
|
||||
onToggleSelect={() => onToggleRow(String(row.id))}
|
||||
{inviteActionEmail}
|
||||
{onResendInvite}
|
||||
{onRevokeInvite}
|
||||
/>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
@@ -1 +1,6 @@
|
||||
// Components exports will be added here when needed
|
||||
export { default as AssignCoursesModal } from './assign-courses-modal.svelte';
|
||||
export { default as AudienceMemberRow } from './audience-member-row.svelte';
|
||||
export { default as AudiencePagination } from './audience-pagination.svelte';
|
||||
export { default as AudienceTable } from './audience-table.svelte';
|
||||
export { default as AudienceTableSkeleton } from './audience-table-skeleton.svelte';
|
||||
export { default as AudienceTableToolbar } from './audience-table-toolbar.svelte';
|
||||
|
||||
@@ -1,41 +1,68 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { page } from '$app/state';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
import { currentOrgMaxAudience } from '$lib/utils/store/org';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import type { OrganizationAudience } from '$features/org/utils/types';
|
||||
import AssignCoursesModal from '$features/audience/components/assign-courses-modal.svelte';
|
||||
import AudienceTableToolbar from '$features/audience/components/audience-table-toolbar.svelte';
|
||||
import AudienceTableSkeleton from '$features/audience/components/audience-table-skeleton.svelte';
|
||||
import AudienceTable from '$features/audience/components/audience-table.svelte';
|
||||
import AudiencePagination from '$features/audience/components/audience-pagination.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
audience?: OrganizationAudience;
|
||||
courses?: Course[];
|
||||
}
|
||||
|
||||
let { audience }: Props = $props();
|
||||
let { audience, courses = [] }: Props = $props();
|
||||
|
||||
// Initialize audience from prop if provided
|
||||
$effect(() => {
|
||||
if (audience) {
|
||||
orgApi.audience = audience;
|
||||
}
|
||||
});
|
||||
|
||||
const headers = [
|
||||
{ key: 'name', value: $t('audience.name') },
|
||||
{ key: 'email', value: $t('audience.email') },
|
||||
{ key: 'date_joined', value: $t('audience.date_joined') }
|
||||
];
|
||||
let pageSize = $state(5);
|
||||
const headers = $derived([
|
||||
{ key: 'name', value: t.get('audience.name') },
|
||||
{ key: 'email', value: t.get('audience.email') },
|
||||
{ key: 'status', value: t.get('audience.status') },
|
||||
{ key: 'date_joined', value: t.get('audience.date_joined') }
|
||||
]);
|
||||
|
||||
let inviteActionEmail = $state<string | null>(null);
|
||||
|
||||
async function handleResendInvite(email: string) {
|
||||
inviteActionEmail = email;
|
||||
try {
|
||||
await orgApi.resendAudienceInvite({ email });
|
||||
} finally {
|
||||
inviteActionEmail = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeInvite(email: string) {
|
||||
inviteActionEmail = email;
|
||||
try {
|
||||
await orgApi.revokeAudienceInvite({ email });
|
||||
} finally {
|
||||
inviteActionEmail = null;
|
||||
}
|
||||
}
|
||||
|
||||
let pageSize = $state(20);
|
||||
let currentPage = $state(1);
|
||||
let searchValue = $state('');
|
||||
let selectedIds = new SvelteSet<string>();
|
||||
let assignModalOpen = $state(false);
|
||||
|
||||
const filteredRows = $derived(
|
||||
searchValue
|
||||
@@ -50,6 +77,45 @@
|
||||
const endIndex = $derived(startIndex + pageSize);
|
||||
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
|
||||
|
||||
const selectablePageRows = $derived(paginatedRows.filter((row) => row.profileId));
|
||||
|
||||
const allPageSelected = $derived(
|
||||
selectablePageRows.length > 0 && selectablePageRows.every((row) => selectedIds.has(String(row.id)))
|
||||
);
|
||||
const somePageSelected = $derived(
|
||||
selectablePageRows.some((row) => selectedIds.has(String(row.id))) && !allPageSelected
|
||||
);
|
||||
const hasSelection = $derived(selectedIds.size > 0);
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allPageSelected) {
|
||||
for (const row of selectablePageRows) {
|
||||
selectedIds.delete(String(row.id));
|
||||
}
|
||||
} else {
|
||||
for (const row of selectablePageRows) {
|
||||
selectedIds.add(String(row.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRow(id: string) {
|
||||
if (selectedIds.has(id)) {
|
||||
selectedIds.delete(id);
|
||||
} else {
|
||||
selectedIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProfileIds = $derived(
|
||||
orgApi.audience.filter((r) => selectedIds.has(String(r.id)) && r.profileId).map((r) => r.profileId!)
|
||||
);
|
||||
|
||||
function handleAssigned() {
|
||||
assignModalOpen = false;
|
||||
selectedIds.clear();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (filteredRows.length > 0 && currentPage > totalPages) {
|
||||
currentPage = 1;
|
||||
@@ -61,90 +127,47 @@
|
||||
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
|
||||
{/if}
|
||||
|
||||
<Page.BodyHeader>
|
||||
<Search placeholder="Search..." bind:value={searchValue} class="" />
|
||||
</Page.BodyHeader>
|
||||
<AudienceTableToolbar
|
||||
{hasSelection}
|
||||
selectedCount={selectedIds.size}
|
||||
bind:searchValue
|
||||
onOpenAssign={() => (assignModalOpen = true)}
|
||||
/>
|
||||
|
||||
{#if orgApi.audience.length || orgApi.isLoading}
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Table -->
|
||||
{#if orgApi.isLoading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(pageSize) as _, i (i)}
|
||||
<Skeleton class="h-12 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
<AudienceTableSkeleton rowCount={pageSize} />
|
||||
{:else}
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each headers as header (header)}
|
||||
<Table.Head>{header.value}</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each paginatedRows as row (row.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<a
|
||||
href={`${page.url.href}/${row.id}`}
|
||||
class="ui:text-primary flex items-center gap-2 hover:underline"
|
||||
>
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
<Avatar.Image
|
||||
src={row.avatarUrl ? row.avatarUrl : '/logo-192.png'}
|
||||
alt={row.name ? row.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(row.name) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{row.name}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{row.email}</Table.Cell>
|
||||
<Table.Cell>{row.createdAt}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
<AudienceTable
|
||||
{headers}
|
||||
rows={paginatedRows}
|
||||
{allPageSelected}
|
||||
{somePageSelected}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
isRowSelected={(id) => selectedIds.has(id)}
|
||||
onToggleRow={toggleRow}
|
||||
{inviteActionEmail}
|
||||
onResendInvite={handleResendInvite}
|
||||
onRevokeInvite={handleRevokeInvite}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Pagination.Root
|
||||
count={filteredRows.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(page) => (currentPage = page)}
|
||||
>
|
||||
{#snippet children({ pages, currentPage: activePage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={activePage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
<AudiencePagination
|
||||
count={filteredRows.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(p) => (currentPage = p)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
|
||||
{/if}
|
||||
|
||||
<AssignCoursesModal
|
||||
bind:open={assignModalOpen}
|
||||
{selectedProfileIds}
|
||||
{courses}
|
||||
onclose={() => (assignModalOpen = false)}
|
||||
onassigned={handleAssigned}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MultiSelectList } from '@cio/ui';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
courses: Course[];
|
||||
}
|
||||
|
||||
let { courses }: Props = $props();
|
||||
|
||||
let recipientCsv = $state('');
|
||||
let courseAccessMode = $state('none');
|
||||
let selectedCourseIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
function toggleCourse(courseId: string) {
|
||||
if (selectedCourseIds.has(courseId)) {
|
||||
selectedCourseIds.delete(courseId);
|
||||
} else {
|
||||
selectedCourseIds.add(courseId);
|
||||
}
|
||||
}
|
||||
|
||||
const audiencePath = $derived(page.url.pathname.replace(/\/import$/, ''));
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!recipientCsv.trim()) {
|
||||
snackbar.error('audience.import.snackbar_no_emails');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const result = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
allCourses: courseAccessMode === 'all',
|
||||
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
|
||||
sendEmail
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await goto(resolve(audiencePath, {}));
|
||||
}
|
||||
} catch {
|
||||
snackbar.error('audience.import.snackbar_failed');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('audience.import.description')}</p>
|
||||
|
||||
<TextareaField
|
||||
label={$t('audience.import.emails_label')}
|
||||
bind:value={recipientCsv}
|
||||
rows={6}
|
||||
className="w-full"
|
||||
placeholder={$t('audience.import.emails_placeholder')}
|
||||
/>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.course_access')}</Label>
|
||||
<RadioGroup.Root bind:value={courseAccessMode} class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="none" id="course-none" />
|
||||
<Label for="course-none" class="font-normal">{$t('audience.import.no_courses')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="all" id="course-all" />
|
||||
<Label for="course-all" class="font-normal">{$t('audience.import.all_courses')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="select" id="course-select" />
|
||||
<Label for="course-select" class="font-normal">{$t('audience.import.select_courses')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
{#if courseAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.assign.select_courses')}
|
||||
emptyMessage={$t('audience.import.select_courses_placeholder')}
|
||||
items={courses.map((c) => ({ id: c.id, label: c.title || c.id }))}
|
||||
isSelected={(id) => selectedCourseIds.has(id)}
|
||||
onToggle={toggleCourse}
|
||||
namePrefix="import-course"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button onclick={handleSubmit} loading={isSubmitting} disabled={isSubmitting}>
|
||||
{$t('audience.import.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as AudiencePage } from './audience.svelte';
|
||||
export { default as ImportAudiencePage } from './import.svelte';
|
||||
export { default as UserAnalyticsPage } from './user-analytics.svelte';
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
|
||||
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
|
||||
|
||||
import { BackButton } from '@cio/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||
@@ -61,10 +61,7 @@
|
||||
|
||||
{#if userAnalytics}
|
||||
<div class="p-5">
|
||||
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}>
|
||||
<ArrowLeftIcon size={16} />
|
||||
{$t('community.ask.go_back')}
|
||||
</a>
|
||||
<BackButton href={`${$currentOrgPath}/audience`} label={$t('community.ask.go_back')} />
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-1">
|
||||
@@ -116,7 +113,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each filteredCourses as course, index}
|
||||
{#each filteredCourses as course (course.id)}
|
||||
{#key index}
|
||||
<div
|
||||
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { BadgeVariant } from '@cio/ui/base/badge';
|
||||
|
||||
export function statusBadgeVariant(status: string): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'secondary';
|
||||
case 'pending':
|
||||
return 'outline';
|
||||
case 'expired':
|
||||
case 'revoked':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabelKey(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'audience.status_active';
|
||||
case 'pending':
|
||||
return 'audience.status_pending';
|
||||
case 'expired':
|
||||
return 'audience.status_expired';
|
||||
case 'revoked':
|
||||
return 'audience.status_revoked';
|
||||
default:
|
||||
return 'audience.status_pending';
|
||||
}
|
||||
}
|
||||
|
||||
export function canResendAudienceInvite(status: string): boolean {
|
||||
return status === 'pending' || status === 'expired' || status === 'revoked';
|
||||
}
|
||||
|
||||
export function canRevokeAudienceInvite(status: string): boolean {
|
||||
return status === 'pending';
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { Vote } from '$features/ui';
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
@@ -40,11 +40,15 @@
|
||||
<header class="flex items-center justify-between p-2 leading-none">
|
||||
<div class="flex items-center text-black">
|
||||
<Avatar.Root class="size-7">
|
||||
<Avatar.Image
|
||||
src={comment.author?.avatarUrl ? comment.author.avatarUrl : '/logo-192.png'}
|
||||
alt={comment.author?.fullname ? comment.author.fullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(comment.author?.fullname) || 'U'}</Avatar.Fallback>
|
||||
{#if comment.author?.avatarUrl}
|
||||
<Avatar.Image
|
||||
src={comment.author.avatarUrl}
|
||||
alt={comment.author?.fullname ? comment.author.fullname : 'User'}
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3.5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<p class="ml-2 text-sm dark:text-white">{comment.author?.fullname}</p>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
@@ -17,19 +18,20 @@
|
||||
import { Vote } from '$features/ui';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { TextEditor } from '$features/ui';
|
||||
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import type { CommunityQuestionSuccess } from '../utils/types';
|
||||
import { currentCommunityQuestion } from '../utils/store';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
backHref?: string;
|
||||
question?: CommunityQuestionSuccess['data'] | null;
|
||||
}
|
||||
|
||||
let { question }: Props = $props();
|
||||
let { question, backHref }: Props = $props();
|
||||
|
||||
let commentEditor: TiptapEditor | undefined = $state();
|
||||
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
|
||||
@@ -100,6 +102,9 @@
|
||||
<Page.Root class="mx-auto w-[90%] md:mx-10 md:max-w-3xl lg:mb-20">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
{#if backHref}
|
||||
<BackButton href={backHref} label={$t('community.ask.go_back')} />
|
||||
{/if}
|
||||
{#if communityApi.isEditMode}
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<InputField
|
||||
@@ -175,13 +180,15 @@
|
||||
<div class="flex items-center justify-between p-2 leading-none">
|
||||
<div class="flex items-center text-black no-underline hover:underline">
|
||||
<Avatar.Root class="h-7 w-7">
|
||||
<Avatar.Image
|
||||
src={communityApi.question?.authorAvatarUrl
|
||||
? communityApi.question?.authorAvatarUrl
|
||||
: '/logo-192.png'}
|
||||
alt={communityApi.question?.authorFullname ? communityApi.question?.authorFullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(communityApi.question?.authorFullname) || 'U'}</Avatar.Fallback>
|
||||
{#if communityApi.question?.authorAvatarUrl}
|
||||
<Avatar.Image
|
||||
src={communityApi.question.authorAvatarUrl}
|
||||
alt={communityApi.question?.authorFullname ? communityApi.question.authorFullname : 'User'}
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3.5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="ml-2 text-sm dark:text-white">{communityApi.question?.authorFullname}</p>
|
||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { resolve } from '$app/paths';
|
||||
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
|
||||
|
||||
@@ -62,10 +64,8 @@
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">{$t('community.all')}</Select.Item>
|
||||
{#each communityApi.courses as course}
|
||||
{#if course.id}
|
||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||
{/if}
|
||||
{#each communityApi.courses as course (course.id)}
|
||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -77,10 +77,10 @@
|
||||
<CommunityListLoader />
|
||||
<CommunityListLoader />
|
||||
{:else}
|
||||
{#each filteredQuestions as question}
|
||||
{#each filteredQuestions as question (question.slug)}
|
||||
<Item.Root variant="outline">
|
||||
{#snippet child({ props })}
|
||||
<a href="{$basePath}/community/{question.slug}" {...props}>
|
||||
<a href={resolve(`${$basePath}/community/[slug]`, { slug: question.slug })} {...props}>
|
||||
<Vote value={question.votes} />
|
||||
<Item.Content class="gap-y-0.5">
|
||||
<Item.Title class="mt-0">
|
||||
@@ -89,11 +89,15 @@
|
||||
<Item.Description>
|
||||
{question?.authorFullname} asked {calDateDiff(question.createdAt || new Date())}
|
||||
</Item.Description>
|
||||
<a class="m-0" href="/courses/{question.courseId}" onclick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
class="m-0! justify-start! px-0! py-0! underline"
|
||||
href={resolve(`/courses/[id]`, { id: question.courseId })}
|
||||
variant="ghost"
|
||||
>
|
||||
<span class="text-muted-foreground p-0 text-xs">
|
||||
#{question?.courseTitle}
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -24,6 +24,7 @@ import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { coursesApi } from './courses.svelte';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { CourseMembers } from '../utils/types';
|
||||
@@ -499,6 +500,7 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
}),
|
||||
logContext: 'deleting course',
|
||||
onSuccess: (response) => {
|
||||
coursesApi.removeCourseFromLists(courseId);
|
||||
if (response.data) {
|
||||
snackbar.success('Course deleted successfully');
|
||||
this.success = true;
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
UserEnrolledCourses
|
||||
} from '$features/course/types';
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
/**
|
||||
* API class for course operations
|
||||
*/
|
||||
@@ -16,12 +18,19 @@ export class CoursesApi extends BaseApiWithErrors {
|
||||
enrolledCourses = $state<UserEnrolledCourses>([]);
|
||||
recommendedCourses = $state<RecommendedCourses>([]);
|
||||
|
||||
/** Keeps list UIs in sync after a course is deleted (server data is updated separately on navigation). */
|
||||
removeCourseFromLists(courseId: string) {
|
||||
this.orgCourses = this.orgCourses.filter((c) => c.id !== courseId);
|
||||
this.enrolledCourses = this.enrolledCourses.filter((c) => c.id !== courseId);
|
||||
this.recommendedCourses = this.recommendedCourses.filter((c) => c.id !== courseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches org courses for the current organization
|
||||
* Org ID is automatically added from currentOrg store
|
||||
*/
|
||||
async getOrgCourses(tagSlugs: string[] = []) {
|
||||
const normalizedTagSlugs = Array.from(new Set(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
|
||||
const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
|
||||
|
||||
return this.execute<GetOrgCoursesRequest>({
|
||||
requestFn: () =>
|
||||
@@ -32,7 +41,7 @@ export class CoursesApi extends BaseApiWithErrors {
|
||||
tags: normalizedTagSlugs.join(',')
|
||||
}
|
||||
}
|
||||
: {}
|
||||
: { query: {} }
|
||||
),
|
||||
logContext: 'fetching org courses',
|
||||
onSuccess: (response) => {
|
||||
|
||||
@@ -77,7 +77,6 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
|
||||
translations = $state<Record<string, Record<TLocale, string>>>({});
|
||||
note = $state('');
|
||||
private autoSaveTimeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Gets a lesson by ID
|
||||
@@ -841,23 +840,6 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts autosave with debouncing
|
||||
* @param courseId Course ID
|
||||
* @param lessonId Lesson ID
|
||||
*/
|
||||
startAutoSave(courseId: string, lessonId: string) {
|
||||
if (!this.isDirty || this.isSaving) return;
|
||||
if (this.autoSaveTimeoutId) clearTimeout(this.autoSaveTimeoutId);
|
||||
|
||||
this.isSaving = true;
|
||||
this.autoSaveTimeoutId = setTimeout(async () => {
|
||||
await this.save(courseId, lessonId);
|
||||
this.isSaving = false;
|
||||
this.autoSaveTimeoutId = null;
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
export const lessonApi = new LessonApi();
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import EmptyState from './empty-state.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import type { CourseAnalytics } from '$features/course/utils/types';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
|
||||
interface Props {
|
||||
students?: CourseAnalytics['students'];
|
||||
@@ -37,7 +36,10 @@
|
||||
if (!student) return;
|
||||
|
||||
goto(
|
||||
resolve(`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`)
|
||||
resolve(
|
||||
`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`,
|
||||
{}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,11 +81,15 @@
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar.Root class="size-8">
|
||||
<Avatar.Image
|
||||
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'}
|
||||
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
|
||||
{#if student.profile.avatar_url}
|
||||
<Avatar.Image
|
||||
src={student.profile.avatar_url}
|
||||
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
|
||||
import CardDropdown from './card-dropdown.svelte';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CourseTagsOverflow from './course-tags-overflow.svelte';
|
||||
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
|
||||
import type { OrgPublicCourses } from '$features/org/utils/types';
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
title: course.title,
|
||||
type: course.type,
|
||||
description: course.description,
|
||||
isPublished: course.isPublished,
|
||||
isPublished: !!course.isPublished,
|
||||
pricingData: {
|
||||
cost: course.cost,
|
||||
discount: course.metadata?.discount || 0,
|
||||
@@ -147,25 +148,14 @@
|
||||
</Item.Header>
|
||||
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-1 min-h-[44px] text-base!">
|
||||
<Item.Title class="line-clamp-2 min-h-14 text-base!">
|
||||
{title}
|
||||
</Item.Title>
|
||||
|
||||
<Item.Description class="min-h-[63px]">{description}</Item.Description>
|
||||
<div class="ui:text-muted-foreground flex min-h-[12px] flex-wrap items-center gap-1">
|
||||
{#if !isLMS && courseTags.length > 0}
|
||||
{#each courseTags as tag (tag.id)}
|
||||
<Item.SubDescription class="border-border inline-flex items-center gap-1.5 rounded-full border px-2">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{tag.name}</span>
|
||||
</Item.SubDescription>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if !isLMS}
|
||||
<CourseTagsOverflow tags={courseTags} variant="card" />
|
||||
{/if}
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { COURSE_SORT_OPTIONS } from '../utils/constants';
|
||||
import {
|
||||
COURSE_SORT_OPTIONS,
|
||||
CourseSortOrder,
|
||||
DEFAULT_COURSE_SORT,
|
||||
DEFAULT_SORT_ORDER,
|
||||
type CourseSortBy
|
||||
} from '../utils/constants';
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import * as ToggleGroup from '@cio/ui/base/toggle-group';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
@@ -27,8 +33,8 @@
|
||||
type SortOption = (typeof COURSE_SORT_OPTIONS)[number];
|
||||
|
||||
interface Props {
|
||||
selectedId?: string;
|
||||
selectedOrder?: 'asc' | 'desc';
|
||||
sortKey?: CourseSortBy;
|
||||
selectedOrder?: CourseSortOrder;
|
||||
selectedTags?: string[];
|
||||
tagGroups?: FilterTagGroup[];
|
||||
sortOptions?: readonly SortOption[];
|
||||
@@ -38,8 +44,8 @@
|
||||
}
|
||||
|
||||
let {
|
||||
selectedId = $bindable('0'),
|
||||
selectedOrder = $bindable('desc' as 'asc' | 'desc'),
|
||||
sortKey = $bindable(DEFAULT_COURSE_SORT),
|
||||
selectedOrder = $bindable(DEFAULT_SORT_ORDER),
|
||||
selectedTags = [],
|
||||
tagGroups = [],
|
||||
sortOptions = COURSE_SORT_OPTIONS,
|
||||
@@ -50,7 +56,9 @@
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
const hasActiveFilters = $derived(selectedId !== '0' || selectedOrder !== 'desc' || selectedTags.length > 0);
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
|
||||
);
|
||||
|
||||
function isTagSelected(tagSlug: string) {
|
||||
return selectedTags.includes(tagSlug);
|
||||
@@ -96,8 +104,8 @@
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selectedId === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => (selectedId = option.value)}
|
||||
variant={sortKey === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => (sortKey = option.value)}
|
||||
>
|
||||
{$t(option.label)}
|
||||
</Button>
|
||||
@@ -115,13 +123,13 @@
|
||||
size="sm"
|
||||
value={selectedOrder}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'asc' || value === 'desc') {
|
||||
if (value === CourseSortOrder.Asc || value === CourseSortOrder.Desc) {
|
||||
selectedOrder = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value={CourseSortOrder.Asc}>{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value={CourseSortOrder.Desc}>{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import * as Tooltip from '@cio/ui/base/tooltip';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tags: Tag[];
|
||||
variant?: 'card' | 'table';
|
||||
}
|
||||
|
||||
let { tags, variant = 'card' }: Props = $props();
|
||||
|
||||
let tagsRowEl: HTMLDivElement | undefined = $state();
|
||||
let tagsMeasureEl: HTMLDivElement | undefined = $state();
|
||||
let overflowBadgeMeasureEl: HTMLSpanElement | undefined = $state();
|
||||
/** -1 = not measured yet (show all tags until layout runs) */
|
||||
let visibleTagCount = $state(-1);
|
||||
|
||||
const tagGapPx = $derived(variant === 'table' ? 8 : 4);
|
||||
const gapClass = $derived(variant === 'table' ? 'gap-2' : 'gap-1');
|
||||
|
||||
function computeVisibleTagCount() {
|
||||
if (tags.length === 0) {
|
||||
visibleTagCount = 0;
|
||||
return;
|
||||
}
|
||||
if (!tagsRowEl || !tagsMeasureEl) {
|
||||
visibleTagCount = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const widths = Array.from(tagsMeasureEl.children).map((el) => (el as HTMLElement).offsetWidth);
|
||||
const containerWidth = tagsRowEl.clientWidth;
|
||||
const badgeMaxWidth = overflowBadgeMeasureEl?.offsetWidth ?? 40;
|
||||
const n = widths.length;
|
||||
const gap = tagGapPx;
|
||||
|
||||
if (containerWidth <= 0) {
|
||||
visibleTagCount = n;
|
||||
return;
|
||||
}
|
||||
|
||||
let best = 0;
|
||||
for (let k = 0; k <= n; k++) {
|
||||
const rest = n - k;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < k; i++) {
|
||||
sum += widths[i] ?? 0;
|
||||
if (i > 0) sum += gap;
|
||||
}
|
||||
const needBadge = rest > 0;
|
||||
const total = sum + (needBadge ? gap + badgeMaxWidth : 0);
|
||||
if (total <= containerWidth) {
|
||||
best = k;
|
||||
}
|
||||
}
|
||||
visibleTagCount = best;
|
||||
}
|
||||
|
||||
const displayTagCount = $derived(visibleTagCount < 0 ? tags.length : visibleTagCount);
|
||||
|
||||
$effect(() => {
|
||||
void tags;
|
||||
void tagsRowEl;
|
||||
void tagsMeasureEl;
|
||||
void overflowBadgeMeasureEl;
|
||||
void variant;
|
||||
|
||||
const schedule = () => {
|
||||
requestAnimationFrame(() => {
|
||||
computeVisibleTagCount();
|
||||
});
|
||||
};
|
||||
|
||||
tick().then(schedule);
|
||||
|
||||
if (!tagsRowEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(schedule);
|
||||
ro.observe(tagsRowEl);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
const hiddenTags = $derived(tags.slice(displayTagCount));
|
||||
const hiddenTagsLabel = $derived(hiddenTags.map((t) => t.name).join(', '));
|
||||
</script>
|
||||
|
||||
{#if tags.length > 0}
|
||||
<div class="relative min-h-[12px] w-full min-w-0">
|
||||
<div
|
||||
class="pointer-events-none invisible absolute top-0 left-0 -z-10 flex {gapClass}"
|
||||
bind:this={tagsMeasureEl}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each tags as tag (tag.id)}
|
||||
{#if variant === 'card'}
|
||||
<Item.SubDescription class="border-border inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{tag.name}</span>
|
||||
</Item.SubDescription>
|
||||
{:else}
|
||||
<span class="ui:text-muted-foreground text-2xs inline-flex shrink-0 items-center gap-1.5">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{tag.name}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<span
|
||||
bind:this={overflowBadgeMeasureEl}
|
||||
class="border-border ui:text-muted-foreground pointer-events-none invisible absolute top-0 left-0 -z-10 inline-flex shrink-0 items-center rounded-full border {variant ===
|
||||
'table'
|
||||
? 'text-2xs px-1.5 py-0.5'
|
||||
: 'px-2 text-xs'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
+{tags.length}
|
||||
</span>
|
||||
<div
|
||||
bind:this={tagsRowEl}
|
||||
class="ui:text-muted-foreground flex min-w-0 flex-nowrap items-center overflow-hidden {gapClass} {variant ===
|
||||
'table'
|
||||
? 'text-2xs'
|
||||
: ''}"
|
||||
>
|
||||
{#each tags.slice(0, displayTagCount) as tag (tag.id)}
|
||||
{#if variant === 'card'}
|
||||
<Item.SubDescription class="border-border inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{tag.name}</span>
|
||||
</Item.SubDescription>
|
||||
{:else}
|
||||
<span class="inline-flex shrink-0 items-center gap-1.5">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{tag.name}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if hiddenTags.length > 0}
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
{#if variant === 'card'}
|
||||
<Item.SubDescription
|
||||
{...props}
|
||||
class="border-border inline-flex shrink-0 cursor-default items-center gap-1.5 rounded-full border px-2"
|
||||
aria-label={$t('courses.course_card.tags_overflow_aria', {
|
||||
count: hiddenTags.length
|
||||
})}
|
||||
>
|
||||
+{hiddenTags.length}
|
||||
</Item.SubDescription>
|
||||
{:else}
|
||||
<span
|
||||
{...props}
|
||||
class="border-border ui:text-muted-foreground text-2xs inline-flex shrink-0 cursor-default items-center rounded-full border px-1.5 py-0.5"
|
||||
aria-label={$t('courses.course_card.tags_overflow_aria', {
|
||||
count: hiddenTags.length
|
||||
})}
|
||||
>
|
||||
+{hiddenTags.length}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" sideOffset={4} class="max-w-xs">
|
||||
{hiddenTagsLabel}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionContractKey } from '@cio/question-types';
|
||||
import type { AnswerData } from '@cio/question-types';
|
||||
import type { AnswerData, ExerciseQuestionModel } from '@cio/question-types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { Grade, ReasonBox } from '$features/ui/question';
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
@@ -8,15 +9,15 @@
|
||||
import type { QuestionnaireMetaData } from './store';
|
||||
|
||||
interface Props {
|
||||
questions?: any;
|
||||
questions?: Question[];
|
||||
questionnaireMetaData?: Partial<QuestionnaireMetaData>;
|
||||
/** Per-question scores (e.g. from `questionnaireMetaData.grades` or grading modal `questionAnswerByPoint`). */
|
||||
grades?: Record<string, number>;
|
||||
onSubmit?: any;
|
||||
onPrevious?: any;
|
||||
disableGrading?: boolean;
|
||||
isGradeWithAI?: boolean;
|
||||
isLoading?: boolean;
|
||||
reasons?: any;
|
||||
/** Optional AI grading copy keyed by question id. */
|
||||
reasons?: Record<string, string>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -33,12 +34,12 @@
|
||||
isGradeWithAI = false;
|
||||
}
|
||||
|
||||
function rejectGradeSuggestion(questionId) {
|
||||
function rejectGradeSuggestion(questionId: Question['id']) {
|
||||
isGradeWithAI = false;
|
||||
grades[questionId] = 0;
|
||||
grades[String(questionId)] = 0;
|
||||
}
|
||||
|
||||
function getAnswerForQuestion(questionModel, fallbackIndex = 0) {
|
||||
function getAnswerForQuestion(questionModel: ExerciseQuestionModel, fallbackIndex = 0): AnswerData | undefined {
|
||||
const questionKey = getExerciseQuestionContractKey(questionModel, fallbackIndex);
|
||||
return _questionnaireMetaData?.answers?.[questionKey];
|
||||
}
|
||||
@@ -59,11 +60,11 @@
|
||||
|
||||
{#if disableGrading && !Object.keys(grades || {}).length}
|
||||
<ExerciseQuestion.QuestionList
|
||||
contract={{ mode: 'view', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
|
||||
contract={{ mode: 'review', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
|
||||
itemClass="mb-4"
|
||||
/>
|
||||
{:else if disableGrading && Object.keys(grades || {}).length}
|
||||
{#each questions as currentQuestion, index}
|
||||
{#each questions as currentQuestion, index (currentQuestion.id)}
|
||||
{@const questionModel = questionModels[index]}
|
||||
<div class="mb-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -73,7 +74,7 @@
|
||||
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'view',
|
||||
mode: 'review',
|
||||
question: questionModel,
|
||||
answer: getAnswerForQuestion(questionModel, index),
|
||||
labels: questionLabels,
|
||||
@@ -83,7 +84,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each questions as currentQuestion, index}
|
||||
{#each questions as currentQuestion, index (currentQuestion.id)}
|
||||
{@const questionModel = questionModels[index]}
|
||||
<div class="mb-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -93,7 +94,7 @@
|
||||
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'view',
|
||||
mode: 'review',
|
||||
question: questionModel,
|
||||
answer: getAnswerForQuestion(questionModel, index),
|
||||
labels: questionLabels,
|
||||
|
||||
@@ -18,6 +18,9 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'common.correct_badge': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.common.correct_badge'
|
||||
),
|
||||
'common.incorrect_badge': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.common.incorrect_badge'
|
||||
),
|
||||
'common.not_set': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.not_set'),
|
||||
'navigation.previous': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.previous'),
|
||||
'navigation.next': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.next'),
|
||||
@@ -131,6 +134,12 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'short_answer.preview.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.preview.helper'
|
||||
),
|
||||
'short_answer.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.review.your_answer_label'
|
||||
),
|
||||
'short_answer.review.accepted_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.review.accepted_label'
|
||||
),
|
||||
'numeric.edit.correct_value_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_label'
|
||||
),
|
||||
@@ -158,6 +167,15 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'numeric.preview.tolerance_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.tolerance_label'
|
||||
),
|
||||
'numeric.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.your_answer_label'
|
||||
),
|
||||
'numeric.review.in_range': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.in_range'
|
||||
),
|
||||
'numeric.review.out_of_range': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.out_of_range'
|
||||
),
|
||||
'fill_blank.edit.accepted_answers_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder'
|
||||
),
|
||||
@@ -167,6 +185,9 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'fill_blank.preview.accepted_answers_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.preview.accepted_answers_label'
|
||||
),
|
||||
'fill_blank.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.review.your_answer_label'
|
||||
),
|
||||
'file_upload.edit.accepted_types_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
|
||||
),
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@
|
||||
<div class="pb-4">
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'view',
|
||||
mode: 'review',
|
||||
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
|
||||
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
|
||||
labels: questionLabels,
|
||||
|
||||
@@ -7,6 +7,7 @@ export { default as CourseFilterPopover } from './course-filter-popover.svelte';
|
||||
export { default as CourseTagPicker } from './course-tag-picker.svelte';
|
||||
export { default as CreateCourseButton } from './create-course-button.svelte';
|
||||
export { default as CourseList } from './list.svelte';
|
||||
export { default as CourseTagsOverflow } from './course-tags-overflow.svelte';
|
||||
export { default as CourseListLoader } from './list-loader.svelte';
|
||||
export { default as NewCourseModal } from './new-course-modal.svelte';
|
||||
export { default as QuestionContainer } from './question-container.svelte';
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { untrack } from 'svelte';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { DeleteModal } from '$features/ui';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
@@ -99,11 +99,12 @@
|
||||
<div>
|
||||
<div class="flex h-full items-start gap-3">
|
||||
<Avatar.Root class="mt-2 size-6!">
|
||||
<Avatar.Image
|
||||
src={$profile.avatarUrl ? $profile.avatarUrl : '/logo-192.png'}
|
||||
alt={$profile.fullname ? $profile.fullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback>
|
||||
{#if $profile.avatarUrl}
|
||||
<Avatar.Image src={$profile.avatarUrl} alt={$profile.fullname ? $profile.fullname : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="h-full w-full">
|
||||
<TextareaField
|
||||
@@ -125,11 +126,12 @@
|
||||
{#each comments as commentItem (commentItem.id)}
|
||||
<div class="mt-2 flex items-start gap-3 pb-2">
|
||||
<Avatar.Root class="mt-2 size-6!">
|
||||
<Avatar.Image
|
||||
src={commentItem.avatar ? commentItem.avatar : '/logo-192.png'}
|
||||
alt={commentItem.name ? commentItem.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(commentItem.name) || 'U'}</Avatar.Fallback>
|
||||
{#if commentItem.avatar}
|
||||
<Avatar.Image src={commentItem.avatar} alt={commentItem.name ? commentItem.name : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<div class="w-full rounded-md border px-3 py-2 dark:border-neutral-700">
|
||||
|
||||
+54
-22
@@ -5,6 +5,7 @@
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Tooltip from '@cio/ui/base/tooltip';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi, lessonApi } from '$features/course/api';
|
||||
@@ -115,8 +116,25 @@
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (INTERACTIVE_TAGS.has(target.tagName) || target.isContentEditable) return;
|
||||
|
||||
if (event.key === 'ArrowLeft' && !isPrevDisabled) {
|
||||
event.preventDefault();
|
||||
goToContent(prevNextContent.prev);
|
||||
} else if (event.key === 'ArrowRight' && !isNextDisabled) {
|
||||
event.preventDefault();
|
||||
goToContent(prevNextContent.next);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if showMarkComplete && lessonId}
|
||||
<Button
|
||||
@@ -131,27 +149,41 @@
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
onclick={() => goToContent(prevNextContent.prev)}
|
||||
disabled={isPrevDisabled}
|
||||
aria-label={$t('course.navItem.lessons.prev')}
|
||||
title={$t('course.navItem.lessons.prev')}
|
||||
>
|
||||
<ChevronLeftIcon size={14} />
|
||||
</Button>
|
||||
<Tooltip.Provider>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
onclick={() => goToContent(prevNextContent.prev)}
|
||||
disabled={isPrevDisabled}
|
||||
aria-label={$t('course.navItem.lessons.prev')}
|
||||
>
|
||||
<ChevronLeftIcon size={14} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom" sideOffset={4}>
|
||||
{$t('course.navItem.lessons.prev_shortcut')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
onclick={() => goToContent(prevNextContent.next)}
|
||||
disabled={isNextDisabled}
|
||||
aria-label={$t('course.navItem.lessons.next')}
|
||||
title={$t('course.navItem.lessons.next')}
|
||||
>
|
||||
<ChevronRightIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
onclick={() => goToContent(prevNextContent.next)}
|
||||
disabled={isNextDisabled}
|
||||
aria-label={$t('course.navItem.lessons.next')}
|
||||
>
|
||||
<ChevronRightIcon size={14} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom" sideOffset={4}>
|
||||
{$t('course.navItem.lessons.next_shortcut')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import AIButton from '$features/course/components/lesson/ai-button.svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { saveDraft } from '$features/course/utils/lesson-draft';
|
||||
|
||||
interface Props {
|
||||
mode?: (typeof MODES)[keyof typeof MODES];
|
||||
@@ -33,12 +34,7 @@
|
||||
}
|
||||
lessonApi.translations[lessonId][lessonApi.currentLocale] = `${content}`;
|
||||
|
||||
try {
|
||||
localStorage.setItem(`lesson-${lessonId}-${lessonApi.currentLocale}`, `${content}`);
|
||||
} catch (error) {
|
||||
console.error('Error saving lesson note to localStorage', error);
|
||||
}
|
||||
|
||||
saveDraft(lessonId, lessonApi.currentLocale, `${content}`);
|
||||
lessonApi.isDirty = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
@@ -7,6 +8,7 @@
|
||||
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CourseTagsOverflow from './course-tags-overflow.svelte';
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
@@ -60,29 +62,18 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(`/courses/${id}`)}>
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(resolve(`/courses/[id]`, { id }))}>
|
||||
<Table.Cell class="truncate"><p class="font-semibold">{title}</p></Table.Cell>
|
||||
<Table.Cell class="truncate">
|
||||
<p>{description}</p>
|
||||
</Table.Cell>
|
||||
{#if !isMobileStore.current}
|
||||
<Table.Cell class="truncate">{type}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Table.Cell class="w-3/12 min-w-0">
|
||||
{#if tags.length === 0}
|
||||
<span class="ui:text-muted-foreground text-2xs">-</span>
|
||||
{:else}
|
||||
<div class="ui:text-muted-foreground text-2xs flex max-w-[220px] flex-wrap items-center gap-2">
|
||||
{#each tags as tag (tag.id)}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{tag.name}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<CourseTagsOverflow {tags} variant="table" />
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{totalLessons}</Table.Cell>
|
||||
|
||||
+24
-38
@@ -3,10 +3,10 @@
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { inviteSettingsStore, studentInviteLinkStore } from './store';
|
||||
import { getInvitePayload } from './invite-utils';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { peopleApi } from '$features/course/api';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
||||
|
||||
@@ -19,72 +19,58 @@
|
||||
|
||||
let recipientCsv = $state('');
|
||||
let sendInviteEmails = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
async function createEmailInvites() {
|
||||
async function inviteNewStudents() {
|
||||
if (!courseId) return;
|
||||
if (!recipientCsv.trim()) {
|
||||
snackbar.error(`${INVITE_MODAL}.snackbar_add_one_recipient`);
|
||||
snackbar.error('audience.import.snackbar_no_emails');
|
||||
return;
|
||||
}
|
||||
|
||||
studentInviteLinkStore.update((s) => ({ ...s, isCreating: true }));
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const settings = $inviteSettingsStore;
|
||||
const payload = getInvitePayload(settings);
|
||||
const response = await peopleApi.createStudentInvite(courseId, {
|
||||
...payload,
|
||||
const response = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
courseIds: [courseId],
|
||||
sendEmail: sendInviteEmails
|
||||
});
|
||||
|
||||
const createdCount = response?.invites?.length || 0;
|
||||
const sent = response?.delivery?.sent || 0;
|
||||
const failed = response?.delivery?.failed || 0;
|
||||
const duplicates = response?.duplicatesSkipped?.length || 0;
|
||||
|
||||
snackbar.success(
|
||||
$t(`${INVITE_MODAL}.snackbar_email_invites_success`, {
|
||||
count: createdCount,
|
||||
sent,
|
||||
failed,
|
||||
duplicates
|
||||
})
|
||||
);
|
||||
|
||||
recipientCsv = '';
|
||||
onInviteCreated?.();
|
||||
if (response) {
|
||||
recipientCsv = '';
|
||||
await courseApi.refreshCourse(courseId, $profile.id);
|
||||
await onInviteCreated?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create email invites', error);
|
||||
snackbar.error(`${INVITE_MODAL}.snackbar_failed_create_email_invites`);
|
||||
console.error('Failed to invite new students into organization', error);
|
||||
snackbar.error('audience.import.snackbar_failed');
|
||||
} finally {
|
||||
studentInviteLinkStore.update((s) => ({ ...s, isCreating: false }));
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
const isCreating = $derived($studentInviteLinkStore.isCreating);
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border p-4">
|
||||
<p class="mb-2 text-base font-semibold">{$t(`${INVITE_MODAL}.direct_email_bulk`)}</p>
|
||||
<p class="mb-2 text-base font-semibold">{$t(`${INVITE_MODAL}.invite_new_students_title`)}</p>
|
||||
<p class="ui:text-muted-foreground mb-2 text-sm">
|
||||
{$t(`${INVITE_MODAL}.direct_email_description`)}
|
||||
{$t(`${INVITE_MODAL}.invite_new_students_description`)}
|
||||
</p>
|
||||
<TextareaField
|
||||
label={$t(`${INVITE_MODAL}.recipient_emails`)}
|
||||
label={$t('audience.import.emails_label')}
|
||||
bind:value={recipientCsv}
|
||||
rows={4}
|
||||
className="w-full"
|
||||
placeholder={$t(`${INVITE_MODAL}.recipient_emails_placeholder`)}
|
||||
placeholder={$t('audience.import.emails_placeholder')}
|
||||
/>
|
||||
<CheckboxField
|
||||
className="mt-2"
|
||||
name="send-invite-emails"
|
||||
label={$t(`${INVITE_MODAL}.send_invite_emails`)}
|
||||
label={$t('audience.import.send_email')}
|
||||
bind:checked={sendInviteEmails}
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<Button variant="secondary" onclick={createEmailInvites} loading={isCreating}>
|
||||
{$t(`${INVITE_MODAL}.create_email_invites`)}
|
||||
<Button variant="secondary" onclick={inviteNewStudents} loading={isSubmitting}>
|
||||
{$t(`${INVITE_MODAL}.invite_new_students`)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { OrgStudent } from './types';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
students: OrgStudent[];
|
||||
isLoading?: boolean;
|
||||
onAssigned?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let { courseId, students, isLoading = false, onAssigned }: Props = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
const selectedProfileIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
const filteredStudents = $derived.by(() => {
|
||||
const query = searchValue.trim().toLowerCase();
|
||||
const sortedStudents = [...students].sort((a, b) =>
|
||||
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
|
||||
);
|
||||
|
||||
if (!query) {
|
||||
return sortedStudents;
|
||||
}
|
||||
|
||||
return sortedStudents.filter((student) => {
|
||||
const name = student.name?.toLowerCase() || '';
|
||||
const email = student.email?.toLowerCase() || '';
|
||||
return name.includes(query) || email.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCount = $derived(selectedProfileIds.size);
|
||||
|
||||
function toggleStudent(profileId: string) {
|
||||
if (selectedProfileIds.has(profileId)) {
|
||||
selectedProfileIds.delete(profileId);
|
||||
} else {
|
||||
selectedProfileIds.add(profileId);
|
||||
}
|
||||
}
|
||||
|
||||
async function assignStudents() {
|
||||
if (!courseId) return;
|
||||
if (selectedProfileIds.size === 0) {
|
||||
snackbar.error('course.navItem.people.invite_modal.select_at_least_one_student');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const result = await orgApi.assignAudienceToCourses({
|
||||
profileIds: [...selectedProfileIds],
|
||||
courseIds: [courseId],
|
||||
sendEmail
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await courseApi.refreshCourse(courseId, $profile.id);
|
||||
selectedProfileIds.clear();
|
||||
searchValue = '';
|
||||
await onAssigned?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to assign existing students to course', error);
|
||||
snackbar.error('audience.assign.snackbar_failed');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border p-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-base font-semibold">{$t('course.navItem.people.invite_modal.existing_students_title')}</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.existing_students_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<Search placeholder={$t('course.navItem.people.invite_modal.existing_students_search')} bind:value={searchValue} />
|
||||
|
||||
{#if selectedCount > 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('audience.selected_count', { count: selectedCount })}</p>
|
||||
{/if}
|
||||
|
||||
<div class="max-h-72 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||
{#if isLoading}
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.loading_existing_students')}
|
||||
</p>
|
||||
{:else if students.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.people.invite_modal.no_existing_students')}</p>
|
||||
{:else if filteredStudents.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.no_matching_existing_students')}
|
||||
</p>
|
||||
{:else}
|
||||
{#each filteredStudents as student (student.profileId)}
|
||||
<div
|
||||
class="ui:hover:bg-muted/40 rounded-md px-2 py-1"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => student.profileId && toggleStudent(student.profileId)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (student.profileId) toggleStudent(student.profileId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckboxField
|
||||
name={`existing-student-${student.profileId}`}
|
||||
label={student.name || student.email}
|
||||
checked={student.profileId ? selectedProfileIds.has(student.profileId) : false}
|
||||
>
|
||||
<span class="ui:text-muted-foreground text-sm">{student.email}</span>
|
||||
</CheckboxField>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<CheckboxField
|
||||
name="notify-existing-students"
|
||||
label={$t('course.navItem.people.invite_modal.notify_existing_students')}
|
||||
bind:checked={sendEmail}
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="secondary" onclick={assignStudents} loading={isSubmitting} disabled={isLoading || isSubmitting}>
|
||||
{$t('course.navItem.people.invite_modal.grant_access')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,19 +13,11 @@
|
||||
import { peopleApi } from '$features/course/api';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import {
|
||||
DEFAULT_INVITE_SETTINGS_STATE,
|
||||
DEFAULT_STUDENT_INVITE_LINK_STATE,
|
||||
inviteSettingsStore,
|
||||
studentInviteLinkStore
|
||||
} from './store';
|
||||
import type { Tutor } from './types';
|
||||
import type { OrgStudent, Tutor } from './types';
|
||||
|
||||
import TutorSelectSection from './tutor-select-section.svelte';
|
||||
import InviteSettingsSection from './invite-settings-section.svelte';
|
||||
import ExistingStudentsSection from './existing-students-section.svelte';
|
||||
import BulkEmailSection from './bulk-email-section.svelte';
|
||||
import InviteListSection from './invite-list-section.svelte';
|
||||
import type { InviteListItem } from './types';
|
||||
|
||||
let tutors = $state<Tutor[]>([]);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
@@ -36,21 +28,9 @@
|
||||
const selectedTutors = $derived(tutors.filter((t) => selectedIds.includes(t.id.toString())));
|
||||
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
||||
|
||||
let existingInvites = $state<InviteListItem[]>([]);
|
||||
let isLoadingInvites = $state(false);
|
||||
let activeTab = $state<'tutors' | 'students'>('tutors');
|
||||
let activeStudentTab = $state<'email' | 'history'>('email');
|
||||
|
||||
async function loadInvites() {
|
||||
if (!courseId) return;
|
||||
isLoadingInvites = true;
|
||||
try {
|
||||
const invites = await peopleApi.listStudentInvites(courseId);
|
||||
existingInvites = (invites || []) as InviteListItem[];
|
||||
} finally {
|
||||
isLoadingInvites = false;
|
||||
}
|
||||
}
|
||||
let isLoadingStudents = $state(false);
|
||||
let activeTab = $state<'tutors' | 'students'>('students');
|
||||
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, courseApi.group.people));
|
||||
|
||||
function getTutors(team: OrgTeamMember[]) {
|
||||
const existingTutors = courseApi?.group?.tutors || [];
|
||||
@@ -65,6 +45,22 @@
|
||||
}));
|
||||
}
|
||||
|
||||
function getAvailableStudents(students: OrgStudent[], courseMembers: typeof courseApi.group.people) {
|
||||
const enrolledStudentIds = new Set(
|
||||
courseMembers
|
||||
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
|
||||
.map((member) => member.profileId)
|
||||
.filter((profileId): profileId is string => Boolean(profileId))
|
||||
);
|
||||
|
||||
return students.filter((student) => {
|
||||
if (!student.profileId) {
|
||||
return false;
|
||||
}
|
||||
return !enrolledStudentIds.has(student.profileId);
|
||||
});
|
||||
}
|
||||
|
||||
function setTutors(orgId: string | undefined) {
|
||||
if (!orgId) return;
|
||||
untrack(async () => {
|
||||
@@ -77,6 +73,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function loadStudents(orgId: string | undefined) {
|
||||
if (!orgId) return;
|
||||
untrack(async () => {
|
||||
isLoadingStudents = true;
|
||||
try {
|
||||
await orgApi.getOrgAudience(orgId);
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!selectedTutors.length) {
|
||||
goto(resolve(page.url.pathname, {}));
|
||||
@@ -105,11 +113,8 @@
|
||||
$effect(() => {
|
||||
if (!isOpen || !courseId) return;
|
||||
untrack(() => {
|
||||
activeTab = 'tutors';
|
||||
activeStudentTab = 'email';
|
||||
inviteSettingsStore.set({ ...DEFAULT_INVITE_SETTINGS_STATE });
|
||||
studentInviteLinkStore.set({ ...DEFAULT_STUDENT_INVITE_LINK_STATE });
|
||||
void loadInvites();
|
||||
activeTab = 'students';
|
||||
void loadStudents($currentOrg.id);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -149,35 +154,8 @@
|
||||
|
||||
<UnderlineTabs.Content value="students">
|
||||
<div class="mt-6 space-y-6">
|
||||
<InviteSettingsSection />
|
||||
|
||||
<UnderlineTabs.Root bind:value={activeStudentTab}>
|
||||
<UnderlineTabs.List class="flex flex-wrap">
|
||||
<UnderlineTabs.Trigger value="email">
|
||||
{$t(`${INVITE_MODAL}.direct_email_bulk`)}
|
||||
</UnderlineTabs.Trigger>
|
||||
<UnderlineTabs.Trigger value="history">
|
||||
{$t(`${INVITE_MODAL}.existing_invites`)}
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
|
||||
<UnderlineTabs.Content value="email">
|
||||
<div class="mt-6">
|
||||
<BulkEmailSection {courseId} onInviteCreated={loadInvites} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
<UnderlineTabs.Content value="history">
|
||||
<div class="mt-6">
|
||||
<InviteListSection
|
||||
{courseId}
|
||||
invites={existingInvites}
|
||||
isLoading={isLoadingInvites}
|
||||
onRefresh={loadInvites}
|
||||
/>
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
<ExistingStudentsSection {courseId} students={availableStudents} isLoading={isLoadingStudents} />
|
||||
<BulkEmailSection {courseId} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { StudentInviteListItem, StudentInviteAuditItem } from '../../utils/types';
|
||||
import type { OrganizationTeamMembers } from '$features/org/utils/types';
|
||||
import type { OrganizationAudience } from '$features/org/utils/types';
|
||||
import type { TCourseInvitePreset } from '@cio/utils/validation/course/invite';
|
||||
|
||||
export interface Profile {
|
||||
@@ -30,6 +31,9 @@ export type Tutor = Pick<OrgTeamMemberItem, 'id' | 'profileId' | 'email'> & {
|
||||
text: OrgTeamMemberItem['fullname'];
|
||||
};
|
||||
|
||||
type OrgAudienceMemberItem = OrganizationAudience[number];
|
||||
export type OrgStudent = OrgAudienceMemberItem;
|
||||
|
||||
/** Re-export API-driven invite types from course utils */
|
||||
export type InviteListItem = StudentInviteListItem;
|
||||
export type InviteAuditItem = StudentInviteAuditItem;
|
||||
|
||||
+2
-6
@@ -4,8 +4,7 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import { BackButton } from '@cio/ui';
|
||||
|
||||
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
|
||||
import {
|
||||
@@ -231,10 +230,7 @@
|
||||
</script>
|
||||
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<Button variant="link" class="h-fit! justify-start! px-2! py-2!" href={resolve(coursesListPath, {})}>
|
||||
<ArrowLeftIcon class="custom" />
|
||||
<span class="text-xs">{$t('org_navigation.courses')}</span>
|
||||
</Button>
|
||||
<BackButton href={resolve(coursesListPath, {})} label={$t('org_navigation.courses')} class="px-2! py-2!" />
|
||||
|
||||
<Sidebar.Menu>
|
||||
{#each navItems as item (item.id)}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { DeleteModal } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { COURSE_SORT_OPTIONS, DEFAULT_COURSE_SORT, type CourseSortBy } from '../utils/constants';
|
||||
import { deleteCourseModal, deleteCourseModalInitialState, courseMetaDeta } from '../utils/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
|
||||
@@ -33,7 +34,7 @@
|
||||
isExplore?: boolean;
|
||||
isLMS?: boolean;
|
||||
searchValue?: string;
|
||||
selectedId?: string;
|
||||
sortKey?: CourseSortBy;
|
||||
isLoading?: boolean;
|
||||
showSortSelect?: boolean;
|
||||
filterControls?: Snippet;
|
||||
@@ -46,22 +47,21 @@
|
||||
isExplore = false,
|
||||
isLMS = false,
|
||||
searchValue = $bindable(''),
|
||||
selectedId = $bindable('0'),
|
||||
sortKey = $bindable(DEFAULT_COURSE_SORT),
|
||||
isLoading = false,
|
||||
showSortSelect = true,
|
||||
filterControls
|
||||
}: Props = $props();
|
||||
|
||||
const filterOptions = $derived([
|
||||
{ value: '0', label: $t('courses.course_filter.date_created') },
|
||||
{ value: '1', label: $t('courses.course_filter.published') },
|
||||
{ value: '2', label: $t('courses.course_filter.lessons') }
|
||||
]);
|
||||
|
||||
const selectedLabel = $derived(
|
||||
filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label
|
||||
const filterOptions = $derived(
|
||||
COURSE_SORT_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: $t(option.label)
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedLabel = $derived(filterOptions.find((opt) => opt.value === sortKey)?.label || filterOptions[0].label);
|
||||
|
||||
const setViewPreference = (preference: 'grid' | 'list') => {
|
||||
$courseMetaDeta.view = preference;
|
||||
if (browser) {
|
||||
@@ -94,12 +94,12 @@
|
||||
isLoading={$deleteCourseModal.isDeleting}
|
||||
/>
|
||||
|
||||
<Page.BodyHeader align="right">
|
||||
<Page.BodyHeader align="right" class="p-0!">
|
||||
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
|
||||
{#if showSortSelect}
|
||||
<Select.Root type="single" bind:value={selectedId}>
|
||||
<Select.Root type="single" bind:value={sortKey}>
|
||||
<Select.Trigger class="min-w-[150px]">
|
||||
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
|
||||
<p>{sortKey ? selectedLabel : filterOptions[0].label}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each filterOptions as option (option.label)}
|
||||
@@ -142,9 +142,9 @@
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.title')}</Table.Head>
|
||||
<Table.Head class="w-[24%]">{$t('courses.course_card.list_view.description')}</Table.Head>
|
||||
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.description')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
|
||||
<Table.Head class="w-[14%]">{$t('courses.course_card.list_view.tags')}</Table.Head>
|
||||
<Table.Head class="w-3/12 min-w-0">{$t('courses.course_card.list_view.tags')}</Table.Head>
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { RefreshPageData } from '$features/ui';
|
||||
import { RefreshPageData, UnsavedChanges } from '$features/ui';
|
||||
import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte';
|
||||
import { courseApi, lessonApi } from '$features/course/api';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
@@ -50,6 +50,7 @@
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { orderedTabs, tabs as materialTabs } from '$features/course/components/lesson/constants';
|
||||
import { getViewModeComponents } from '$features/course/components/lesson/utils';
|
||||
import { loadDraft, clearDraft } from '$features/course/utils/lesson-draft';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
@@ -61,9 +62,14 @@
|
||||
const mode = $derived($page.url.searchParams.get('mode') === 'edit' ? MODES.edit : MODES.view);
|
||||
|
||||
let prevModeParam = $state<string | null>(null);
|
||||
let isSaving = $state(false);
|
||||
let isDeletingLesson = $state(false);
|
||||
let isVersionDrawerOpen = $state(false);
|
||||
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
|
||||
let hasUnsavedChanges = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
hasUnsavedChanges = lessonApi.isDirty && mode === MODES.edit;
|
||||
});
|
||||
|
||||
const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson');
|
||||
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
|
||||
@@ -80,6 +86,12 @@
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
if (mode === MODES.edit && lessonApi.isDirty) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
saveLesson();
|
||||
}
|
||||
hasUnsavedChanges = false;
|
||||
setModeQueryParam(mode === MODES.edit ? MODES.view : MODES.edit);
|
||||
}
|
||||
|
||||
@@ -139,7 +151,7 @@
|
||||
|
||||
const isMaterialsEmpty = $derived(tabs.every((tab) => tab.badgeValue === 0));
|
||||
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
let isLoading = writable(false);
|
||||
|
||||
@@ -184,9 +196,6 @@
|
||||
async function saveLesson() {
|
||||
if (!lessonApi.lesson) return false;
|
||||
|
||||
// Prevent autosave loops: we only set this back to `true` when the user edits again.
|
||||
lessonApi.isDirty = false;
|
||||
|
||||
const [isLessonUpdated] = await Promise.all([
|
||||
lessonApi.update(courseApi.course?.id || '', lessonId, {
|
||||
title: lessonApi.lesson.title || undefined,
|
||||
@@ -200,6 +209,8 @@
|
||||
|
||||
if (isLessonUpdated) {
|
||||
patchLessonListItemLocally();
|
||||
clearDraft(lessonId, lessonApi.currentLocale);
|
||||
lessonApi.isDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +230,7 @@
|
||||
await lessonApi.delete(courseId, lessonId);
|
||||
|
||||
if (lessonApi.success) {
|
||||
hasUnsavedChanges = false;
|
||||
await goto(resolve(`/courses/${courseId}/lessons`, {}));
|
||||
}
|
||||
|
||||
@@ -228,6 +240,8 @@
|
||||
function handleSave(prevMode: string) {
|
||||
untrack(() => {
|
||||
if (prevMode === MODES.edit) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
saveLesson();
|
||||
}
|
||||
});
|
||||
@@ -240,11 +254,8 @@
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
untrack(() => {
|
||||
isSaving = true;
|
||||
timeoutId = setTimeout(async () => {
|
||||
await saveLesson();
|
||||
|
||||
isSaving = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
@@ -255,13 +266,41 @@
|
||||
autoSave();
|
||||
};
|
||||
|
||||
// Check for an unsaved localStorage draft when the lesson first loads.
|
||||
let draftCheckedForLesson = '';
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (!lessonApi.lesson || !lessonId) return;
|
||||
if (draftCheckedForLesson === lessonId) return;
|
||||
draftCheckedForLesson = lessonId;
|
||||
|
||||
const draft = loadDraft(lessonId, lessonApi.currentLocale);
|
||||
if (!draft) return;
|
||||
|
||||
const serverContent = lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '';
|
||||
if (draft.content === serverContent) {
|
||||
clearDraft(lessonId, lessonApi.currentLocale);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldRestore = window.confirm(t.get('course.navItem.lessons.draft_recovery'));
|
||||
if (shouldRestore) {
|
||||
if (!lessonApi.translations[lessonId]) {
|
||||
lessonApi.translations[lessonId] = {} as Record<TLocale, string>;
|
||||
}
|
||||
lessonApi.translations[lessonId][lessonApi.currentLocale] = draft.content;
|
||||
lessonApi.isDirty = true;
|
||||
} else {
|
||||
clearDraft(lessonId, lessonApi.currentLocale);
|
||||
}
|
||||
});
|
||||
|
||||
// Only autosave after real edits, not on every reactive update.
|
||||
$effect(() => {
|
||||
if (mode !== MODES.edit) return;
|
||||
if (!lessonApi.lesson || !lessonId) return;
|
||||
if (!lessonApi.isDirty) return;
|
||||
if ($isLoading) return;
|
||||
console.log('autoSave');
|
||||
|
||||
autoSave();
|
||||
});
|
||||
@@ -270,7 +309,6 @@
|
||||
let didHandleExitEdit = false;
|
||||
$effect(() => {
|
||||
if (!lessonId) return;
|
||||
console.log('didHandleExitEdit');
|
||||
|
||||
const currentParam = $page.url.searchParams.get('mode');
|
||||
const prev = prevModeParam;
|
||||
@@ -338,8 +376,17 @@
|
||||
</IconButton>
|
||||
{/if}
|
||||
|
||||
<div class="flex-row items-center lg:flex">
|
||||
<IconButton onclick={toggleMode} disabled={isSaving}>
|
||||
<div class="flex flex-row items-center gap-2 lg:flex">
|
||||
{#if mode === MODES.edit}
|
||||
<span class="ui:text-muted-foreground text-sm" aria-live="polite">
|
||||
{#if lessonApi.isSaving}
|
||||
{$t('course.navItem.lessons.saving')}
|
||||
{:else if !lessonApi.isDirty}
|
||||
{$t('course.navItem.lessons.saved')}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<IconButton onclick={toggleMode} disabled={lessonApi.isSaving}>
|
||||
{#if mode === MODES.edit}
|
||||
<Save size={20} />
|
||||
{:else}
|
||||
@@ -452,6 +499,8 @@
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
|
||||
{#if isVersionDrawerOpen && window.innerWidth >= 1024}
|
||||
<LessonVersionHistory
|
||||
open={isVersionDrawerOpen}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { CourseMembers, CourseMember } from '$features/course/utils/types';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
@@ -138,11 +139,15 @@
|
||||
{#if person.profile}
|
||||
<div class="flex items-start lg:items-center">
|
||||
<Avatar.Root class="mr-3">
|
||||
<Avatar.Image
|
||||
src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'}
|
||||
alt={person.profile.fullname ? person.profile.fullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(person.profile.fullname) || 'U'}</Avatar.Fallback>
|
||||
{#if person.profile.avatar_url}
|
||||
<Avatar.Image
|
||||
src={person.profile.avatar_url}
|
||||
alt={person.profile.fullname ? person.profile.fullname : 'User'}
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col items-start lg:flex-row lg:items-center">
|
||||
<div class="mr-2">
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
await submissionApi.updateGrades(courseId, subId, {
|
||||
answers,
|
||||
total,
|
||||
feedback,
|
||||
feedback: feedback || undefined,
|
||||
statusId: STATUS.GRADED
|
||||
});
|
||||
|
||||
@@ -195,6 +195,8 @@
|
||||
}
|
||||
};
|
||||
|
||||
handleModalClose();
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,8 +16,51 @@ export const EXERCISE_TEMPLATE_TAGS = {
|
||||
GIT: 'GIT'
|
||||
};
|
||||
|
||||
/** Named sort keys for course lists (URL `sort`, localStorage, filter UI). */
|
||||
export const CourseSortBy = {
|
||||
DateCreated: 'date_created',
|
||||
Published: 'published',
|
||||
Lessons: 'lessons'
|
||||
} as const;
|
||||
|
||||
export type CourseSortBy = (typeof CourseSortBy)[keyof typeof CourseSortBy];
|
||||
|
||||
export const DEFAULT_COURSE_SORT: CourseSortBy = CourseSortBy.DateCreated;
|
||||
|
||||
/** URL `order` param and filter toggle (ascending vs descending). */
|
||||
export const CourseSortOrder = {
|
||||
Asc: 'asc',
|
||||
Desc: 'desc'
|
||||
} as const;
|
||||
|
||||
export type CourseSortOrder = (typeof CourseSortOrder)[keyof typeof CourseSortOrder];
|
||||
|
||||
export const DEFAULT_SORT_ORDER: CourseSortOrder = CourseSortOrder.Desc;
|
||||
|
||||
const COURSE_SORT_ORDER_VALUES = new Set<string>(Object.values(CourseSortOrder));
|
||||
|
||||
export function parseCourseSortOrder(value: string | null | undefined): CourseSortOrder {
|
||||
if (value != null && COURSE_SORT_ORDER_VALUES.has(value)) {
|
||||
return value as CourseSortOrder;
|
||||
}
|
||||
return DEFAULT_SORT_ORDER;
|
||||
}
|
||||
|
||||
export const COURSE_SORT_OPTIONS = [
|
||||
{ value: '0', label: 'courses.course_filter.date_created' },
|
||||
{ value: '1', label: 'courses.course_filter.published' },
|
||||
{ value: '2', label: 'courses.course_filter.lessons' }
|
||||
{ value: CourseSortBy.DateCreated, label: 'courses.course_filter.date_created' },
|
||||
{ value: CourseSortBy.Published, label: 'courses.course_filter.published' },
|
||||
{ value: CourseSortBy.Lessons, label: 'courses.course_filter.lessons' }
|
||||
] as const;
|
||||
|
||||
const COURSE_SORT_VALUES = new Set<string>(Object.values(CourseSortBy));
|
||||
|
||||
/** Parses URL `sort` / stored sort strings into {@link CourseSortBy}; unknown values fall back to default. */
|
||||
export function parseCourseSortValue(value: string | null | undefined): CourseSortBy {
|
||||
if (value == null || value === '') {
|
||||
return DEFAULT_COURSE_SORT;
|
||||
}
|
||||
if (COURSE_SORT_VALUES.has(value)) {
|
||||
return value as CourseSortBy;
|
||||
}
|
||||
return DEFAULT_COURSE_SORT;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
|
||||
interface LessonDraft {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function draftKey(lessonId: string, locale: TLocale): string {
|
||||
return `lesson-draft-${lessonId}-${locale}`;
|
||||
}
|
||||
|
||||
export function saveDraft(lessonId: string, locale: TLocale, content: string) {
|
||||
try {
|
||||
const draft: LessonDraft = { content, timestamp: Date.now() };
|
||||
localStorage.setItem(draftKey(lessonId, locale), JSON.stringify(draft));
|
||||
} catch {
|
||||
// localStorage may be full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDraft(lessonId: string, locale: TLocale): LessonDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKey(lessonId, locale));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as LessonDraft;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDraft(lessonId: string, locale: TLocale) {
|
||||
try {
|
||||
localStorage.removeItem(draftKey(lessonId, locale));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -7,15 +8,18 @@
|
||||
import { courseMetaDeta } from '$features/course/utils/store';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
import type { RecommendedCourses } from '$features/course/types';
|
||||
import { CourseSortBy, DEFAULT_COURSE_SORT, parseCourseSortValue } from '$features/course/utils/constants';
|
||||
|
||||
let searchValue = $state('');
|
||||
let selectedId: string | undefined = $state('');
|
||||
let sortKey = $state<CourseSortBy>(DEFAULT_COURSE_SORT);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
void sortKey;
|
||||
localStorage.setItem('classroomio_filter_course_key', sortKey);
|
||||
});
|
||||
|
||||
const filteredExploreCourses: RecommendedCourses = $derived.by(() => {
|
||||
if (selectedId) {
|
||||
localStorage.setItem('classroomio_filter_course_key', selectedId);
|
||||
}
|
||||
|
||||
const coursesFiltered = coursesApi.recommendedCourses.filter((course) => {
|
||||
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
|
||||
return true;
|
||||
@@ -24,11 +28,13 @@
|
||||
return false;
|
||||
});
|
||||
|
||||
if (selectedId === '0') {
|
||||
if (sortKey === CourseSortBy.DateCreated) {
|
||||
return coursesFiltered.sort(
|
||||
(a, b) => new Date(a.createdAt ?? '').getTime() - new Date(b.createdAt ?? '').getTime()
|
||||
);
|
||||
} else if (selectedId === '1') {
|
||||
} else if (sortKey === CourseSortBy.Published) {
|
||||
return coursesFiltered.sort((a, b) => Number(b.isPublished) - Number(a.isPublished));
|
||||
} else if (sortKey === CourseSortBy.Lessons) {
|
||||
return coursesFiltered.sort((a, b) => (b.lessonCount ?? 0) - (a.lessonCount ?? 0));
|
||||
}
|
||||
|
||||
@@ -42,7 +48,7 @@
|
||||
$courseMetaDeta.view = courseView;
|
||||
}
|
||||
|
||||
selectedId = localStorage.getItem('classroomio_filter_course_key') || '0';
|
||||
sortKey = parseCourseSortValue(localStorage.getItem('classroomio_filter_course_key'));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -60,5 +66,5 @@
|
||||
isLMS={true}
|
||||
isLoading={coursesApi.isLoading}
|
||||
bind:searchValue
|
||||
bind:selectedId
|
||||
bind:sortKey
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
AssignAudienceCoursesRequest,
|
||||
DeleteTeamRequest,
|
||||
DomainRequestRequest,
|
||||
GetAudienceRequest,
|
||||
GetOrgPublicCoursesRequest,
|
||||
ImportAudienceRequest,
|
||||
InviteTeamRequest,
|
||||
OrgPublicCourses,
|
||||
OrganizationAudience,
|
||||
OrganizationTeamMembers
|
||||
OrganizationTeamMembers,
|
||||
ResendAudienceInviteRequest,
|
||||
RevokeAudienceInviteRequest
|
||||
} from '../utils/types';
|
||||
import type { TCreateOrganization, TGetOrganizations, TUpdateOrganization } from '@cio/utils/validation/organization';
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
TAssignAudienceCourses,
|
||||
TAudienceInviteByEmail,
|
||||
TCreateOrganization,
|
||||
TGetOrganizations,
|
||||
TImportAudienceMembers,
|
||||
TUpdateOrganization
|
||||
} from '@cio/utils/validation/organization';
|
||||
import { ZCreateOrganization, ZUpdateOrganization } from '@cio/utils/validation/organization';
|
||||
import { currentOrg, orgs } from '$lib/utils/store/org';
|
||||
|
||||
@@ -370,6 +381,117 @@ class OrgApi extends BaseApiWithErrors {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports users into the organization as students
|
||||
*/
|
||||
async importAudienceMembers(data: TImportAudienceMembers) {
|
||||
console.log('importAudienceMembers', data);
|
||||
return this.execute<ImportAudienceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.audience.import.$post({
|
||||
json: data
|
||||
}),
|
||||
logContext: 'importing audience members',
|
||||
onSuccess: (response) => {
|
||||
const d = response.data;
|
||||
snackbar.success(
|
||||
t.get('audience.import.snackbar_success', {
|
||||
imported: d.imported,
|
||||
skipped: d.skipped,
|
||||
emailsSent: d.emailsSent
|
||||
})
|
||||
);
|
||||
this.success = true;
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result) {
|
||||
this.errors[result.field as string] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns existing audience members to courses
|
||||
*/
|
||||
async assignAudienceToCourses(data: TAssignAudienceCourses) {
|
||||
return this.execute<AssignAudienceCoursesRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.audience['assign-courses'].$post({
|
||||
json: data
|
||||
}),
|
||||
logContext: 'assigning audience to courses',
|
||||
onSuccess: (response) => {
|
||||
const d = response.data;
|
||||
snackbar.success(
|
||||
t.get('audience.assign.snackbar_success', {
|
||||
assigned: d.assigned,
|
||||
alreadyEnrolled: d.alreadyEnrolled,
|
||||
emailsSent: d.emailsSent
|
||||
})
|
||||
);
|
||||
this.success = true;
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result) {
|
||||
this.errors[result.field as string] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async resendAudienceInvite(data: TAudienceInviteByEmail) {
|
||||
return this.execute<ResendAudienceInviteRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.audience['resend-invite'].$post({
|
||||
json: data
|
||||
}),
|
||||
logContext: 'resending audience invite',
|
||||
onSuccess: (response) => {
|
||||
if (response.data.emailSent) {
|
||||
snackbar.success('audience.invite.resend_snackbar_success');
|
||||
} else {
|
||||
snackbar.error('audience.invite.resend_snackbar_email_failed');
|
||||
}
|
||||
const orgId = get(currentOrg).id;
|
||||
if (orgId) this.getOrgAudience(orgId);
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async revokeAudienceInvite(data: TAudienceInviteByEmail) {
|
||||
return this.execute<RevokeAudienceInviteRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.audience['revoke-invite'].$post({
|
||||
json: data
|
||||
}),
|
||||
logContext: 'revoking audience invite',
|
||||
onSuccess: () => {
|
||||
snackbar.success('audience.invite.revoke_snackbar_success');
|
||||
const orgId = get(currentOrg).id;
|
||||
if (orgId) this.getOrgAudience(orgId);
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const orgApi = new OrgApi();
|
||||
|
||||
@@ -23,6 +23,8 @@ export type OrganizationAudienceSuccess = Extract<InferResponseType<GetAudienceR
|
||||
|
||||
export type OrganizationAudience = OrganizationAudienceSuccess['data'];
|
||||
|
||||
export type OrganizationAudienceMember = OrganizationAudience[number];
|
||||
|
||||
// Org Public Courses types
|
||||
export type GetOrgPublicCoursesRequest = typeof classroomio.organization.courses.public.$get;
|
||||
export type OrgPublicCoursesResponse = InferResponseType<GetOrgPublicCoursesRequest> | null;
|
||||
@@ -83,6 +85,20 @@ export type SsoDiscoveryResult = GetSsoDiscoverySuccess['data'];
|
||||
export type GetOrgSsoInfoSuccess = Extract<InferResponseType<GetOrgSsoInfoRequest>, { success: true }>;
|
||||
export type OrgSsoInfo = GetOrgSsoInfoSuccess['data'];
|
||||
|
||||
// Audience import types
|
||||
export type ImportAudienceRequest = (typeof classroomio.organization)['audience']['import']['$post'];
|
||||
export type ImportAudienceSuccess = Extract<InferResponseType<ImportAudienceRequest>, { success: true }>;
|
||||
export type ImportAudienceData = ImportAudienceSuccess['data'];
|
||||
|
||||
// Audience assign courses types
|
||||
export type AssignAudienceCoursesRequest = (typeof classroomio.organization)['audience']['assign-courses']['$post'];
|
||||
export type AssignAudienceCoursesSuccess = Extract<InferResponseType<AssignAudienceCoursesRequest>, { success: true }>;
|
||||
export type AssignAudienceCoursesData = AssignAudienceCoursesSuccess['data'];
|
||||
|
||||
// Audience invite actions
|
||||
export type ResendAudienceInviteRequest = (typeof classroomio.organization)['audience']['resend-invite']['$post'];
|
||||
export type RevokeAudienceInviteRequest = (typeof classroomio.organization)['audience']['revoke-invite']['$post'];
|
||||
|
||||
// Token auth types
|
||||
export type CreateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$post'];
|
||||
export type GetTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$get'];
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||
import AlarmClockIcon from '@lucide/svelte/icons/alarm-clock';
|
||||
import MailIcon from '@lucide/svelte/icons/mail';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
interface Props {
|
||||
user: UserAnalytics['user'];
|
||||
@@ -16,11 +17,12 @@
|
||||
<div class="rounded-md border p-5">
|
||||
<div class="flex w-full flex-col items-center justify-start gap-4 text-start md:flex-row">
|
||||
<Avatar.Root class="h-16 w-16">
|
||||
<Avatar.Image
|
||||
src={user.avatarUrl ? user.avatarUrl : '/logo-192.png'}
|
||||
alt={user.fullName ? user.fullName : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{user.fullName ? user.fullName : 'User'}</Avatar.Fallback>
|
||||
{#if user.avatarUrl}
|
||||
<Avatar.Image src={user.avatarUrl} alt={user.fullName ? user.fullName : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-8 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<p class="text-center text-2xl md:text-left dark:text-white">
|
||||
|
||||
+7
-6
@@ -2,12 +2,12 @@
|
||||
import get from 'lodash/get';
|
||||
import z from 'zod';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ReviewFormEditor from './review-form-editor.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { processErrors } from '$lib/utils/functions/validator';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
|
||||
@@ -98,11 +98,12 @@
|
||||
<!-- the headers -->
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<Avatar.Root class="mt-1 h-10 w-10">
|
||||
<Avatar.Image
|
||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
||||
alt={review.name ? review.name : 'Reviewer'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(review.name) || 'R'}</Avatar.Fallback>
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Reviewer'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="text-sm">{review.name}</p>
|
||||
|
||||
|
||||
+9
-1
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -7,8 +9,14 @@
|
||||
}
|
||||
|
||||
let { id, children }: Props = $props();
|
||||
|
||||
let includesHash = $derived(page.url.hash?.includes(id) ?? false);
|
||||
</script>
|
||||
|
||||
<section {id} transition:fade={{ delay: 250, duration: 300 }} class="border-b border-gray-300 pt-12 pb-10">
|
||||
<section
|
||||
{id}
|
||||
transition:fade={{ delay: 250, duration: 300 }}
|
||||
class={cn('border-b border-gray-300 pt-5 pb-5', includesHash && 'pt-22')}
|
||||
>
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import PlayIcon from '@lucide/svelte/icons/play';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import { getLectureNo } from '$features/course/utils/functions';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
@@ -31,7 +32,6 @@
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import PricingSection from './components/pricing-section.svelte';
|
||||
import NavSection from './components/nav-section.svelte';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import SectionsDisplay from './components/sections-display.svelte';
|
||||
import { observeIntersection } from './components/intersection-observer';
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
<!-- Course Details -->
|
||||
<div class="course-content w-full p-3 lg:mr-10 lg:w-10/12">
|
||||
<!-- Navigation -->
|
||||
<UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-0 py-2">
|
||||
<UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-10 py-2">
|
||||
<UnderlineTabs.List>
|
||||
{#each navItems as navItem (navItem.key)}
|
||||
<UnderlineTabs.Trigger value={navItem.key}>
|
||||
@@ -316,15 +316,14 @@
|
||||
<!-- review -->
|
||||
<div class="item-start my-2 flex w-2/4 flex-row">
|
||||
<!-- image container -->
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Root class="mt-1 size-10">
|
||||
<Avatar.Image
|
||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
||||
alt={review.name ? review.name : 'Avatar'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/if}
|
||||
<Avatar.Root class="mt-1 size-10">
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<!-- profile content -->
|
||||
<div class="w-11/12 pl-2.5">
|
||||
@@ -385,15 +384,14 @@
|
||||
<!-- review -->
|
||||
<div class="item-start my-2 flex w-full flex-row">
|
||||
<!-- image container -->
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Root class="mt-1 h-10 w-10">
|
||||
<Avatar.Image
|
||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
||||
alt={review.name ? review.name : 'Avatar'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/if}
|
||||
<Avatar.Root class="mt-1 h-10 w-10">
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<!-- profile content -->
|
||||
<div class="w-11/12 pl-2.5">
|
||||
|
||||
@@ -48,7 +48,7 @@ export const landingPageStore = writable({
|
||||
id: 1,
|
||||
show: true,
|
||||
name: 'Adeyemi Adetunji',
|
||||
avatar_url: '/logo-192.png',
|
||||
avatar_url: '',
|
||||
rating: 1,
|
||||
created_at: new Date(),
|
||||
description: 'rice ventore officiis maxime laudantium recusandae sit dolor.'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { basePath, globalStore } from '$lib/utils/store/app';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
@@ -12,7 +14,6 @@
|
||||
import ThemeToggle from './theme-toggle.svelte';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { logout } from '$lib/utils/functions/logout';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
@@ -36,7 +37,7 @@
|
||||
|
||||
{#snippet usertrigger()}
|
||||
<DropdownMenu.Label class="p-0">
|
||||
<a href={`${$basePath}/settings`} class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<a href={resolve(`${$basePath}/settings`, {})} class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
{@render avatarblock()}
|
||||
</a>
|
||||
</DropdownMenu.Label>
|
||||
@@ -122,7 +123,7 @@
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item onclick={() => logout()}>
|
||||
<DropdownMenu.Item onclick={() => goto(resolve(`/logout`, {}))}>
|
||||
<span class="flex items-center gap-2">
|
||||
<LogOutIcon />
|
||||
<p class="text-sm">{$t('settings.profile.logout')}</p>
|
||||
|
||||
@@ -8,7 +8,7 @@ export const ROUTE = {
|
||||
COURSE: '/course',
|
||||
LOGIN: '/login',
|
||||
SIGN_UP: '/signup',
|
||||
INVITE_TEACHER: '/invite/t',
|
||||
INVITE: '/invite',
|
||||
PROFILE: '/profile',
|
||||
PEOPLE: '/people',
|
||||
DISCUSSIONS: '/discussions',
|
||||
@@ -25,7 +25,7 @@ export const PUBLIC_ROUTES = [
|
||||
ROUTE.LOGIN,
|
||||
ROUTE.LOGOUT,
|
||||
ROUTE.SIGN_UP,
|
||||
`^${ROUTE.INVITE_TEACHER}/.*`,
|
||||
`^${ROUTE.INVITE}/.*`,
|
||||
ROUTE.FORGOT,
|
||||
ROUTE.RESET,
|
||||
`^${ROUTE.PAGES}/.*`,
|
||||
@@ -42,7 +42,7 @@ export const ROUTES_TO_HIDE_NAV = [
|
||||
`^${ROUTE.LOGIN}$`,
|
||||
`^${ROUTE.SIGN_UP}$`,
|
||||
ROUTE.LMS_HOME,
|
||||
`^${ROUTE.INVITE_TEACHER}/.*`,
|
||||
`^${ROUTE.INVITE}/.*`,
|
||||
`^/course/.*/enroll$`,
|
||||
`^${ROUTE.FORGOT}$`,
|
||||
`^${ROUTE.RESET}$`,
|
||||
|
||||
@@ -11,3 +11,14 @@ export function getCioCookieString(cookies: Cookies): string {
|
||||
.map((c) => `${c.name}=${c.value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
export function getHasCioCookies(cookies: Cookies): boolean {
|
||||
const cioCookies = cookies.getAll().filter((c) => c.name.includes('classroomio'));
|
||||
|
||||
if (cioCookies.length === 0) return false;
|
||||
|
||||
// check if the cookies doesn't includ ONLY classroomio_locale
|
||||
const onlyLocaleCookie = cioCookies.every((c) => c.name === 'classroomio_locale');
|
||||
|
||||
return !onlyLocaleCookie;
|
||||
}
|
||||
|
||||
@@ -475,7 +475,54 @@
|
||||
"name": "Navn",
|
||||
"email": "E-mail",
|
||||
"date_joined": "Dato tilmeldt",
|
||||
"upgrade": "Opgrader din plan for at invitere flere elever"
|
||||
"upgrade": "Opgrader din plan for at invitere flere elever",
|
||||
"import_users": "Importer brugere",
|
||||
"search_placeholder": "Søg...",
|
||||
"status": "Status",
|
||||
"status_active": "Aktiv",
|
||||
"status_pending": "Afventer",
|
||||
"status_expired": "Udløbet",
|
||||
"status_revoked": "Tilbagekaldt",
|
||||
"select_all": "Vælg alle",
|
||||
"selected_count": "{count} valgt",
|
||||
"assign_courses": "Tildel til kurser",
|
||||
"invite": {
|
||||
"row_actions_aria": "Åbne handlinger",
|
||||
"resend": "Send invitationen igen",
|
||||
"revoke": "Tilbagekald afventende invitation",
|
||||
"resend_snackbar_success": "Invitationsmail sendt",
|
||||
"resend_snackbar_email_failed": "Invitationen blev fornyet, men e-mailen kunne ikke sendes",
|
||||
"revoke_snackbar_success": "Afventende invitation tilbagekaldt"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Tildel til kurser",
|
||||
"description": "Giv de valgte brugere adgang til kurser og giv dem eventuelt besked via e-mail.",
|
||||
"select_courses": "Vælg kurser",
|
||||
"select_courses_placeholder": "Vælg kurser...",
|
||||
"send_email": "Send notifikations-e-mail",
|
||||
"submit": "Tildel",
|
||||
"cancel": "Annuller",
|
||||
"snackbar_success": "Tildelt {assigned} brugere til kurser ({alreadyEnrolled} allerede tilmeldt, {emailsSent} e-mails sendt)",
|
||||
"snackbar_no_courses": "Vælg venligst mindst ét kursus",
|
||||
"snackbar_failed": "Kunne ikke tildele kurser"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer brugere",
|
||||
"description": "Tilføj elever til din organisation ved at indtaste deres e-mailadresser. Tildel dem eventuelt til kurser og send invitationsmails.",
|
||||
"emails_label": "E-mail-adresser",
|
||||
"emails_placeholder": "Indtast e-mails adskilt af kommaer, nye linjer eller semikolon\nf.eks. [email protected], [email protected]",
|
||||
"course_access": "Kursusadgang",
|
||||
"no_courses": "Ingen kursusopgave",
|
||||
"all_courses": "Alle kurser",
|
||||
"select_courses": "Vælg specifikke kurser",
|
||||
"select_courses_placeholder": "Vælg kurser...",
|
||||
"send_email": "Send invitationsmail til importerede brugere",
|
||||
"submit": "Importer brugere",
|
||||
"back": "Tilbage til Publikum",
|
||||
"snackbar_success": "Importerede {importerede} brugere ({skipped} eksisterede allerede, {emailsSent} e-mails sendt)",
|
||||
"snackbar_no_emails": "Indtast venligst mindst én e-mailadresse",
|
||||
"snackbar_failed": "Kunne ikke importere brugere"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
"title": "Fællesskab",
|
||||
@@ -940,6 +987,8 @@
|
||||
"complete": "Komplet",
|
||||
"next": "Næste",
|
||||
"prev": "Prev",
|
||||
"prev_shortcut": "Forrige (←)",
|
||||
"next_shortcut": "Næste (→)",
|
||||
"disabled": "Funktionen er deaktiveret",
|
||||
"version_history": {
|
||||
"title": "Versionshistorik",
|
||||
@@ -1200,6 +1249,9 @@
|
||||
},
|
||||
"edit": {
|
||||
"accepted_answers_placeholder": "Accepterede svar (kommasepareret)"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Indsendt:"
|
||||
}
|
||||
},
|
||||
"numeric": {
|
||||
@@ -1217,6 +1269,11 @@
|
||||
"tolerance_info": "Tilladt forskel fra den korrekte værdi (f.eks. accepterer 0,5 +/-0,5).",
|
||||
"correct_value_placeholder": "Korrekt værdi",
|
||||
"tolerance_placeholder": "Tolerance"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Indsendt:",
|
||||
"in_range": "Inden for tolerance",
|
||||
"out_of_range": "Ydre tolerance"
|
||||
}
|
||||
},
|
||||
"short_answer": {
|
||||
@@ -1228,6 +1285,10 @@
|
||||
},
|
||||
"edit": {
|
||||
"instructions_placeholder": "Valgfri instruktioner"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Indsendt:",
|
||||
"accepted_label": "Accepterede svar"
|
||||
}
|
||||
},
|
||||
"true_false": {
|
||||
@@ -1291,8 +1352,9 @@
|
||||
"option_prefix": "Mulighed",
|
||||
"remove": "Fjern",
|
||||
"add_option": "Tilføj mulighed",
|
||||
"correct_badge": "(korrekt)",
|
||||
"not_set": "Ikke indstillet"
|
||||
"correct_badge": "korrekt",
|
||||
"not_set": "Ikke indstillet",
|
||||
"incorrect_badge": "Forkert"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1473,7 +1535,10 @@
|
||||
"cancel": "Annuller"
|
||||
},
|
||||
"content_locked_title": "Dette indhold er låst",
|
||||
"content_locked_description": "Din instruktør har ikke låst dette indhold op endnu. Fuldfør tidligere indhold, eller tjek tilbage senere."
|
||||
"content_locked_description": "Din instruktør har ikke låst dette indhold op endnu. Fuldfør tidligere indhold, eller tjek tilbage senere.",
|
||||
"saving": "Gemmer...",
|
||||
"saved": "Gemt",
|
||||
"draft_recovery": "Der blev fundet et ikke-gemt udkast til denne lektion. Vil du gendanne den?"
|
||||
},
|
||||
"news_feed": {
|
||||
"heading": "Nyhedsfeed",
|
||||
@@ -1582,6 +1647,7 @@
|
||||
"unpublished": "Upubliceret",
|
||||
"continue_course": "Fortsæt",
|
||||
"learn_more": "Få flere oplysninger",
|
||||
"tags_overflow_aria": "{count} flere tags",
|
||||
"list_view": {
|
||||
"title": "Titel",
|
||||
"description": "Beskrivelse",
|
||||
@@ -2245,7 +2311,8 @@
|
||||
"email_password_disabled_message": "E-mail/adgangskodelogin er deaktiveret for denne organisation.",
|
||||
"google_auth_disabled": "Google-godkendelse er deaktiveret.",
|
||||
"signup_disabled_error": "Tilmelding er deaktiveret for denne organisation. Kontakt venligst din administrator.",
|
||||
"or_continue_with": "Eller fortsæt med e-mail og adgangskode"
|
||||
"or_continue_with": "Eller fortsæt med e-mail og adgangskode",
|
||||
"sso_enabled": "{provider} er aktiveret for denne organisation"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "Generelt",
|
||||
@@ -2471,7 +2538,8 @@
|
||||
"edit_group": "Rediger gruppe",
|
||||
"edit_tag": "Rediger tag",
|
||||
"save": "Gem",
|
||||
"cancel": "Annuller"
|
||||
"cancel": "Annuller",
|
||||
"actions_menu": "Tag handlinger"
|
||||
},
|
||||
"common": {
|
||||
"not_available": "N/A"
|
||||
@@ -2480,9 +2548,13 @@
|
||||
"invite": {
|
||||
"organization": {
|
||||
"page_title": "Tilmeld dig {orgName} på ClassroomIO",
|
||||
"role_label": "Rolle: {role}",
|
||||
"email_label": "Inviteret e-mail: {email}",
|
||||
"join_button": "Deltag i organisationen",
|
||||
"invitation_student": "Du er blevet inviteret til {orgName} som elev.",
|
||||
"invitation_teacher": "Du er blevet inviteret til {orgName} som lærer.",
|
||||
"invitation_admin": "Du er blevet inviteret til {orgName} som administrator.",
|
||||
"invitation_fallback": "Du er blevet inviteret til {orgName} som {role}.",
|
||||
"email_line": "E-mail: {email}",
|
||||
"accept_button": "Acceptér invitation",
|
||||
"go_to_dashboard": "Gå til dashboard",
|
||||
"messages": {
|
||||
"email_mismatch": "Du er logget ind med en anden e-mail end denne invitation.",
|
||||
"expired": "Dette invitationslink er udløbet.",
|
||||
@@ -2520,6 +2592,17 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Create key"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"title": "Anvendelse og grænser",
|
||||
"description": "MCP er tilgængelig på alle abonnementer, men brugen styres med månedlige automatiseringskreditter og nøglegrænser.",
|
||||
"credits_title": "Kreditter tilbage",
|
||||
"credits_description": "Muterende MCP-handlinger bruger kreditter. Læsehandlinger er gratis.",
|
||||
"keys_title": "Aktive nøgler",
|
||||
"keys_description": "Brug én MCP-nøgle pr. integration, så adgang kan roteres eller tilbagekaldes rent.",
|
||||
"rate_limits_title": "Satsgrænser",
|
||||
"rate_limits_description": "Læs/skriv/publicér anmodninger tilladt pr. minut.",
|
||||
"used_label": "Brugt:"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
@@ -2534,7 +2617,11 @@
|
||||
"never_used": "Aldrig",
|
||||
"scopes_label": "Omfang:",
|
||||
"revoke_confirm": "Vil du tilbagekalde denne automatiseringsnøgle?",
|
||||
"rotate_confirm": "Drej denne automatiseringsnøgle?"
|
||||
"rotate_confirm": "Drej denne automatiseringsnøgle?",
|
||||
"table_key": "Nøgle",
|
||||
"table_status": "Status",
|
||||
"table_created": "Oprettet",
|
||||
"table_last_used": "Sidst brugt"
|
||||
},
|
||||
"clients": {
|
||||
"claude_code": "Claude kode",
|
||||
|
||||
@@ -475,7 +475,54 @@
|
||||
"name": "Name",
|
||||
"email": "E-Mail",
|
||||
"date_joined": "Datum des Beitritts",
|
||||
"upgrade": "Aktualisieren Sie Ihren Plan, um mehr Studenten einzuladen"
|
||||
"upgrade": "Aktualisieren Sie Ihren Plan, um mehr Studenten einzuladen",
|
||||
"import_users": "Benutzer importieren",
|
||||
"search_placeholder": "Suchen…",
|
||||
"status": "Status",
|
||||
"status_active": "Aktiv",
|
||||
"status_pending": "Ausstehend",
|
||||
"status_expired": "Abgelaufen",
|
||||
"status_revoked": "Widerrufen",
|
||||
"select_all": "Alles auswählen",
|
||||
"selected_count": "{count} ausgewählt",
|
||||
"assign_courses": "Zu Kursen zuweisen",
|
||||
"invite": {
|
||||
"row_actions_aria": "Offene Aktionen",
|
||||
"resend": "Einladung erneut senden",
|
||||
"revoke": "Ausstehende Einladung widerrufen",
|
||||
"resend_snackbar_success": "Einladungs-E-Mail gesendet",
|
||||
"resend_snackbar_email_failed": "Die Einladung wurde erneuert, die E-Mail konnte jedoch nicht gesendet werden",
|
||||
"revoke_snackbar_success": "Ausstehende Einladung widerrufen"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Zu Kursen zuweisen",
|
||||
"description": "Gewähren Sie den ausgewählten Benutzern Zugriff auf Kurse und benachrichtigen Sie sie optional per E-Mail.",
|
||||
"select_courses": "Kurse auswählen",
|
||||
"select_courses_placeholder": "Kurse auswählen...",
|
||||
"send_email": "Benachrichtigungs-E-Mail senden",
|
||||
"submit": "Zuweisen",
|
||||
"cancel": "Abbrechen",
|
||||
"snackbar_success": "{assigned} Benutzer den Kursen zugewiesen ({alreadyEnrolled} bereits angemeldet, {emailsSent} E-Mails gesendet)",
|
||||
"snackbar_no_courses": "Bitte wählen Sie mindestens einen Kurs aus",
|
||||
"snackbar_failed": "Kurse konnten nicht zugewiesen werden"
|
||||
},
|
||||
"import": {
|
||||
"title": "Benutzer importieren",
|
||||
"description": "Fügen Sie Ihrer Organisation Studenten hinzu, indem Sie deren E-Mail-Adressen eingeben. Ordnen Sie sie optional Kursen zu und versenden Sie Einladungs-E-Mails.",
|
||||
"emails_label": "E-Mail-Adressen",
|
||||
"emails_placeholder": "Geben Sie E-Mails durch Kommas, Zeilenumbrüche oder Semikolons getrennt ein\nz.B. [email protected], [email protected]",
|
||||
"course_access": "Kurszugang",
|
||||
"no_courses": "Keine Kurszuordnung",
|
||||
"all_courses": "Alle Kurse",
|
||||
"select_courses": "Wählen Sie bestimmte Kurse aus",
|
||||
"select_courses_placeholder": "Kurse auswählen...",
|
||||
"send_email": "Senden Sie Einladungs-E-Mails an importierte Benutzer",
|
||||
"submit": "Benutzer importieren",
|
||||
"back": "Zurück zum Publikum",
|
||||
"snackbar_success": "Importierte {imported} Benutzer ({skipped} existierte bereits, {emailsSent} E-Mails wurden gesendet)",
|
||||
"snackbar_no_emails": "Bitte geben Sie mindestens eine E-Mail-Adresse ein",
|
||||
"snackbar_failed": "Benutzer konnten nicht importiert werden"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
"title": "Gemeinschaft",
|
||||
@@ -940,6 +987,8 @@
|
||||
"complete": "Komplett",
|
||||
"next": "Als nächstes",
|
||||
"prev": "Vorher",
|
||||
"prev_shortcut": "Zurück (←)",
|
||||
"next_shortcut": "Weiter (→)",
|
||||
"disabled": "Funktion ist deaktiviert",
|
||||
"version_history": {
|
||||
"title": "Versionsgeschichte",
|
||||
@@ -1200,6 +1249,9 @@
|
||||
},
|
||||
"edit": {
|
||||
"accepted_answers_placeholder": "Akzeptierte Antworten (durch Kommas getrennt)"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Eingereicht:"
|
||||
}
|
||||
},
|
||||
"numeric": {
|
||||
@@ -1217,6 +1269,11 @@
|
||||
"tolerance_info": "Zulässige Abweichung vom korrekten Wert (Beispiel: 0,5 akzeptiert +/-0,5).",
|
||||
"correct_value_placeholder": "Korrekter Wert",
|
||||
"tolerance_placeholder": "Toleranz"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Eingereicht:",
|
||||
"in_range": "Innerhalb der Toleranz",
|
||||
"out_of_range": "Außerhalb der Toleranz"
|
||||
}
|
||||
},
|
||||
"short_answer": {
|
||||
@@ -1228,6 +1285,10 @@
|
||||
},
|
||||
"edit": {
|
||||
"instructions_placeholder": "Optionale Anweisungen"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Eingereicht:",
|
||||
"accepted_label": "Akzeptierte Antworten"
|
||||
}
|
||||
},
|
||||
"true_false": {
|
||||
@@ -1291,8 +1352,9 @@
|
||||
"option_prefix": "Option",
|
||||
"remove": "Entfernen",
|
||||
"add_option": "Option hinzufügen",
|
||||
"correct_badge": "(Richtig)",
|
||||
"not_set": "Nicht festgelegt"
|
||||
"correct_badge": "Richtig",
|
||||
"not_set": "Nicht festgelegt",
|
||||
"incorrect_badge": "Falsch"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1473,7 +1535,10 @@
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"content_locked_title": "Dieser Inhalt ist gesperrt",
|
||||
"content_locked_description": "Ihr Kursleiter hat diesen Inhalt noch nicht freigeschaltet. Vervollständigen Sie den vorherigen Inhalt oder schauen Sie später noch einmal vorbei."
|
||||
"content_locked_description": "Ihr Kursleiter hat diesen Inhalt noch nicht freigeschaltet. Vervollständigen Sie den vorherigen Inhalt oder schauen Sie später noch einmal vorbei.",
|
||||
"saving": "Sparen…",
|
||||
"saved": "Gespeichert",
|
||||
"draft_recovery": "Für diese Lektion wurde ein nicht gespeicherter Entwurf gefunden. Möchten Sie es wiederherstellen?"
|
||||
},
|
||||
"news_feed": {
|
||||
"heading": "Newsfeed",
|
||||
@@ -1582,6 +1647,7 @@
|
||||
"unpublished": "Unveröffentlicht",
|
||||
"continue_course": "Weiter",
|
||||
"learn_more": "Erfahren Sie mehr",
|
||||
"tags_overflow_aria": "{count} weitere Tags",
|
||||
"list_view": {
|
||||
"title": "Titel",
|
||||
"description": "Beschreibung",
|
||||
@@ -2245,7 +2311,8 @@
|
||||
"email_password_disabled_message": "Die E-Mail-/Passwort-Anmeldung ist für diese Organisation deaktiviert.",
|
||||
"google_auth_disabled": "Die Google-Authentifizierung ist deaktiviert.",
|
||||
"signup_disabled_error": "Die Anmeldung ist für diese Organisation deaktiviert. Bitte wenden Sie sich an Ihren Administrator.",
|
||||
"or_continue_with": "Oder fahren Sie mit E-Mail und Passwort fort"
|
||||
"or_continue_with": "Oder fahren Sie mit E-Mail und Passwort fort",
|
||||
"sso_enabled": "{provider} ist für diese Organisation aktiviert"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "Allgemein",
|
||||
@@ -2471,7 +2538,8 @@
|
||||
"edit_group": "Gruppe bearbeiten",
|
||||
"edit_tag": "Tag bearbeiten",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen"
|
||||
"cancel": "Abbrechen",
|
||||
"actions_menu": "Markieren Sie Aktionen"
|
||||
},
|
||||
"common": {
|
||||
"not_available": "N/A"
|
||||
@@ -2480,9 +2548,13 @@
|
||||
"invite": {
|
||||
"organization": {
|
||||
"page_title": "Treten Sie {orgName} auf ClassroomIO bei",
|
||||
"role_label": "Rolle: {role}",
|
||||
"email_label": "Eingeladene E-Mail: {email}",
|
||||
"join_button": "Treten Sie der Organisation bei",
|
||||
"invitation_student": "Sie wurden zu {orgName} als Schüler/in eingeladen.",
|
||||
"invitation_teacher": "Sie wurden zu {orgName} als Lehrer/in eingeladen.",
|
||||
"invitation_admin": "Sie wurden zu {orgName} als Administrator/in eingeladen.",
|
||||
"invitation_fallback": "Sie wurden zu {orgName} als {role} eingeladen.",
|
||||
"email_line": "E-Mail: {email}",
|
||||
"accept_button": "Einladung annehmen",
|
||||
"go_to_dashboard": "Zum Dashboard",
|
||||
"messages": {
|
||||
"email_mismatch": "Sie sind mit einer anderen E-Mail-Adresse als dieser Einladung angemeldet.",
|
||||
"expired": "Dieser Einladungslink ist abgelaufen.",
|
||||
@@ -2520,6 +2592,17 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Create key"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"title": "Nutzung und Grenzen",
|
||||
"description": "MCP ist in jedem Plan verfügbar, die Nutzung wird jedoch durch monatliche Automatisierungsguthaben und Schlüssellimits gesteuert.",
|
||||
"credits_title": "Verbleibende Credits",
|
||||
"credits_description": "Mutierende MCP-Aktionen verbrauchen Credits. Leseaktionen sind kostenlos.",
|
||||
"keys_title": "Aktive Schlüssel",
|
||||
"keys_description": "Verwenden Sie einen MCP-Schlüssel pro Integration, damit der Zugriff sauber rotiert oder widerrufen werden kann.",
|
||||
"rate_limits_title": "Tarifbegrenzungen",
|
||||
"rate_limits_description": "Zulässige Lese-/Schreib-/Veröffentlichungsanfragen pro Minute.",
|
||||
"used_label": "Verwendet:"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
@@ -2534,7 +2617,11 @@
|
||||
"never_used": "Niemals",
|
||||
"scopes_label": "Geltungsbereiche:",
|
||||
"revoke_confirm": "Diesen Automatisierungsschlüssel widerrufen?",
|
||||
"rotate_confirm": "Diesen Automatisierungsschlüssel drehen?"
|
||||
"rotate_confirm": "Diesen Automatisierungsschlüssel drehen?",
|
||||
"table_key": "Schlüssel",
|
||||
"table_status": "Status",
|
||||
"table_created": "Erstellt",
|
||||
"table_last_used": "Zuletzt verwendet"
|
||||
},
|
||||
"clients": {
|
||||
"claude_code": "Claude Code",
|
||||
|
||||
@@ -902,6 +902,7 @@
|
||||
"unpublished": "Unpublished",
|
||||
"continue_course": "Continue",
|
||||
"learn_more": "Learn more",
|
||||
"tags_overflow_aria": "{count} more tags",
|
||||
"list_view": {
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
@@ -1084,6 +1085,8 @@
|
||||
"complete": "Complete",
|
||||
"next": "Next",
|
||||
"prev": "Prev",
|
||||
"prev_shortcut": "Previous (←)",
|
||||
"next_shortcut": "Next (→)",
|
||||
"materials": {
|
||||
"body_heading": "No note, video or slide added for this lesson yet",
|
||||
"body_content": "Share your knowledge with your students by creating engaging content. Start by clicking on",
|
||||
@@ -1345,7 +1348,8 @@
|
||||
"option_prefix": "Option",
|
||||
"remove": "Remove",
|
||||
"add_option": "Add option",
|
||||
"correct_badge": "(Correct)",
|
||||
"correct_badge": "Correct",
|
||||
"incorrect_badge": "Incorrect",
|
||||
"not_set": "Not set"
|
||||
},
|
||||
"navigation": {
|
||||
@@ -1414,6 +1418,10 @@
|
||||
},
|
||||
"preview": {
|
||||
"helper": "Free-text answer question."
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Submitted:",
|
||||
"accepted_label": "Accepted answers"
|
||||
}
|
||||
},
|
||||
"numeric": {
|
||||
@@ -1431,6 +1439,11 @@
|
||||
"preview": {
|
||||
"correct_value_label": "Correct value",
|
||||
"tolerance_label": "Tolerance"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Submitted:",
|
||||
"in_range": "Within tolerance",
|
||||
"out_of_range": "Outside tolerance"
|
||||
}
|
||||
},
|
||||
"fill_blank": {
|
||||
@@ -1442,6 +1455,9 @@
|
||||
},
|
||||
"preview": {
|
||||
"accepted_answers_label": "Accepted answers"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Submitted:"
|
||||
}
|
||||
},
|
||||
"file_upload": {
|
||||
@@ -2015,6 +2031,12 @@
|
||||
"manage": "Manage all your students here and stay connected with them",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"search_placeholder": "Search…",
|
||||
"status": "Status",
|
||||
"status_active": "Active",
|
||||
"status_pending": "Pending",
|
||||
"status_expired": "Expired",
|
||||
"status_revoked": "Revoked",
|
||||
"date_joined": "Date Joined",
|
||||
"upgrade": "Upgrade your plan to invite more students",
|
||||
"select_all": "Select all",
|
||||
@@ -2048,6 +2070,14 @@
|
||||
"snackbar_success": "Assigned {assigned} users to courses ({alreadyEnrolled} already enrolled, {emailsSent} emails sent)",
|
||||
"snackbar_no_courses": "Please select at least one course",
|
||||
"snackbar_failed": "Failed to assign courses"
|
||||
},
|
||||
"invite": {
|
||||
"row_actions_aria": "Open actions",
|
||||
"resend": "Resend invite",
|
||||
"revoke": "Revoke pending invite",
|
||||
"resend_snackbar_success": "Invitation email sent",
|
||||
"resend_snackbar_email_failed": "Invite was renewed but the email could not be sent",
|
||||
"revoke_snackbar_success": "Pending invitation revoked"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
@@ -2453,9 +2483,13 @@
|
||||
"invite": {
|
||||
"organization": {
|
||||
"page_title": "Join {orgName} on ClassroomIO",
|
||||
"role_label": "Role: {role}",
|
||||
"email_label": "Invited email: {email}",
|
||||
"join_button": "Join Organization",
|
||||
"invitation_student": "You've been invited into {orgName} as a student.",
|
||||
"invitation_teacher": "You've been invited into {orgName} as a teacher.",
|
||||
"invitation_admin": "You've been invited into {orgName} as an admin.",
|
||||
"invitation_fallback": "You've been invited into {orgName} as {role}.",
|
||||
"email_line": "Email: {email}",
|
||||
"accept_button": "Accept invitation",
|
||||
"go_to_dashboard": "Go to Dashboard",
|
||||
"messages": {
|
||||
"email_mismatch": "You are logged in with a different email from this invite.",
|
||||
"expired": "This invite link has expired.",
|
||||
|
||||
@@ -475,7 +475,54 @@
|
||||
"name": "Nombre",
|
||||
"email": "Correo electrónico",
|
||||
"date_joined": "Fecha de incorporación",
|
||||
"upgrade": "Actualice su plan para invitar a más estudiantes"
|
||||
"upgrade": "Actualice su plan para invitar a más estudiantes",
|
||||
"import_users": "Importar usuarios",
|
||||
"search_placeholder": "Buscar…",
|
||||
"status": "Estado",
|
||||
"status_active": "Activo",
|
||||
"status_pending": "Pendiente",
|
||||
"status_expired": "Caducado",
|
||||
"status_revoked": "Revocado",
|
||||
"select_all": "Seleccionar todo",
|
||||
"selected_count": "{count} seleccionado",
|
||||
"assign_courses": "Asignar a cursos",
|
||||
"invite": {
|
||||
"row_actions_aria": "Acciones abiertas",
|
||||
"resend": "Reenviar invitación",
|
||||
"revoke": "Revocar invitación pendiente",
|
||||
"resend_snackbar_success": "Correo electrónico de invitación enviado",
|
||||
"resend_snackbar_email_failed": "La invitación se renovó pero no se pudo enviar el correo electrónico.",
|
||||
"revoke_snackbar_success": "Invitación pendiente revocada"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Asignar a cursos",
|
||||
"description": "Dar acceso a los cursos a los usuarios seleccionados y, opcionalmente, notificarles por correo electrónico.",
|
||||
"select_courses": "Seleccionar cursos",
|
||||
"select_courses_placeholder": "Elige cursos...",
|
||||
"send_email": "Enviar correo electrónico de notificación",
|
||||
"submit": "Asignar",
|
||||
"cancel": "Cancelar",
|
||||
"snackbar_success": "Usuarios asignados {assigned} a cursos ({alreadyEnrolled} ya inscritos, {emailsSent} correos electrónicos enviados)",
|
||||
"snackbar_no_courses": "Por favor seleccione al menos un curso",
|
||||
"snackbar_failed": "No se pudieron asignar cursos"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar usuarios",
|
||||
"description": "Agregue estudiantes a su organización ingresando sus direcciones de correo electrónico. Opcionalmente, asígnalos a cursos y envía correos electrónicos de invitación.",
|
||||
"emails_label": "Direcciones de correo electrónico",
|
||||
"emails_placeholder": "Ingrese correos electrónicos separados por comas, nuevas líneas o punto y coma\npor ej. [email protected], [email protected]",
|
||||
"course_access": "Acceso al curso",
|
||||
"no_courses": "Sin tarea de curso",
|
||||
"all_courses": "Todos los cursos",
|
||||
"select_courses": "Seleccionar cursos específicos",
|
||||
"select_courses_placeholder": "Elige cursos...",
|
||||
"send_email": "Enviar correo electrónico de invitación a usuarios importados",
|
||||
"submit": "Importar usuarios",
|
||||
"back": "Volver a la audiencia",
|
||||
"snackbar_success": "Usuarios importados {importados} ({skipped} ya existía, {emailsSent} correos electrónicos enviados)",
|
||||
"snackbar_no_emails": "Por favor ingrese al menos una dirección de correo electrónico",
|
||||
"snackbar_failed": "No se pudieron importar usuarios"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
"title": "Comunidad",
|
||||
@@ -940,6 +987,8 @@
|
||||
"complete": "completo",
|
||||
"next": "Siguiente",
|
||||
"prev": "Anterior",
|
||||
"prev_shortcut": "Anterior (←)",
|
||||
"next_shortcut": "Siguiente (→)",
|
||||
"disabled": "La característica está deshabilitada",
|
||||
"version_history": {
|
||||
"title": "Historial de versiones",
|
||||
@@ -1200,6 +1249,9 @@
|
||||
},
|
||||
"edit": {
|
||||
"accepted_answers_placeholder": "Respuestas aceptadas (separadas por comas)"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Enviado:"
|
||||
}
|
||||
},
|
||||
"numeric": {
|
||||
@@ -1217,6 +1269,11 @@
|
||||
"tolerance_info": "Diferencia permitida con respecto al valor correcto (por ejemplo, 0,5 acepta +/-0,5).",
|
||||
"correct_value_placeholder": "Valor correcto",
|
||||
"tolerance_placeholder": "Tolerancia"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Enviado:",
|
||||
"in_range": "Dentro de la tolerancia",
|
||||
"out_of_range": "Tolerancia exterior"
|
||||
}
|
||||
},
|
||||
"short_answer": {
|
||||
@@ -1228,6 +1285,10 @@
|
||||
},
|
||||
"edit": {
|
||||
"instructions_placeholder": "Instrucciones opcionales"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Enviado:",
|
||||
"accepted_label": "Respuestas aceptadas"
|
||||
}
|
||||
},
|
||||
"true_false": {
|
||||
@@ -1291,8 +1352,9 @@
|
||||
"option_prefix": "Opción",
|
||||
"remove": "Quitar",
|
||||
"add_option": "Agregar opción",
|
||||
"correct_badge": "(Correcto)",
|
||||
"not_set": "No establecido"
|
||||
"correct_badge": "Correcto",
|
||||
"not_set": "No establecido",
|
||||
"incorrect_badge": "Incorrecto"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1473,7 +1535,10 @@
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"content_locked_title": "Este contenido está bloqueado.",
|
||||
"content_locked_description": "Su instructor aún no ha desbloqueado este contenido. Complete el contenido anterior o vuelva a consultarlo más tarde."
|
||||
"content_locked_description": "Su instructor aún no ha desbloqueado este contenido. Complete el contenido anterior o vuelva a consultarlo más tarde.",
|
||||
"saving": "Guardando…",
|
||||
"saved": "Guardado",
|
||||
"draft_recovery": "Se encontró un borrador no guardado para esta lección. ¿Quieres restaurarlo?"
|
||||
},
|
||||
"news_feed": {
|
||||
"heading": "Noticias",
|
||||
@@ -1582,6 +1647,7 @@
|
||||
"unpublished": "Inédito",
|
||||
"continue_course": "Continuar",
|
||||
"learn_more": "Más información",
|
||||
"tags_overflow_aria": "{count} etiquetas más",
|
||||
"list_view": {
|
||||
"title": "Título",
|
||||
"description": "Descripción",
|
||||
@@ -2245,7 +2311,8 @@
|
||||
"email_password_disabled_message": "El inicio de sesión con correo electrónico/contraseña está deshabilitado para esta organización.",
|
||||
"google_auth_disabled": "La autenticación de Google está deshabilitada.",
|
||||
"signup_disabled_error": "El registro está deshabilitado para esta organización. Por favor contacte a su administrador.",
|
||||
"or_continue_with": "O continuar con correo electrónico y contraseña"
|
||||
"or_continue_with": "O continuar con correo electrónico y contraseña",
|
||||
"sso_enabled": "{provider} está habilitado para esta organización"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "generales",
|
||||
@@ -2471,7 +2538,8 @@
|
||||
"edit_group": "Editar grupo",
|
||||
"edit_tag": "Editar etiqueta",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar"
|
||||
"cancel": "Cancelar",
|
||||
"actions_menu": "Acciones de etiqueta"
|
||||
},
|
||||
"common": {
|
||||
"not_available": "N/A"
|
||||
@@ -2480,9 +2548,13 @@
|
||||
"invite": {
|
||||
"organization": {
|
||||
"page_title": "Únase a {orgName} en ClassroomIO",
|
||||
"role_label": "Rol: {rol}",
|
||||
"email_label": "Correo electrónico invitado: {email}",
|
||||
"join_button": "Unirse a la organización",
|
||||
"invitation_student": "Has sido invitado a {orgName} como estudiante.",
|
||||
"invitation_teacher": "Has sido invitado a {orgName} como profesor.",
|
||||
"invitation_admin": "Has sido invitado a {orgName} como administrador.",
|
||||
"invitation_fallback": "Has sido invitado a {orgName} como {role}.",
|
||||
"email_line": "Correo electrónico: {email}",
|
||||
"accept_button": "Aceptar invitación",
|
||||
"go_to_dashboard": "Ir al panel",
|
||||
"messages": {
|
||||
"email_mismatch": "Has iniciado sesión con un correo electrónico diferente al de esta invitación.",
|
||||
"expired": "Este enlace de invitación ha caducado.",
|
||||
@@ -2520,6 +2592,17 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Create key"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"title": "Uso y límites",
|
||||
"description": "MCP está disponible en todos los planes, pero el uso se controla con créditos de automatización mensuales y límites clave.",
|
||||
"credits_title": "Créditos restantes",
|
||||
"credits_description": "Las acciones mutantes de MCP consumen créditos. Las acciones de lectura son gratuitas.",
|
||||
"keys_title": "Claves activas",
|
||||
"keys_description": "Utilice una clave MCP por integración para que el acceso se pueda rotar o revocar limpiamente.",
|
||||
"rate_limits_title": "Límites de tarifas",
|
||||
"rate_limits_description": "Solicitudes de lectura/escritura/publicación permitidas por minuto.",
|
||||
"used_label": "Usado:"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
@@ -2534,7 +2617,11 @@
|
||||
"never_used": "nunca",
|
||||
"scopes_label": "Alcances:",
|
||||
"revoke_confirm": "¿Revocar esta clave de automatización?",
|
||||
"rotate_confirm": "¿Girar esta clave de automatización?"
|
||||
"rotate_confirm": "¿Girar esta clave de automatización?",
|
||||
"table_key": "clave",
|
||||
"table_status": "Estado",
|
||||
"table_created": "Creado",
|
||||
"table_last_used": "último usado"
|
||||
},
|
||||
"clients": {
|
||||
"claude_code": "Código Claude",
|
||||
|
||||
@@ -475,7 +475,54 @@
|
||||
"name": "Nom",
|
||||
"email": "Courriel",
|
||||
"date_joined": "Date d'adhésion",
|
||||
"upgrade": "Améliorez votre forfait pour inviter plus d'étudiants"
|
||||
"upgrade": "Améliorez votre forfait pour inviter plus d'étudiants",
|
||||
"import_users": "Importer des utilisateurs",
|
||||
"search_placeholder": "Rechercher…",
|
||||
"status": "Statut",
|
||||
"status_active": "Actif",
|
||||
"status_pending": "En attente",
|
||||
"status_expired": "Expiré",
|
||||
"status_revoked": "Révoqué",
|
||||
"select_all": "Tout sélectionner",
|
||||
"selected_count": "{count} sélectionnés",
|
||||
"assign_courses": "Attribuer aux cours",
|
||||
"invite": {
|
||||
"row_actions_aria": "Actions ouvertes",
|
||||
"resend": "Renvoyer l'invitation",
|
||||
"revoke": "Révoquer l'invitation en attente",
|
||||
"resend_snackbar_success": "E-mail d'invitation envoyé",
|
||||
"resend_snackbar_email_failed": "L'invitation a été renouvelée mais l'e-mail n'a pas pu être envoyé",
|
||||
"revoke_snackbar_success": "Invitation en attente révoquée"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Attribuer aux cours",
|
||||
"description": "Donnez aux utilisateurs sélectionnés l'accès aux cours et éventuellement les avertir par e-mail.",
|
||||
"select_courses": "Sélectionner des cours",
|
||||
"select_courses_placeholder": "Choisissez des cours...",
|
||||
"send_email": "Envoyer un e-mail de notification",
|
||||
"submit": "Attribuer",
|
||||
"cancel": "Annuler",
|
||||
"snackbar_success": "{assigned} utilisateurs affectés aux cours ({alreadyEnrolled} déjà inscrits, {emailsSent} e-mails envoyés)",
|
||||
"snackbar_no_courses": "Veuillez sélectionner au moins un cours",
|
||||
"snackbar_failed": "Échec de l'attribution des cours"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer des utilisateurs",
|
||||
"description": "Ajoutez des étudiants à votre organisation en saisissant leurs adresses e-mail. Vous pouvez éventuellement les attribuer à des cours et envoyer des e-mails d'invitation.",
|
||||
"emails_label": "Adresses e-mail",
|
||||
"emails_placeholder": "Saisissez les e-mails séparés par des virgules, des nouvelles lignes ou des points-virgules\npar ex. [email protected], [email protected]",
|
||||
"course_access": "Accès aux cours",
|
||||
"no_courses": "Aucun devoir de cours",
|
||||
"all_courses": "Tous les cours",
|
||||
"select_courses": "Sélectionnez des cours spécifiques",
|
||||
"select_courses_placeholder": "Choisissez des cours...",
|
||||
"send_email": "Envoyer un e-mail d'invitation aux utilisateurs importés",
|
||||
"submit": "Importer des utilisateurs",
|
||||
"back": "Retour au public",
|
||||
"snackbar_success": "Utilisateurs {importés} importés ({skipped} existait déjà, {emailsSent} e-mails envoyés)",
|
||||
"snackbar_no_emails": "Veuillez saisir au moins une adresse e-mail",
|
||||
"snackbar_failed": "Échec de l'importation des utilisateurs"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
"title": "Communauté",
|
||||
@@ -940,6 +987,8 @@
|
||||
"complete": "Terminé",
|
||||
"next": "Suivant",
|
||||
"prev": "Précédent",
|
||||
"prev_shortcut": "Précédent (←)",
|
||||
"next_shortcut": "Suivant (→)",
|
||||
"disabled": "La fonctionnalité est désactivée",
|
||||
"version_history": {
|
||||
"title": "Historique des versions",
|
||||
@@ -1200,6 +1249,9 @@
|
||||
},
|
||||
"edit": {
|
||||
"accepted_answers_placeholder": "Réponses acceptées (séparées par des virgules)"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Soumis :"
|
||||
}
|
||||
},
|
||||
"numeric": {
|
||||
@@ -1217,6 +1269,11 @@
|
||||
"tolerance_info": "Différence autorisée par rapport à la valeur correcte (par exemple, 0,5 accepte +/-0,5).",
|
||||
"correct_value_placeholder": "Valeur correcte",
|
||||
"tolerance_placeholder": "Tolérance"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Soumis :",
|
||||
"in_range": "Dans les limites de tolérance",
|
||||
"out_of_range": "Hors tolérance"
|
||||
}
|
||||
},
|
||||
"short_answer": {
|
||||
@@ -1228,6 +1285,10 @@
|
||||
},
|
||||
"edit": {
|
||||
"instructions_placeholder": "Instructions facultatives"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Soumis :",
|
||||
"accepted_label": "Réponses acceptées"
|
||||
}
|
||||
},
|
||||
"true_false": {
|
||||
@@ -1291,8 +1352,9 @@
|
||||
"option_prefix": "Options",
|
||||
"remove": "Supprimer",
|
||||
"add_option": "Ajouter une option",
|
||||
"correct_badge": "(C'est vrai)",
|
||||
"not_set": "Non défini"
|
||||
"correct_badge": "C'est vrai",
|
||||
"not_set": "Non défini",
|
||||
"incorrect_badge": "Incorrect"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1473,7 +1535,10 @@
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"content_locked_title": "Ce contenu est verrouillé",
|
||||
"content_locked_description": "Votre instructeur n'a pas encore débloqué ce contenu. Complétez le contenu précédent ou revenez plus tard."
|
||||
"content_locked_description": "Votre instructeur n'a pas encore débloqué ce contenu. Complétez le contenu précédent ou revenez plus tard.",
|
||||
"saving": "Sauvegarde…",
|
||||
"saved": "Enregistré",
|
||||
"draft_recovery": "Un brouillon non enregistré a été trouvé pour cette leçon. Souhaitez-vous le restaurer ?"
|
||||
},
|
||||
"news_feed": {
|
||||
"heading": "Fil d'actualité",
|
||||
@@ -1582,6 +1647,7 @@
|
||||
"unpublished": "Inédit",
|
||||
"continue_course": "Continuer",
|
||||
"learn_more": "En savoir plus",
|
||||
"tags_overflow_aria": "{count} autres tags",
|
||||
"list_view": {
|
||||
"title": "Titre",
|
||||
"description": "Descriptif",
|
||||
@@ -2245,7 +2311,8 @@
|
||||
"email_password_disabled_message": "La connexion par e-mail/mot de passe est désactivée pour cette organisation.",
|
||||
"google_auth_disabled": "L'authentification Google est désactivée.",
|
||||
"signup_disabled_error": "L'inscription est désactivée pour cette organisation. Veuillez contacter votre administrateur.",
|
||||
"or_continue_with": "Ou continuez avec email et mot de passe"
|
||||
"or_continue_with": "Ou continuez avec email et mot de passe",
|
||||
"sso_enabled": "{provider} est activé pour cette organisation"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "Général",
|
||||
@@ -2471,7 +2538,8 @@
|
||||
"edit_group": "Modifier le groupe",
|
||||
"edit_tag": "Modifier la balise",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler"
|
||||
"cancel": "Annuler",
|
||||
"actions_menu": "Actions de balise"
|
||||
},
|
||||
"common": {
|
||||
"not_available": "N/D"
|
||||
@@ -2480,9 +2548,13 @@
|
||||
"invite": {
|
||||
"organization": {
|
||||
"page_title": "Rejoignez {orgName} sur ClassroomIO",
|
||||
"role_label": "Rôle : {rôle}",
|
||||
"email_label": "E-mail invité : {email}",
|
||||
"join_button": "Rejoindre l'organisation",
|
||||
"invitation_student": "Vous avez été invité(e) dans {orgName} en tant qu'étudiant.",
|
||||
"invitation_teacher": "Vous avez été invité(e) dans {orgName} en tant qu'enseignant.",
|
||||
"invitation_admin": "Vous avez été invité(e) dans {orgName} en tant qu'administrateur.",
|
||||
"invitation_fallback": "Vous avez été invité(e) dans {orgName} en tant que {role}.",
|
||||
"email_line": "E-mail : {email}",
|
||||
"accept_button": "Accepter l'invitation",
|
||||
"go_to_dashboard": "Aller au tableau de bord",
|
||||
"messages": {
|
||||
"email_mismatch": "Vous êtes connecté avec un e-mail différent de celui de cette invitation.",
|
||||
"expired": "Ce lien d'invitation a expiré.",
|
||||
@@ -2520,6 +2592,17 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Create key"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"title": "Utilisation et limites",
|
||||
"description": "MCP est disponible sur chaque forfait, mais l'utilisation est contrôlée par des crédits d'automatisation mensuels et des limites clés.",
|
||||
"credits_title": "Crédits restants",
|
||||
"credits_description": "Les actions MCP en mutation consomment des crédits. Les actions de lecture sont gratuites.",
|
||||
"keys_title": "Clés actives",
|
||||
"keys_description": "Utilisez une clé MCP par intégration afin que l'accès puisse être alterné ou révoqué proprement.",
|
||||
"rate_limits_title": "Limites de taux",
|
||||
"rate_limits_description": "Requêtes de lecture/écriture/publication autorisées par minute.",
|
||||
"used_label": "Utilisé :"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
@@ -2534,7 +2617,11 @@
|
||||
"never_used": "Jamais",
|
||||
"scopes_label": "Portées :",
|
||||
"revoke_confirm": "Révoquer cette clé d'automatisation ?",
|
||||
"rotate_confirm": "Faire pivoter cette clé d'automatisation ?"
|
||||
"rotate_confirm": "Faire pivoter cette clé d'automatisation ?",
|
||||
"table_key": "Clé",
|
||||
"table_status": "Statut",
|
||||
"table_created": "Créé",
|
||||
"table_last_used": "Dernière utilisation"
|
||||
},
|
||||
"clients": {
|
||||
"claude_code": "Claude Code",
|
||||
|
||||
@@ -475,7 +475,54 @@
|
||||
"name": "नाम",
|
||||
"email": "ईमेल",
|
||||
"date_joined": "शामिल होने की तिथि",
|
||||
"upgrade": "अधिक छात्रों को आमंत्रित करने के लिए अपनी योजना को अपग्रेड करें"
|
||||
"upgrade": "अधिक छात्रों को आमंत्रित करने के लिए अपनी योजना को अपग्रेड करें",
|
||||
"import_users": "उपयोगकर्ता आयात करें",
|
||||
"search_placeholder": "खोजें…",
|
||||
"status": "स्थिति",
|
||||
"status_active": "सक्रिय",
|
||||
"status_pending": "लंबित",
|
||||
"status_expired": "समाप्त हो गया",
|
||||
"status_revoked": "निरस्त किया गया",
|
||||
"select_all": "सभी का चयन करें",
|
||||
"selected_count": "{गिनती} चयनित",
|
||||
"assign_courses": "पाठ्यक्रमों को असाइन करें",
|
||||
"invite": {
|
||||
"row_actions_aria": "खुली कार्रवाइयां",
|
||||
"resend": "आमंत्रण पुनः भेजें",
|
||||
"revoke": "लंबित आमंत्रण निरस्त करें",
|
||||
"resend_snackbar_success": "आमंत्रण ईमेल भेजा गया",
|
||||
"resend_snackbar_email_failed": "आमंत्रण नवीनीकृत किया गया लेकिन ईमेल नहीं भेजा जा सका",
|
||||
"revoke_snackbar_success": "लंबित आमंत्रण निरस्त कर दिया गया"
|
||||
},
|
||||
"assign": {
|
||||
"title": "पाठ्यक्रमों को असाइन करें",
|
||||
"description": "चयनित उपयोगकर्ताओं को पाठ्यक्रमों तक पहुंच प्रदान करें और वैकल्पिक रूप से उन्हें ईमेल द्वारा सूचित करें।",
|
||||
"select_courses": "पाठ्यक्रम चुनें",
|
||||
"select_courses_placeholder": "पाठ्यक्रम चुनें...",
|
||||
"send_email": "अधिसूचना ईमेल भेजें",
|
||||
"submit": "नियुक्त करें",
|
||||
"cancel": "रद्द करें",
|
||||
"snackbar_success": "पाठ्यक्रमों के लिए {assigned} उपयोगकर्ताओं को असाइन किया गया ({alreadyEnrolled} पहले से ही नामांकित, {emailsSent} ईमेल भेजे गए)",
|
||||
"snackbar_no_courses": "कृपया कम से कम एक पाठ्यक्रम चुनें",
|
||||
"snackbar_failed": "पाठ्यक्रम आवंटित करने में विफल"
|
||||
},
|
||||
"import": {
|
||||
"title": "उपयोगकर्ता आयात करें",
|
||||
"description": "छात्रों के ईमेल पते दर्ज करके उन्हें अपने संगठन में जोड़ें। वैकल्पिक रूप से उन्हें पाठ्यक्रमों में नियुक्त करें और आमंत्रण ईमेल भेजें।",
|
||||
"emails_label": "ईमेल पते",
|
||||
"emails_placeholder": "ईमेल को अल्पविराम, न्यूलाइन या अर्धविराम से अलग करके दर्ज करें\nजैसे [email protected], [email protected]",
|
||||
"course_access": "पाठ्यक्रम प्रवेश",
|
||||
"no_courses": "कोई पाठ्यक्रम असाइनमेंट नहीं",
|
||||
"all_courses": "सभी पाठ्यक्रम",
|
||||
"select_courses": "विशिष्ट पाठ्यक्रम चुनें",
|
||||
"select_courses_placeholder": "पाठ्यक्रम चुनें...",
|
||||
"send_email": "आयातित उपयोगकर्ताओं को आमंत्रण ईमेल भेजें",
|
||||
"submit": "उपयोगकर्ता आयात करें",
|
||||
"back": "दर्शकों के पास वापस जाएँ",
|
||||
"snackbar_success": "आयातित {आयातित} उपयोगकर्ता ({स्किप्ड} पहले से मौजूद थे, {ईमेल्ससेंट} ईमेल भेजे गए)",
|
||||
"snackbar_no_emails": "कृपया कम से कम एक ईमेल पता दर्ज करें",
|
||||
"snackbar_failed": "उपयोगकर्ताओं को आयात करने में विफल"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
"title": "समुदाय",
|
||||
@@ -940,6 +987,8 @@
|
||||
"complete": "पूर्ण",
|
||||
"next": "अगला",
|
||||
"prev": "पिछला",
|
||||
"prev_shortcut": "पिछला (←)",
|
||||
"next_shortcut": "अगला (→)",
|
||||
"disabled": "सुविधा अक्षम है",
|
||||
"version_history": {
|
||||
"title": "संस्करण इतिहास",
|
||||
@@ -1200,6 +1249,9 @@
|
||||
},
|
||||
"edit": {
|
||||
"accepted_answers_placeholder": "स्वीकृत उत्तर (अल्पविराम से अलग)"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "प्रस्तुत:"
|
||||
}
|
||||
},
|
||||
"numeric": {
|
||||
@@ -1217,6 +1269,11 @@
|
||||
"tolerance_info": "सही मान से स्वीकार्य अंतर (उदाहरण के लिए, 0.5 +/-0.5 स्वीकार करता है)।",
|
||||
"correct_value_placeholder": "सही मूल्य",
|
||||
"tolerance_placeholder": "सहनशीलता"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "प्रस्तुत:",
|
||||
"in_range": "सहनशीलता के भीतर",
|
||||
"out_of_range": "बाहर की सहनशीलता"
|
||||
}
|
||||
},
|
||||
"short_answer": {
|
||||
@@ -1228,6 +1285,10 @@
|
||||
},
|
||||
"edit": {
|
||||
"instructions_placeholder": "वैकल्पिक निर्देश"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "प्रस्तुत:",
|
||||
"accepted_label": "स्वीकृत उत्तर"
|
||||
}
|
||||
},
|
||||
"true_false": {
|
||||
@@ -1291,8 +1352,9 @@
|
||||
"option_prefix": "विकल्प",
|
||||
"remove": "हटाओ",
|
||||
"add_option": "विकल्प जोड़ें",
|
||||
"correct_badge": "(सही)",
|
||||
"not_set": "सेट नहीं"
|
||||
"correct_badge": "सही",
|
||||
"not_set": "सेट नहीं",
|
||||
"incorrect_badge": "गलत"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1473,7 +1535,10 @@
|
||||
"cancel": "रद्द करें"
|
||||
},
|
||||
"content_locked_title": "यह सामग्री लॉक है",
|
||||
"content_locked_description": "आपके प्रशिक्षक ने अभी तक इस सामग्री को अनलॉक नहीं किया है। पिछली सामग्री पूरी करें या बाद में दोबारा जाँचें।"
|
||||
"content_locked_description": "आपके प्रशिक्षक ने अभी तक इस सामग्री को अनलॉक नहीं किया है। पिछली सामग्री पूरी करें या बाद में दोबारा जाँचें।",
|
||||
"saving": "सहेजा जा रहा है...",
|
||||
"saved": "सहेजा गया",
|
||||
"draft_recovery": "इस पाठ के लिए एक सहेजा न गया ड्राफ्ट मिला। क्या आप इसे पुनर्स्थापित करना चाहेंगे?"
|
||||
},
|
||||
"news_feed": {
|
||||
"heading": "समाचार फ़ीड",
|
||||
@@ -1582,6 +1647,7 @@
|
||||
"unpublished": "अप्रकाशित",
|
||||
"continue_course": "जारी रखें",
|
||||
"learn_more": "और जानें",
|
||||
"tags_overflow_aria": "{count} और टैग",
|
||||
"list_view": {
|
||||
"title": "शीर्षक",
|
||||
"description": "विवरण",
|
||||
@@ -2245,7 +2311,8 @@
|
||||
"email_password_disabled_message": "इस संगठन के लिए ईमेल/पासवर्ड लॉगिन अक्षम है।",
|
||||
"google_auth_disabled": "Google प्रमाणीकरण अक्षम है.",
|
||||
"signup_disabled_error": "इस संगठन के लिए साइनअप अक्षम है. कृपया अपने व्यवस्थापक से संपर्क करें.",
|
||||
"or_continue_with": "या ईमेल और पासवर्ड के साथ जारी रखें"
|
||||
"or_continue_with": "या ईमेल और पासवर्ड के साथ जारी रखें",
|
||||
"sso_enabled": "इस संगठन के लिए {प्रदाता} सक्षम है"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "सामान्य",
|
||||
@@ -2471,7 +2538,8 @@
|
||||
"edit_group": "समूह संपादित करें",
|
||||
"edit_tag": "टैग संपादित करें",
|
||||
"save": "सहेजें",
|
||||
"cancel": "रद्द करें"
|
||||
"cancel": "रद्द करें",
|
||||
"actions_menu": "क्रियाएँ टैग करें"
|
||||
},
|
||||
"common": {
|
||||
"not_available": "एन/ए"
|
||||
@@ -2480,9 +2548,13 @@
|
||||
"invite": {
|
||||
"organization": {
|
||||
"page_title": "ClassroomIO पर {orgName} से जुड़ें",
|
||||
"role_label": "भूमिका: {भूमिका}",
|
||||
"email_label": "आमंत्रित ईमेल: {ईमेल}",
|
||||
"join_button": "संगठन से जुड़ें",
|
||||
"invitation_student": "आपको {orgName} में छात्र के रूप में आमंत्रित किया गया है।",
|
||||
"invitation_teacher": "आपको {orgName} में शिक्षक के रूप में आमंत्रित किया गया है।",
|
||||
"invitation_admin": "आपको {orgName} में व्यवस्थापक के रूप में आमंत्रित किया गया है।",
|
||||
"invitation_fallback": "आपको {orgName} में {role} के रूप में आमंत्रित किया गया है।",
|
||||
"email_line": "ईमेल: {email}",
|
||||
"accept_button": "निमंत्रण स्वीकार करें",
|
||||
"go_to_dashboard": "डैशबोर्ड पर जाएं",
|
||||
"messages": {
|
||||
"email_mismatch": "आप इस आमंत्रण से भिन्न ईमेल से लॉग इन हैं।",
|
||||
"expired": "यह आमंत्रण लिंक समाप्त हो गया है.",
|
||||
@@ -2520,6 +2592,17 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Create key"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"title": "उपयोग और सीमाएँ",
|
||||
"description": "एमसीपी हर योजना पर उपलब्ध है, लेकिन उपयोग को मासिक स्वचालन क्रेडिट और प्रमुख सीमाओं के साथ नियंत्रित किया जाता है।",
|
||||
"credits_title": "क्रेडिट शेष",
|
||||
"credits_description": "MCP क्रियाओं को बदलने से क्रेडिट की खपत होती है। पढ़ें क्रियाएँ निःशुल्क हैं।",
|
||||
"keys_title": "सक्रिय कुंजियाँ",
|
||||
"keys_description": "प्रति एकीकरण एक एमसीपी कुंजी का उपयोग करें ताकि पहुंच को घुमाया जा सके या सफाई से रद्द किया जा सके।",
|
||||
"rate_limits_title": "दर सीमा",
|
||||
"rate_limits_description": "प्रति मिनट पढ़ने/लिखने/प्रकाशित करने के अनुरोधों की अनुमति है।",
|
||||
"used_label": "प्रयुक्त:"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
@@ -2534,7 +2617,11 @@
|
||||
"never_used": "कभी नहीं",
|
||||
"scopes_label": "दायरा:",
|
||||
"revoke_confirm": "इस स्वचालन कुंजी को निरस्त करें?",
|
||||
"rotate_confirm": "इस स्वचालन कुंजी को घुमाएँ?"
|
||||
"rotate_confirm": "इस स्वचालन कुंजी को घुमाएँ?",
|
||||
"table_key": "कुंजी",
|
||||
"table_status": "स्थिति",
|
||||
"table_created": "बनाया गया",
|
||||
"table_last_used": "अंतिम बार उपयोग किया गया"
|
||||
},
|
||||
"clients": {
|
||||
"claude_code": "क्लाउड कोड",
|
||||
|
||||
@@ -475,7 +475,54 @@
|
||||
"name": "Imię",
|
||||
"email": "E-mail",
|
||||
"date_joined": "Data dołączenia",
|
||||
"upgrade": "Uaktualnij swój plan, aby zaprosić więcej uczniów"
|
||||
"upgrade": "Uaktualnij swój plan, aby zaprosić więcej uczniów",
|
||||
"import_users": "Importuj użytkowników",
|
||||
"search_placeholder": "Szukaj…",
|
||||
"status": "Stan",
|
||||
"status_active": "Aktywny",
|
||||
"status_pending": "Oczekujące",
|
||||
"status_expired": "Wygasło",
|
||||
"status_revoked": "Unieważnione",
|
||||
"select_all": "Zaznacz wszystko",
|
||||
"selected_count": "Wybrano {count}",
|
||||
"assign_courses": "Przypisz do kursów",
|
||||
"invite": {
|
||||
"row_actions_aria": "Otwórz działania",
|
||||
"resend": "Wyślij zaproszenie ponownie",
|
||||
"revoke": "Odwołaj oczekujące zaproszenie",
|
||||
"resend_snackbar_success": "E-mail z zaproszeniem wysłany",
|
||||
"resend_snackbar_email_failed": "Zaproszenie zostało odnowione, ale e-mail nie mógł zostać wysłany",
|
||||
"revoke_snackbar_success": "Oczekujące zaproszenie zostało odwołane"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Przypisz do kursów",
|
||||
"description": "Nadaj wybranym użytkownikom dostęp do kursów i opcjonalnie powiadom ich e-mailem.",
|
||||
"select_courses": "Wybierz kursy",
|
||||
"select_courses_placeholder": "Wybierz kursy...",
|
||||
"send_email": "Wyślij e-mail z powiadomieniem",
|
||||
"submit": "Przypisz",
|
||||
"cancel": "Anuluj",
|
||||
"snackbar_success": "Przypisano {przydzielonych} użytkowników do kursów ({alreadyEnrolled} jest już zapisanych, wysłano e-maile: {emailsSent})",
|
||||
"snackbar_no_courses": "Proszę wybrać przynajmniej jeden kurs",
|
||||
"snackbar_failed": "Nie udało się przypisać kursów"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importuj użytkowników",
|
||||
"description": "Dodaj uczniów do swojej organizacji, wprowadzając ich adresy e-mail. Opcjonalnie przypisz je do kursów i wyślij e-maile z zaproszeniami.",
|
||||
"emails_label": "Adresy e-mail",
|
||||
"emails_placeholder": "Wpisz adresy e-mail oddzielone przecinkami, znakami nowej linii lub średnikami\nnp. [email protected], [email protected]",
|
||||
"course_access": "Dostęp do kursu",
|
||||
"no_courses": "Brak przypisanego kursu",
|
||||
"all_courses": "Wszystkie kursy",
|
||||
"select_courses": "Wybierz konkretne kursy",
|
||||
"select_courses_placeholder": "Wybierz kursy...",
|
||||
"send_email": "Wyślij wiadomość e-mail z zaproszeniem do zaimportowanych użytkowników",
|
||||
"submit": "Importuj użytkowników",
|
||||
"back": "Powrót do Publiczności",
|
||||
"snackbar_success": "Zaimportowani użytkownicy: {imported} ({skipped} już istniało, wysłano e-maile: {emailsSent})",
|
||||
"snackbar_no_emails": "Proszę podać przynajmniej jeden adres e-mail",
|
||||
"snackbar_failed": "Nie udało się zaimportować użytkowników"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
"title": "Społeczność",
|
||||
@@ -940,6 +987,8 @@
|
||||
"complete": "Kompletny",
|
||||
"next": "Następny",
|
||||
"prev": "Poprzednia",
|
||||
"prev_shortcut": "Poprzedni (←)",
|
||||
"next_shortcut": "Następny (→)",
|
||||
"disabled": "Funkcja jest wyłączona",
|
||||
"version_history": {
|
||||
"title": "Historia wersji",
|
||||
@@ -1200,6 +1249,9 @@
|
||||
},
|
||||
"edit": {
|
||||
"accepted_answers_placeholder": "Zaakceptowane odpowiedzi (oddzielone przecinkami)"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Przesłane:"
|
||||
}
|
||||
},
|
||||
"numeric": {
|
||||
@@ -1217,6 +1269,11 @@
|
||||
"tolerance_info": "Dopuszczalna różnica od prawidłowej wartości (na przykład 0,5 akceptuje +/-0,5).",
|
||||
"correct_value_placeholder": "Prawidłowa wartość",
|
||||
"tolerance_placeholder": "Tolerancja"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Przesłane:",
|
||||
"in_range": "W ramach tolerancji",
|
||||
"out_of_range": "Tolerancja zewnętrzna"
|
||||
}
|
||||
},
|
||||
"short_answer": {
|
||||
@@ -1228,6 +1285,10 @@
|
||||
},
|
||||
"edit": {
|
||||
"instructions_placeholder": "Instrukcje opcjonalne"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Przesłane:",
|
||||
"accepted_label": "Zaakceptowane odpowiedzi"
|
||||
}
|
||||
},
|
||||
"true_false": {
|
||||
@@ -1291,8 +1352,9 @@
|
||||
"option_prefix": "Opcja",
|
||||
"remove": "Usuń",
|
||||
"add_option": "Dodaj opcję",
|
||||
"correct_badge": "(Poprawnie)",
|
||||
"not_set": "Nie ustawiono"
|
||||
"correct_badge": "Poprawnie",
|
||||
"not_set": "Nie ustawiono",
|
||||
"incorrect_badge": "Niepoprawne"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1473,7 +1535,10 @@
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"content_locked_title": "Ta zawartość jest zablokowana",
|
||||
"content_locked_description": "Twój instruktor nie odblokował jeszcze tej zawartości. Uzupełnij poprzednią zawartość lub sprawdź później."
|
||||
"content_locked_description": "Twój instruktor nie odblokował jeszcze tej zawartości. Uzupełnij poprzednią zawartość lub sprawdź później.",
|
||||
"saving": "Zapisuję…",
|
||||
"saved": "Zapisano",
|
||||
"draft_recovery": "Znaleziono niezapisaną wersję roboczą tej lekcji. Czy chcesz go przywrócić?"
|
||||
},
|
||||
"news_feed": {
|
||||
"heading": "Kanał wiadomości",
|
||||
@@ -1582,6 +1647,7 @@
|
||||
"unpublished": "Niepublikowane",
|
||||
"continue_course": "Kontynuuj",
|
||||
"learn_more": "Dowiedz się więcej",
|
||||
"tags_overflow_aria": "Jeszcze {count} tagów",
|
||||
"list_view": {
|
||||
"title": "Tytuł",
|
||||
"description": "Opis",
|
||||
@@ -2245,7 +2311,8 @@
|
||||
"email_password_disabled_message": "Logowanie za pomocą adresu e-mail/hasła jest wyłączone w tej organizacji.",
|
||||
"google_auth_disabled": "Uwierzytelnianie Google jest wyłączone.",
|
||||
"signup_disabled_error": "Rejestracja w tej organizacji jest wyłączona. Skontaktuj się ze swoim administratorem.",
|
||||
"or_continue_with": "Możesz też kontynuować, podając adres e-mail i hasło"
|
||||
"or_continue_with": "Możesz też kontynuować, podając adres e-mail i hasło",
|
||||
"sso_enabled": "Usługa {provider} jest włączona dla tej organizacji"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "Generał",
|
||||
@@ -2471,7 +2538,8 @@
|
||||
"edit_group": "Edytuj grupę",
|
||||
"edit_tag": "Edytuj znacznik",
|
||||
"save": "Zapisz",
|
||||
"cancel": "Anuluj"
|
||||
"cancel": "Anuluj",
|
||||
"actions_menu": "Oznacz działania"
|
||||
},
|
||||
"common": {
|
||||
"not_available": "Nie dotyczy"
|
||||
@@ -2480,9 +2548,13 @@
|
||||
"invite": {
|
||||
"organization": {
|
||||
"page_title": "Dołącz do organizacji {orgName} w ClassroomIO",
|
||||
"role_label": "Rola: {rola}",
|
||||
"email_label": "Zaproszony e-mail: {email}",
|
||||
"join_button": "Dołącz do organizacji",
|
||||
"invitation_student": "Zostałeś zaproszony do {orgName} jako uczeń.",
|
||||
"invitation_teacher": "Zostałeś zaproszony do {orgName} jako nauczyciel.",
|
||||
"invitation_admin": "Zostałeś zaproszony do {orgName} jako administrator.",
|
||||
"invitation_fallback": "Zostałeś zaproszony do {orgName} jako {role}.",
|
||||
"email_line": "E-mail: {email}",
|
||||
"accept_button": "Zaakceptuj zaproszenie",
|
||||
"go_to_dashboard": "Przejdź do panelu",
|
||||
"messages": {
|
||||
"email_mismatch": "Jesteś zalogowany przy użyciu innego adresu e-mail niż to zaproszenie.",
|
||||
"expired": "Ten link z zaproszeniem wygasł.",
|
||||
@@ -2520,6 +2592,17 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Create key"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"title": "Zastosowanie i ograniczenia",
|
||||
"description": "MCP jest dostępne w każdym planie, ale wykorzystanie jest kontrolowane za pomocą miesięcznych kredytów automatyzacji i limitów kluczowych.",
|
||||
"credits_title": "Pozostały kredyty",
|
||||
"credits_description": "Mutowanie działań MCP zużywa kredyty. Czytanie akcji jest bezpłatne.",
|
||||
"keys_title": "Aktywne klucze",
|
||||
"keys_description": "Użyj jednego klucza MCP na każdą integrację, aby umożliwić łatwą rotację lub cofnięcie dostępu.",
|
||||
"rate_limits_title": "Limity stawek",
|
||||
"rate_limits_description": "Żądania odczytu/zapisu/publikowania dozwolone na minutę.",
|
||||
"used_label": "Używane:"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
@@ -2534,7 +2617,11 @@
|
||||
"never_used": "Nigdy",
|
||||
"scopes_label": "Zakresy:",
|
||||
"revoke_confirm": "Unieważnić ten klucz automatyzacji?",
|
||||
"rotate_confirm": "Obrócić ten klucz automatyzacji?"
|
||||
"rotate_confirm": "Obrócić ten klucz automatyzacji?",
|
||||
"table_key": "Klucz",
|
||||
"table_status": "Stan",
|
||||
"table_created": "Utworzono",
|
||||
"table_last_used": "Ostatnio używany"
|
||||
},
|
||||
"clients": {
|
||||
"claude_code": "Claude'a Koda",
|
||||
|
||||
@@ -475,7 +475,54 @@
|
||||
"name": "Nome",
|
||||
"email": "E-mail",
|
||||
"date_joined": "Data de adesão",
|
||||
"upgrade": "Atualize seu plano para convidar mais alunos"
|
||||
"upgrade": "Atualize seu plano para convidar mais alunos",
|
||||
"import_users": "Importar usuários",
|
||||
"search_placeholder": "Pesquisar…",
|
||||
"status": "Estado",
|
||||
"status_active": "Ativo",
|
||||
"status_pending": "Pendente",
|
||||
"status_expired": "Expirado",
|
||||
"status_revoked": "Revogado",
|
||||
"select_all": "Selecionar tudo",
|
||||
"selected_count": "{contagem} selecionados",
|
||||
"assign_courses": "Atribuir a cursos",
|
||||
"invite": {
|
||||
"row_actions_aria": "Ações abertas",
|
||||
"resend": "Reenviar convite",
|
||||
"revoke": "Revogar convite pendente",
|
||||
"resend_snackbar_success": "E-mail de convite enviado",
|
||||
"resend_snackbar_email_failed": "O convite foi renovado, mas não foi possível enviar o e-mail",
|
||||
"revoke_snackbar_success": "Convite pendente revogado"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Atribuir a cursos",
|
||||
"description": "Conceda aos usuários selecionados acesso aos cursos e, opcionalmente, notifique-os por e-mail.",
|
||||
"select_courses": "Selecione os cursos",
|
||||
"select_courses_placeholder": "Escolha cursos...",
|
||||
"send_email": "Enviar e-mail de notificação",
|
||||
"submit": "Atribuir",
|
||||
"cancel": "Cancelar",
|
||||
"snackbar_success": "{assigned} usuários atribuídos aos cursos ({alreadyEnrolled} já inscrito, {emailsSent} e-mails enviados)",
|
||||
"snackbar_no_courses": "Selecione pelo menos um curso",
|
||||
"snackbar_failed": "Falha ao atribuir cursos"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar usuários",
|
||||
"description": "Adicione alunos à sua organização inserindo seus endereços de e-mail. Opcionalmente, atribua-os a cursos e envie e-mails de convite.",
|
||||
"emails_label": "Endereços de e-mail",
|
||||
"emails_placeholder": "Insira e-mails separados por vírgulas, novas linhas ou ponto e vírgula\npor exemplo [email protected], [email protected]",
|
||||
"course_access": "Acesso ao curso",
|
||||
"no_courses": "Nenhuma tarefa de curso",
|
||||
"all_courses": "Todos os cursos",
|
||||
"select_courses": "Selecione cursos específicos",
|
||||
"select_courses_placeholder": "Escolha cursos...",
|
||||
"send_email": "Enviar e-mail de convite para usuários importados",
|
||||
"submit": "Importar usuários",
|
||||
"back": "Voltar ao público",
|
||||
"snackbar_success": "Usuários {importados} importados ({skipped} já existia, {emailsSent} emails enviados)",
|
||||
"snackbar_no_emails": "Insira pelo menos um endereço de e-mail",
|
||||
"snackbar_failed": "Falha ao importar usuários"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
"title": "Comunidade",
|
||||
@@ -940,6 +987,8 @@
|
||||
"complete": "Completo",
|
||||
"next": "Próximo",
|
||||
"prev": "Anterior",
|
||||
"prev_shortcut": "Anterior (←)",
|
||||
"next_shortcut": "Próximo (→)",
|
||||
"disabled": "O recurso está desativado",
|
||||
"version_history": {
|
||||
"title": "Histórico de versões",
|
||||
@@ -1200,6 +1249,9 @@
|
||||
},
|
||||
"edit": {
|
||||
"accepted_answers_placeholder": "Respostas aceitas (separadas por vírgula)"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Enviado:"
|
||||
}
|
||||
},
|
||||
"numeric": {
|
||||
@@ -1217,6 +1269,11 @@
|
||||
"tolerance_info": "Diferença permitida do valor correto (por exemplo, 0,5 aceita +/-0,5).",
|
||||
"correct_value_placeholder": "Valor correto",
|
||||
"tolerance_placeholder": "Tolerância"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Enviado:",
|
||||
"in_range": "Dentro da tolerância",
|
||||
"out_of_range": "Tolerância externa"
|
||||
}
|
||||
},
|
||||
"short_answer": {
|
||||
@@ -1228,6 +1285,10 @@
|
||||
},
|
||||
"edit": {
|
||||
"instructions_placeholder": "Instruções opcionais"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Enviado:",
|
||||
"accepted_label": "Respostas aceitas"
|
||||
}
|
||||
},
|
||||
"true_false": {
|
||||
@@ -1291,8 +1352,9 @@
|
||||
"option_prefix": "Opção",
|
||||
"remove": "Remover",
|
||||
"add_option": "Adicionar opção",
|
||||
"correct_badge": "(Correto)",
|
||||
"not_set": "Não definido"
|
||||
"correct_badge": "Correto",
|
||||
"not_set": "Não definido",
|
||||
"incorrect_badge": "Incorreto"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1473,7 +1535,10 @@
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"content_locked_title": "Este conteúdo está bloqueado",
|
||||
"content_locked_description": "Seu instrutor ainda não desbloqueou este conteúdo. Conclua o conteúdo anterior ou volte mais tarde."
|
||||
"content_locked_description": "Seu instrutor ainda não desbloqueou este conteúdo. Conclua o conteúdo anterior ou volte mais tarde.",
|
||||
"saving": "Salvando…",
|
||||
"saved": "Salvo",
|
||||
"draft_recovery": "Um rascunho não salvo foi encontrado para esta lição. Você gostaria de restaurá-lo?"
|
||||
},
|
||||
"news_feed": {
|
||||
"heading": "Feed de notícias",
|
||||
@@ -1582,6 +1647,7 @@
|
||||
"unpublished": "Não publicado",
|
||||
"continue_course": "Continuar",
|
||||
"learn_more": "Saiba mais",
|
||||
"tags_overflow_aria": "Mais {count} tags",
|
||||
"list_view": {
|
||||
"title": "Título",
|
||||
"description": "Descrição",
|
||||
@@ -2245,7 +2311,8 @@
|
||||
"email_password_disabled_message": "O login por e-mail/senha está desativado para esta organização.",
|
||||
"google_auth_disabled": "A autenticação do Google está desativada.",
|
||||
"signup_disabled_error": "A inscrição está desativada para esta organização. Entre em contato com seu administrador.",
|
||||
"or_continue_with": "Ou continue com e-mail e senha"
|
||||
"or_continue_with": "Ou continue com e-mail e senha",
|
||||
"sso_enabled": "{provider} está ativado para esta organização"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "Geral",
|
||||
@@ -2471,7 +2538,8 @@
|
||||
"edit_group": "Editar grupo",
|
||||
"edit_tag": "Editar etiqueta",
|
||||
"save": "Salvar",
|
||||
"cancel": "Cancelar"
|
||||
"cancel": "Cancelar",
|
||||
"actions_menu": "Ações de tags"
|
||||
},
|
||||
"common": {
|
||||
"not_available": "N/A"
|
||||
@@ -2480,9 +2548,13 @@
|
||||
"invite": {
|
||||
"organization": {
|
||||
"page_title": "Junte-se a {orgName} no ClassroomIO",
|
||||
"role_label": "Função: {função}",
|
||||
"email_label": "E-mail convidado: {email}",
|
||||
"join_button": "Junte-se à organização",
|
||||
"invitation_student": "Você foi convidado para {orgName} como aluno.",
|
||||
"invitation_teacher": "Você foi convidado para {orgName} como professor.",
|
||||
"invitation_admin": "Você foi convidado para {orgName} como administrador.",
|
||||
"invitation_fallback": "Você foi convidado para {orgName} como {role}.",
|
||||
"email_line": "E-mail: {email}",
|
||||
"accept_button": "Aceitar convite",
|
||||
"go_to_dashboard": "Ir para o painel",
|
||||
"messages": {
|
||||
"email_mismatch": "Você está conectado com um e-mail diferente deste convite.",
|
||||
"expired": "Este link de convite expirou.",
|
||||
@@ -2520,6 +2592,17 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Create key"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"title": "Uso e limites",
|
||||
"description": "O MCP está disponível em todos os planos, mas o uso é controlado com créditos mensais de automação e limites principais.",
|
||||
"credits_title": "Créditos restantes",
|
||||
"credits_description": "As ações mutantes do MCP consomem créditos. As ações de leitura são gratuitas.",
|
||||
"keys_title": "Chaves ativas",
|
||||
"keys_description": "Use uma chave MCP por integração para que o acesso possa ser alternado ou revogado de forma limpa.",
|
||||
"rate_limits_title": "Limites de taxa",
|
||||
"rate_limits_description": "Solicitações de leitura/gravação/publicação permitidas por minuto.",
|
||||
"used_label": "Usado:"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
@@ -2534,7 +2617,11 @@
|
||||
"never_used": "Nunca",
|
||||
"scopes_label": "Escopos:",
|
||||
"revoke_confirm": "Revogar esta chave de automação?",
|
||||
"rotate_confirm": "Girar esta chave de automação?"
|
||||
"rotate_confirm": "Girar esta chave de automação?",
|
||||
"table_key": "Chave",
|
||||
"table_status": "Estado",
|
||||
"table_created": "Criado",
|
||||
"table_last_used": "Usado pela última vez"
|
||||
},
|
||||
"clients": {
|
||||
"claude_code": "Código Claude",
|
||||
|
||||
@@ -475,7 +475,54 @@
|
||||
"name": "Имя",
|
||||
"email": "электронная почта",
|
||||
"date_joined": "Дата присоединения",
|
||||
"upgrade": "Обновите свой план, чтобы пригласить больше студентов"
|
||||
"upgrade": "Обновите свой план, чтобы пригласить больше студентов",
|
||||
"import_users": "Импортировать пользователей",
|
||||
"search_placeholder": "Искать…",
|
||||
"status": "Статус",
|
||||
"status_active": "Активный",
|
||||
"status_pending": "Ожидается",
|
||||
"status_expired": "Срок действия истек",
|
||||
"status_revoked": "Отозван",
|
||||
"select_all": "Выбрать все",
|
||||
"selected_count": "{count} выбрано",
|
||||
"assign_courses": "Назначение курсам",
|
||||
"invite": {
|
||||
"row_actions_aria": "Открытые действия",
|
||||
"resend": "Отправить приглашение повторно",
|
||||
"revoke": "Отозвать ожидающее приглашение",
|
||||
"resend_snackbar_success": "Письмо с приглашением отправлено",
|
||||
"resend_snackbar_email_failed": "Приглашение было продлено, но письмо не удалось отправить.",
|
||||
"revoke_snackbar_success": "Ожидаемое приглашение отозвано"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Назначение курсам",
|
||||
"description": "Предоставьте выбранным пользователям доступ к курсам и, при необходимости, уведомите их по электронной почте.",
|
||||
"select_courses": "Выберите курсы",
|
||||
"select_courses_placeholder": "Выбирайте курсы...",
|
||||
"send_email": "Отправить уведомление по электронной почте",
|
||||
"submit": "Назначить",
|
||||
"cancel": "Отмена",
|
||||
"snackbar_success": "Назначены пользователи {assigned} для курсов ({alreadyEnrolled} уже зачислены, письма {emailsSent} отправлены)",
|
||||
"snackbar_no_courses": "Пожалуйста, выберите хотя бы один курс",
|
||||
"snackbar_failed": "Не удалось назначить курсы."
|
||||
},
|
||||
"import": {
|
||||
"title": "Импортировать пользователей",
|
||||
"description": "Добавьте студентов в свою организацию, введя их адреса электронной почты. При желании назначьте их на курсы и отправьте приглашения по электронной почте.",
|
||||
"emails_label": "Адреса электронной почты",
|
||||
"emails_placeholder": "Введите адреса электронной почты, разделенные запятыми, новой строкой или точкой с запятой.\nнапример [email protected], [email protected]",
|
||||
"course_access": "Доступ к курсу",
|
||||
"no_courses": "Нет задания по курсу",
|
||||
"all_courses": "Все курсы",
|
||||
"select_courses": "Выберите конкретные курсы",
|
||||
"select_courses_placeholder": "Выбирайте курсы...",
|
||||
"send_email": "Отправить электронное письмо с приглашением импортированным пользователям",
|
||||
"submit": "Импортировать пользователей",
|
||||
"back": "Вернуться к аудитории",
|
||||
"snackbar_success": "Импортированные пользователи: {imported} ({skiped} уже существует, писем отправлено: {emailsSent})",
|
||||
"snackbar_no_emails": "Пожалуйста, введите хотя бы один адрес электронной почты",
|
||||
"snackbar_failed": "Не удалось импортировать пользователей."
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
"title": "Сообщество",
|
||||
@@ -940,6 +987,8 @@
|
||||
"complete": "Завершить",
|
||||
"next": "Далее",
|
||||
"prev": "Предыдущий",
|
||||
"prev_shortcut": "Назад (←)",
|
||||
"next_shortcut": "Далее (→)",
|
||||
"disabled": "Функция отключена",
|
||||
"version_history": {
|
||||
"title": "История версий",
|
||||
@@ -1200,6 +1249,9 @@
|
||||
},
|
||||
"edit": {
|
||||
"accepted_answers_placeholder": "Принятые ответы (через запятую)"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Отправлено:"
|
||||
}
|
||||
},
|
||||
"numeric": {
|
||||
@@ -1217,6 +1269,11 @@
|
||||
"tolerance_info": "Допустимое отклонение от правильного значения (например, 0,5 принимает +/-0,5).",
|
||||
"correct_value_placeholder": "Правильное значение",
|
||||
"tolerance_placeholder": "Толерантность"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Отправлено:",
|
||||
"in_range": "В пределах допуска",
|
||||
"out_of_range": "Внешняя толерантность"
|
||||
}
|
||||
},
|
||||
"short_answer": {
|
||||
@@ -1228,6 +1285,10 @@
|
||||
},
|
||||
"edit": {
|
||||
"instructions_placeholder": "Дополнительные инструкции"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Отправлено:",
|
||||
"accepted_label": "Принятые ответы"
|
||||
}
|
||||
},
|
||||
"true_false": {
|
||||
@@ -1291,8 +1352,9 @@
|
||||
"option_prefix": "Вариант",
|
||||
"remove": "Удалить",
|
||||
"add_option": "Добавить вариант",
|
||||
"correct_badge": "(Правильно)",
|
||||
"not_set": "Не установлено"
|
||||
"correct_badge": "Правильно",
|
||||
"not_set": "Не установлено",
|
||||
"incorrect_badge": "Неправильный"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1473,7 +1535,10 @@
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"content_locked_title": "Этот контент заблокирован",
|
||||
"content_locked_description": "Ваш преподаватель еще не разблокировал этот контент. Завершите предыдущий контент или зайдите позже."
|
||||
"content_locked_description": "Ваш преподаватель еще не разблокировал этот контент. Завершите предыдущий контент или зайдите позже.",
|
||||
"saving": "Сохранение…",
|
||||
"saved": "Сохранено",
|
||||
"draft_recovery": "Для этого урока найден несохраненный черновик. Хотели бы вы восстановить его?"
|
||||
},
|
||||
"news_feed": {
|
||||
"heading": "Лента новостей",
|
||||
@@ -1582,6 +1647,7 @@
|
||||
"unpublished": "Неопубликовано",
|
||||
"continue_course": "Продолжить",
|
||||
"learn_more": "Узнать больше",
|
||||
"tags_overflow_aria": "Ещё {count} тегов",
|
||||
"list_view": {
|
||||
"title": "Название",
|
||||
"description": "Описание",
|
||||
@@ -2245,7 +2311,8 @@
|
||||
"email_password_disabled_message": "Для этой организации отключен вход по электронной почте и паролю.",
|
||||
"google_auth_disabled": "Аутентификация Google отключена.",
|
||||
"signup_disabled_error": "Регистрация для этой организации отключена. Пожалуйста, свяжитесь с вашим администратором.",
|
||||
"or_continue_with": "Или продолжите, используя адрес электронной почты и пароль."
|
||||
"or_continue_with": "Или продолжите, используя адрес электронной почты и пароль.",
|
||||
"sso_enabled": "{provider} включен для этой организации"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "Общий",
|
||||
@@ -2471,7 +2538,8 @@
|
||||
"edit_group": "Редактировать группу",
|
||||
"edit_tag": "Изменить тег",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена"
|
||||
"cancel": "Отмена",
|
||||
"actions_menu": "Действия с тегами"
|
||||
},
|
||||
"common": {
|
||||
"not_available": "Н/Д"
|
||||
@@ -2480,9 +2548,13 @@
|
||||
"invite": {
|
||||
"organization": {
|
||||
"page_title": "Присоединяйтесь к {orgName} в ClassroomIO.",
|
||||
"role_label": "Роль: {роль}",
|
||||
"email_label": "Приглашенный адрес электронной почты: {email}",
|
||||
"join_button": "Присоединиться к организации",
|
||||
"invitation_student": "Вас пригласили в {orgName} в качестве ученика.",
|
||||
"invitation_teacher": "Вас пригласили в {orgName} в качестве преподавателя.",
|
||||
"invitation_admin": "Вас пригласили в {orgName} в качестве администратора.",
|
||||
"invitation_fallback": "Вас пригласили в {orgName} в качестве {role}.",
|
||||
"email_line": "Электронная почта: {email}",
|
||||
"accept_button": "Принять приглашение",
|
||||
"go_to_dashboard": "Перейти в панель управления",
|
||||
"messages": {
|
||||
"email_mismatch": "Вы вошли в систему с другим адресом электронной почты, указанным в этом приглашении.",
|
||||
"expired": "Срок действия этой ссылки для приглашения истек.",
|
||||
@@ -2520,6 +2592,17 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Create key"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"title": "Использование и ограничения",
|
||||
"description": "MCP доступен в каждом плане, но использование контролируется с помощью ежемесячных кредитов на автоматизацию и ключевых лимитов.",
|
||||
"credits_title": "Оставшиеся кредиты",
|
||||
"credits_description": "Мутирующие действия MCP потребляют кредиты. Действия чтения бесплатны.",
|
||||
"keys_title": "Активные ключи",
|
||||
"keys_description": "Используйте один ключ MCP для каждой интеграции, чтобы можно было без проблем менять или отзывать доступ.",
|
||||
"rate_limits_title": "Ограничения ставок",
|
||||
"rate_limits_description": "Запросы на чтение/запись/публикацию разрешены в минуту.",
|
||||
"used_label": "Использовано:"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
@@ -2534,7 +2617,11 @@
|
||||
"never_used": "Никогда",
|
||||
"scopes_label": "Области применения:",
|
||||
"revoke_confirm": "Отозвать этот ключ автоматизации?",
|
||||
"rotate_confirm": "Повернуть этот ключ автоматизации?"
|
||||
"rotate_confirm": "Повернуть этот ключ автоматизации?",
|
||||
"table_key": "Ключ",
|
||||
"table_status": "Статус",
|
||||
"table_created": "Создано",
|
||||
"table_last_used": "Последнее использование"
|
||||
},
|
||||
"clients": {
|
||||
"claude_code": "Клод Код",
|
||||
|
||||
@@ -475,7 +475,54 @@
|
||||
"name": "Tên",
|
||||
"email": "Email",
|
||||
"date_joined": "Ngày tham gia",
|
||||
"upgrade": "Nâng cấp kế hoạch của bạn để mời thêm sinh viên"
|
||||
"upgrade": "Nâng cấp kế hoạch của bạn để mời thêm sinh viên",
|
||||
"import_users": "Nhập người dùng",
|
||||
"search_placeholder": "Tìm kiếm…",
|
||||
"status": "Trạng thái",
|
||||
"status_active": "Đang hoạt động",
|
||||
"status_pending": "Đang chờ xử lý",
|
||||
"status_expired": "Đã hết hạn",
|
||||
"status_revoked": "Đã thu hồi",
|
||||
"select_all": "Chọn tất cả",
|
||||
"selected_count": "đã chọn {count}",
|
||||
"assign_courses": "Chỉ định cho các khóa học",
|
||||
"invite": {
|
||||
"row_actions_aria": "Mở hành động",
|
||||
"resend": "Gửi lại lời mời",
|
||||
"revoke": "Thu hồi lời mời đang chờ xử lý",
|
||||
"resend_snackbar_success": "Đã gửi email mời",
|
||||
"resend_snackbar_email_failed": "Lời mời đã được gia hạn nhưng không thể gửi email",
|
||||
"revoke_snackbar_success": "Đã thu hồi lời mời đang chờ xử lý"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Chỉ định cho các khóa học",
|
||||
"description": "Cấp cho người dùng đã chọn quyền truy cập vào các khóa học và tùy ý thông báo cho họ qua email.",
|
||||
"select_courses": "Chọn khóa học",
|
||||
"select_courses_placeholder": "Chọn khóa học...",
|
||||
"send_email": "Gửi email thông báo",
|
||||
"submit": "Chỉ định",
|
||||
"cancel": "Hủy bỏ",
|
||||
"snackbar_success": "Đã chỉ định người dùng {được chỉ định} cho các khóa học ({alreadyEnrolled} đã đăng ký, đã gửi {emailsSent} email)",
|
||||
"snackbar_no_courses": "Vui lòng chọn ít nhất một khóa học",
|
||||
"snackbar_failed": "Không thể chỉ định khóa học"
|
||||
},
|
||||
"import": {
|
||||
"title": "Nhập người dùng",
|
||||
"description": "Thêm sinh viên vào tổ chức của bạn bằng cách nhập địa chỉ email của họ. Tùy chọn chỉ định chúng cho các khóa học và gửi email mời.",
|
||||
"emails_label": "Địa chỉ email",
|
||||
"emails_placeholder": "Nhập các email được phân tách bằng dấu phẩy, dòng mới hoặc dấu chấm phẩy\nví dụ: [email protected], [email protected]",
|
||||
"course_access": "Truy cập khóa học",
|
||||
"no_courses": "Không có bài tập khóa học",
|
||||
"all_courses": "Tất cả các khóa học",
|
||||
"select_courses": "Chọn các khóa học cụ thể",
|
||||
"select_courses_placeholder": "Chọn khóa học...",
|
||||
"send_email": "Gửi email lời mời tới người dùng đã nhập",
|
||||
"submit": "Nhập người dùng",
|
||||
"back": "Quay lại Đối tượng",
|
||||
"snackbar_success": "Đã nhập {imported} người dùng ({skipped} đã tồn tại, đã gửi email {emailsSent})",
|
||||
"snackbar_no_emails": "Vui lòng nhập ít nhất một địa chỉ email",
|
||||
"snackbar_failed": "Không thể nhập người dùng"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
"title": "cộng đồng",
|
||||
@@ -940,6 +987,8 @@
|
||||
"complete": "Hoàn thành",
|
||||
"next": "Tiếp theo",
|
||||
"prev": "Trước đó",
|
||||
"prev_shortcut": "Trước (←)",
|
||||
"next_shortcut": "Tiếp (→)",
|
||||
"disabled": "Tính năng bị vô hiệu hóa",
|
||||
"version_history": {
|
||||
"title": "Lịch sử phiên bản",
|
||||
@@ -1200,6 +1249,9 @@
|
||||
},
|
||||
"edit": {
|
||||
"accepted_answers_placeholder": "Câu trả lời được chấp nhận (được phân tách bằng dấu phẩy)"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Đã gửi:"
|
||||
}
|
||||
},
|
||||
"numeric": {
|
||||
@@ -1217,6 +1269,11 @@
|
||||
"tolerance_info": "Chênh lệch được phép so với giá trị chính xác (ví dụ: 0,5 chấp nhận +/- 0,5).",
|
||||
"correct_value_placeholder": "Giá trị đúng",
|
||||
"tolerance_placeholder": "Dung sai"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Đã gửi:",
|
||||
"in_range": "Trong phạm vi dung sai",
|
||||
"out_of_range": "Dung sai bên ngoài"
|
||||
}
|
||||
},
|
||||
"short_answer": {
|
||||
@@ -1228,6 +1285,10 @@
|
||||
},
|
||||
"edit": {
|
||||
"instructions_placeholder": "Hướng dẫn tùy chọn"
|
||||
},
|
||||
"review": {
|
||||
"your_answer_label": "Đã gửi:",
|
||||
"accepted_label": "Câu trả lời được chấp nhận"
|
||||
}
|
||||
},
|
||||
"true_false": {
|
||||
@@ -1291,8 +1352,9 @@
|
||||
"option_prefix": "Tùy chọn",
|
||||
"remove": "Xóa",
|
||||
"add_option": "Thêm tùy chọn",
|
||||
"correct_badge": "(Đúng)",
|
||||
"not_set": "Chưa đặt"
|
||||
"correct_badge": "Đúng",
|
||||
"not_set": "Chưa đặt",
|
||||
"incorrect_badge": "sai"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1473,7 +1535,10 @@
|
||||
"cancel": "Hủy bỏ"
|
||||
},
|
||||
"content_locked_title": "Nội dung này đã bị khóa",
|
||||
"content_locked_description": "Người hướng dẫn của bạn chưa mở khóa nội dung này. Hoàn thành nội dung trước đó hoặc kiểm tra lại sau."
|
||||
"content_locked_description": "Người hướng dẫn của bạn chưa mở khóa nội dung này. Hoàn thành nội dung trước đó hoặc kiểm tra lại sau.",
|
||||
"saving": "Đang lưu…",
|
||||
"saved": "Đã lưu",
|
||||
"draft_recovery": "Đã tìm thấy một bản nháp chưa được lưu cho bài học này. Bạn có muốn khôi phục nó không?"
|
||||
},
|
||||
"news_feed": {
|
||||
"heading": "Nguồn cấp tin tức",
|
||||
@@ -1582,6 +1647,7 @@
|
||||
"unpublished": "Chưa được xuất bản",
|
||||
"continue_course": "Tiếp tục",
|
||||
"learn_more": "Tìm hiểu thêm",
|
||||
"tags_overflow_aria": "Thêm {count} thẻ",
|
||||
"list_view": {
|
||||
"title": "Tiêu đề",
|
||||
"description": "Mô tả",
|
||||
@@ -2245,7 +2311,8 @@
|
||||
"email_password_disabled_message": "Đăng nhập email/mật khẩu bị vô hiệu hóa đối với tổ chức này.",
|
||||
"google_auth_disabled": "Xác thực Google bị vô hiệu hóa.",
|
||||
"signup_disabled_error": "Đăng ký bị vô hiệu hóa cho tổ chức này. Vui lòng liên hệ với quản trị viên của bạn.",
|
||||
"or_continue_with": "Hoặc tiếp tục với email và mật khẩu"
|
||||
"or_continue_with": "Hoặc tiếp tục với email và mật khẩu",
|
||||
"sso_enabled": "{provider} đã được bật cho tổ chức này"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "chung",
|
||||
@@ -2471,7 +2538,8 @@
|
||||
"edit_group": "Chỉnh sửa nhóm",
|
||||
"edit_tag": "Chỉnh sửa thẻ",
|
||||
"save": "Lưu",
|
||||
"cancel": "Hủy bỏ"
|
||||
"cancel": "Hủy bỏ",
|
||||
"actions_menu": "Hành động gắn thẻ"
|
||||
},
|
||||
"common": {
|
||||
"not_available": "không áp dụng"
|
||||
@@ -2480,9 +2548,13 @@
|
||||
"invite": {
|
||||
"organization": {
|
||||
"page_title": "Tham gia cùng {orgName} trên ClassroomIO",
|
||||
"role_label": "Vai trò: {vai trò}",
|
||||
"email_label": "Email được mời: {email}",
|
||||
"join_button": "Tham gia tổ chức",
|
||||
"invitation_student": "Bạn được mời vào {orgName} với vai trò học viên.",
|
||||
"invitation_teacher": "Bạn được mời vào {orgName} với vai trò giáo viên.",
|
||||
"invitation_admin": "Bạn được mời vào {orgName} với vai trò quản trị viên.",
|
||||
"invitation_fallback": "Bạn được mời vào {orgName} với vai trò {role}.",
|
||||
"email_line": "Email: {email}",
|
||||
"accept_button": "Chấp nhận lời mời",
|
||||
"go_to_dashboard": "Đi tới bảng điều khiển",
|
||||
"messages": {
|
||||
"email_mismatch": "Bạn đã đăng nhập bằng một email khác với lời mời này.",
|
||||
"expired": "Liên kết mời này đã hết hạn.",
|
||||
@@ -2520,6 +2592,17 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Create key"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"title": "Cách sử dụng và giới hạn",
|
||||
"description": "MCP có sẵn trên mọi gói, nhưng việc sử dụng được kiểm soát bằng tín dụng tự động hóa hàng tháng và các giới hạn chính.",
|
||||
"credits_title": "Tín dụng còn lại",
|
||||
"credits_description": "Các hành động MCP đột biến sẽ tiêu tốn tín dụng. Hành động đọc là miễn phí.",
|
||||
"keys_title": "Phím hoạt động",
|
||||
"keys_description": "Sử dụng một khóa MCP cho mỗi lần tích hợp để có thể luân chuyển hoặc thu hồi quyền truy cập một cách dễ dàng.",
|
||||
"rate_limits_title": "Giới hạn tỷ lệ",
|
||||
"rate_limits_description": "Yêu cầu đọc/ghi/xuất bản được phép mỗi phút.",
|
||||
"used_label": "Đã sử dụng:"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
@@ -2534,7 +2617,11 @@
|
||||
"never_used": "không bao giờ",
|
||||
"scopes_label": "Phạm vi:",
|
||||
"revoke_confirm": "Thu hồi khóa tự động hóa này?",
|
||||
"rotate_confirm": "Xoay phím tự động hóa này?"
|
||||
"rotate_confirm": "Xoay phím tự động hóa này?",
|
||||
"table_key": "Chìa khóa",
|
||||
"table_status": "Trạng thái",
|
||||
"table_created": "Đã tạo",
|
||||
"table_last_used": "Lần sử dụng cuối cùng"
|
||||
},
|
||||
"clients": {
|
||||
"claude_code": "Mã Claude",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
// Invite context: allow signup when invite_token present or redirect contains invite info
|
||||
const hasInviteContext = $derived(
|
||||
!!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token')))
|
||||
!!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/') || redirectUrl.includes('invite_token')))
|
||||
);
|
||||
|
||||
const inviteOnly = $derived(!!org?.settings?.signup?.inviteOnly);
|
||||
|
||||
@@ -86,7 +86,14 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// No need to require login for public routes
|
||||
// Skip /login redirect below when the route is already public.
|
||||
// [NO_REDIRECT] public + path !== '/' — e.g. /login, /signup, /courses, … (not the main-app home edge case)
|
||||
// [NO_REDIRECT] public + '/' + isOrgSite — org landing page on its own domain
|
||||
// [MAY_REDIRECT_TO_LOGIN] public + '/' + !isOrgSite — main app home is public so we can show loading/errors while
|
||||
// auth resolves; if there is still no session after that, we send to /login (this effect). Logged-in users
|
||||
// return earlier (session branch above); post-login navigation from '/' is in init.svelte.ts routeUserToNextPage.
|
||||
// [MAY_REDIRECT_TO_LOGIN] not public — private route; unauthenticated users go to /login
|
||||
|
||||
if (isPublicRoute(path) && (path !== '/' || data.isOrgSite)) {
|
||||
return;
|
||||
}
|
||||
|
||||
+42
-8
@@ -11,6 +11,7 @@
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { page } from '$app/state';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -25,6 +26,9 @@
|
||||
)
|
||||
);
|
||||
const canJoinOrganization = $derived(inviteStatus === 'ACTIVE' && !isInviteEmailMismatch);
|
||||
const isAlreadyJoined = $derived(inviteStatus === 'ACCEPTED');
|
||||
|
||||
const orgSlug = $derived(data.currentOrg?.siteName ?? data.invite.organization.siteName ?? '');
|
||||
|
||||
function buildInviteAuthParams(pathname: string, email: string): string {
|
||||
const parts: string[] = [];
|
||||
@@ -35,6 +39,9 @@
|
||||
|
||||
const loginParams = $derived(buildInviteAuthParams(page.url?.pathname || '', data.invite?.invite?.email || ''));
|
||||
|
||||
const orgName = $derived(data.invite?.organization?.name ?? '');
|
||||
const inviteRoleId = $derived(data.invite?.invite?.roleId ?? 0);
|
||||
|
||||
function getBlockedInviteMessage(): string {
|
||||
if (isInviteEmailMismatch) {
|
||||
return t.get('invite.organization.messages.email_mismatch');
|
||||
@@ -96,6 +103,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleNavigateAfterJoined() {
|
||||
if (inviteRoleId === ROLE.STUDENT) {
|
||||
return goto(resolve('/lms', {}));
|
||||
}
|
||||
return goto(resolve(orgSlug ? `/org/${orgSlug}` : '/org', {}));
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!data.currentOrg) return;
|
||||
|
||||
@@ -110,23 +124,43 @@
|
||||
|
||||
<AuthUI isLogin={false} {handleSubmit} isLoading={loading} showOnlyContent={true} showLogo={true}>
|
||||
<div class="mt-0 w-full">
|
||||
<h3 class="mt-0 mb-4 text-center text-lg font-medium dark:text-white">{data.invite.organization.name}</h3>
|
||||
<p class="text-center text-sm font-light dark:text-white">
|
||||
{$t('invite.organization.role_label', { role: data.invite.invite.roleLabel })}
|
||||
{#if inviteRoleId === ROLE.STUDENT}
|
||||
{$t('invite.organization.invitation_student', { orgName })}
|
||||
{:else if inviteRoleId === ROLE.TUTOR}
|
||||
{$t('invite.organization.invitation_teacher', { orgName })}
|
||||
{:else if inviteRoleId === ROLE.ADMIN}
|
||||
{$t('invite.organization.invitation_admin', { orgName })}
|
||||
{:else}
|
||||
{$t('invite.organization.invitation_fallback', {
|
||||
orgName,
|
||||
role: data.invite.invite.roleLabel
|
||||
})}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="mt-1 text-center text-sm font-light dark:text-white">
|
||||
{$t('invite.organization.email_label', { email: data.invite.invite.email })}
|
||||
<p class="mt-3 text-center text-sm font-light dark:text-white">
|
||||
{$t('invite.organization.email_line', { email: data.invite.invite.email })}
|
||||
</p>
|
||||
|
||||
{#if !canJoinOrganization}
|
||||
{#if !canJoinOrganization && !isAlreadyJoined}
|
||||
<p class="mt-3 text-center text-sm text-red-500">{getBlockedInviteMessage()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="my-4 flex w-full flex-col items-center justify-center gap-3">
|
||||
<Button type="submit" disabled={!canJoinOrganization || loading} {loading}>
|
||||
{$t('invite.organization.join_button')}
|
||||
</Button>
|
||||
{#if isAlreadyJoined}
|
||||
<Button type="button" onclick={handleNavigateAfterJoined}>
|
||||
{#if inviteRoleId === ROLE.STUDENT}
|
||||
{$t('navigation.goto_lms')}
|
||||
{:else}
|
||||
{$t('invite.organization.go_to_dashboard')}
|
||||
{/if}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button type="submit" disabled={!canJoinOrganization || loading} {loading}>
|
||||
{$t('invite.organization.accept_button')}
|
||||
</Button>
|
||||
{/if}
|
||||
<p class="ui:text-muted-foreground text-center text-sm">
|
||||
{$t('login.already_have_account')}
|
||||
<a class="ui:text-primary hover:underline" href={resolve(`/login?${loginParams}`, {})}> {$t('login.login')}</a>
|
||||
@@ -1,11 +1,15 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { CommunityQuestionPage } from '$features/community/pages';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const communityPath = $derived(page.url.pathname.replace(/\/[^/]+$/, ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Question - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<CommunityQuestionPage slug={data.slug} />
|
||||
<CommunityQuestionPage slug={data.slug} backHref={resolve(communityPath, {})} />
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { AskCommunityPage } from '$features/community/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const communityPath = $derived(page.url.pathname.replace(/\/ask$/, ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -11,6 +16,7 @@
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<BackButton href={resolve(communityPath, {})} label={$t('community.ask.go_back')} />
|
||||
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
import { CreateCourseButton } from '$features/course/components';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { WelcomeModal } from '$features/onboarding/components';
|
||||
@@ -148,11 +147,12 @@
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar.Root class="h-6 w-6">
|
||||
<Avatar.Image
|
||||
src={enrollment.avatarUrl ? enrollment.avatarUrl : '/logo-192.png'}
|
||||
alt={enrollment.name ? enrollment.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(enrollment.name) || 'U'}</Avatar.Fallback>
|
||||
{#if enrollment.avatarUrl}
|
||||
<Avatar.Image src={enrollment.avatarUrl} alt={enrollment.name ? enrollment.name : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<div class="min-h-[45px] space-y-1">
|
||||
|
||||
@@ -5,17 +5,26 @@ export const load = async ({ parent, cookies }) => {
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
audience: []
|
||||
audience: [],
|
||||
courses: []
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch organization audience
|
||||
const audienceResponse = await classroomio.organization.audience.$get({}, getApiHeaders(cookies, orgId));
|
||||
const headers = getApiHeaders(cookies, orgId);
|
||||
|
||||
const [audienceResponse, coursesResponse] = await Promise.all([
|
||||
classroomio.organization.audience.$get({}, headers),
|
||||
classroomio.organization.courses.$get({ query: { tags: undefined } }, headers)
|
||||
]);
|
||||
|
||||
const audienceData = await audienceResponse.json();
|
||||
const audience = audienceData.success ? audienceData.data : [];
|
||||
|
||||
const coursesData = await coursesResponse.json();
|
||||
const courses = coursesData.success ? coursesData.data : [];
|
||||
|
||||
return {
|
||||
audience
|
||||
audience,
|
||||
courses
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import { currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
|
||||
import { PLAN } from '@cio/utils/plans';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { data } = $props();
|
||||
let isLoading = $state(false);
|
||||
@@ -35,14 +37,17 @@
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button onclick={exportAudience} disabled={isLoading} loading={isLoading}>
|
||||
<Button variant="outline" onclick={exportAudience} disabled={isLoading} loading={isLoading}>
|
||||
{$t('audience.export')}
|
||||
</Button>
|
||||
<Button variant="secondary" href={resolve(`${page.url.pathname}/import`, {})}>
|
||||
{$t('audience.import_users')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AudiencePage audience={data.audience} />
|
||||
<AudiencePage audience={data.audience} courses={data.courses} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
|
||||
export const load = async ({ parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
courses: []
|
||||
};
|
||||
}
|
||||
|
||||
const coursesResponse = await classroomio.organization.courses.$get({}, getApiHeaders(cookies, orgId));
|
||||
const coursesData = await coursesResponse.json();
|
||||
const courses = coursesData.success ? coursesData.data : [];
|
||||
|
||||
return {
|
||||
courses
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { ImportAudiencePage } from '$features/audience/pages';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const audiencePath = $derived(page.url.pathname.replace(/\/import$/, ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('audience.import.title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="mx-auto w-full max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<BackButton href={resolve(audiencePath, {})} label={$t('audience.import.back')} class="p-0!" />
|
||||
<Page.Title>{$t('audience.import.title')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ImportAudiencePage courses={data.courses} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -1,11 +1,15 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { CommunityQuestionPage } from '$features/community/pages';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const communityPath = $derived(page.url.pathname.replace(/\/[^/]+$/, ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Question - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<CommunityQuestionPage slug={data.slug} question={data.question} />
|
||||
<CommunityQuestionPage slug={data.slug} question={data.question} backHref={resolve(communityPath, {})} />
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { AskCommunityPage } from '$features/community/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const communityPath = $derived(page.url.pathname.replace(/\/ask$/, ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -11,6 +16,7 @@
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<BackButton href={resolve(communityPath, {})} label={$t('community.ask.go_back')} />
|
||||
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
@@ -2,24 +2,33 @@
|
||||
import { CoursesPage } from '$features/course/pages';
|
||||
import { CreateCourseButton, CourseFilterPopover } from '$features/course/components';
|
||||
import { courseMetaDeta } from '$features/course/utils/store';
|
||||
import { COURSE_SORT_OPTIONS } from '$features/course/utils/constants';
|
||||
import {
|
||||
CourseSortBy,
|
||||
CourseSortOrder,
|
||||
DEFAULT_COURSE_SORT,
|
||||
DEFAULT_SORT_ORDER,
|
||||
parseCourseSortOrder,
|
||||
parseCourseSortValue
|
||||
} from '$features/course/utils/constants';
|
||||
import { browser } from '$app/environment';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { onMount } from 'svelte';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
|
||||
let { data } = $props();
|
||||
const getInitialSelectedTags = () => data.activeTags ?? [];
|
||||
|
||||
let searchValue = $state('');
|
||||
let selectedId: string = $state('0');
|
||||
let selectedOrder = $state<'asc' | 'desc'>('desc');
|
||||
let selectedTags = $state<string[]>([]);
|
||||
let hasInitializedFilters = $state(false);
|
||||
let isFiltering = $state(false);
|
||||
let sortKey: CourseSortBy = $state(DEFAULT_COURSE_SORT);
|
||||
let selectedOrder = $state<CourseSortOrder>(DEFAULT_SORT_ORDER);
|
||||
|
||||
const validSortValues = new Set(COURSE_SORT_OPTIONS.map((option) => option.value));
|
||||
const validSortOrders = new Set(['asc', 'desc']);
|
||||
let selectedTags = $state<string[]>(getInitialSelectedTags());
|
||||
|
||||
let hasInitializedFilters = $state(false);
|
||||
let hasCompletedInitialUrlSync = false;
|
||||
let isFiltering = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (data.courses) {
|
||||
@@ -27,39 +36,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
selectedTags = data.activeTags ?? [];
|
||||
});
|
||||
|
||||
function normalizeSortValue(sortValue: string | null): string {
|
||||
if (sortValue && validSortValues.has(sortValue)) {
|
||||
return sortValue;
|
||||
}
|
||||
|
||||
return '0';
|
||||
}
|
||||
|
||||
function normalizeSortOrder(sortOrder: string | null): 'asc' | 'desc' {
|
||||
if (sortOrder && validSortOrders.has(sortOrder)) {
|
||||
return sortOrder as 'asc' | 'desc';
|
||||
}
|
||||
|
||||
return 'desc';
|
||||
}
|
||||
|
||||
function getLegacySortState(sortValue: string | null) {
|
||||
switch (sortValue) {
|
||||
case '3':
|
||||
return { selectedId: '0', selectedOrder: 'asc' as const };
|
||||
case '4':
|
||||
return { selectedId: '1', selectedOrder: 'asc' as const };
|
||||
case '5':
|
||||
return { selectedId: '2', selectedOrder: 'asc' as const };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateFiltersUrl() {
|
||||
if (!browser || !hasInitializedFilters) {
|
||||
return;
|
||||
@@ -73,13 +49,13 @@
|
||||
nextUrl.searchParams.delete('tags');
|
||||
}
|
||||
|
||||
if (selectedId !== '0') {
|
||||
nextUrl.searchParams.set('sort', selectedId);
|
||||
if (sortKey !== DEFAULT_COURSE_SORT) {
|
||||
nextUrl.searchParams.set('sort', sortKey);
|
||||
} else {
|
||||
nextUrl.searchParams.delete('sort');
|
||||
}
|
||||
|
||||
if (selectedOrder !== 'desc') {
|
||||
if (selectedOrder !== DEFAULT_SORT_ORDER) {
|
||||
nextUrl.searchParams.set('order', selectedOrder);
|
||||
} else {
|
||||
nextUrl.searchParams.delete('order');
|
||||
@@ -94,8 +70,14 @@
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('classroomio_filter_course_sort_key', selectedId);
|
||||
localStorage.setItem('classroomio_filter_course_sort_key', sortKey);
|
||||
localStorage.setItem('classroomio_filter_course_order_key', selectedOrder);
|
||||
|
||||
if (!hasCompletedInitialUrlSync) {
|
||||
hasCompletedInitialUrlSync = true;
|
||||
return;
|
||||
}
|
||||
|
||||
updateFiltersUrl();
|
||||
});
|
||||
|
||||
@@ -111,7 +93,7 @@
|
||||
}
|
||||
|
||||
function toggleTag(tagSlug: string, checked: boolean) {
|
||||
const next = new Set(selectedTags);
|
||||
const next = new SvelteSet(selectedTags);
|
||||
|
||||
if (checked) {
|
||||
next.add(tagSlug);
|
||||
@@ -123,8 +105,8 @@
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
selectedId = '0';
|
||||
selectedOrder = 'desc';
|
||||
sortKey = DEFAULT_COURSE_SORT;
|
||||
selectedOrder = DEFAULT_SORT_ORDER;
|
||||
|
||||
if (selectedTags.length === 0) {
|
||||
return;
|
||||
@@ -144,21 +126,21 @@
|
||||
|
||||
const sortedCourses = [...filteredCourses];
|
||||
|
||||
if (selectedId === '0') {
|
||||
if (sortKey === CourseSortBy.DateCreated) {
|
||||
return sortedCourses.sort((a, b) =>
|
||||
selectedOrder === 'asc'
|
||||
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
: new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
selectedOrder === CourseSortOrder.Asc
|
||||
? new Date(a.createdAt ?? '').getTime() - new Date(b.createdAt ?? '').getTime()
|
||||
: new Date(b.createdAt ?? '').getTime() - new Date(a.createdAt ?? '').getTime()
|
||||
);
|
||||
} else if (selectedId === '1') {
|
||||
} else if (sortKey === CourseSortBy.Published) {
|
||||
return sortedCourses.sort((a, b) =>
|
||||
selectedOrder === 'asc'
|
||||
selectedOrder === CourseSortOrder.Asc
|
||||
? Number(a.isPublished) - Number(b.isPublished)
|
||||
: Number(b.isPublished) - Number(a.isPublished)
|
||||
);
|
||||
} else if (selectedId === '2') {
|
||||
} else if (sortKey === CourseSortBy.Lessons) {
|
||||
return sortedCourses.sort((a, b) =>
|
||||
selectedOrder === 'asc' ? a.lessonCount - b.lessonCount : b.lessonCount - a.lessonCount
|
||||
selectedOrder === CourseSortOrder.Asc ? a.lessonCount - b.lessonCount : b.lessonCount - a.lessonCount
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,34 +154,22 @@
|
||||
$courseMetaDeta.view = courseView;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const legacySortFromUrl = getLegacySortState(searchParams.get('sort'));
|
||||
const legacySortFromStorage = getLegacySortState(localStorage.getItem('classroomio_filter_course_key'));
|
||||
const oldSortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_key'));
|
||||
const sortFromUrl = normalizeSortValue(searchParams.get('sort'));
|
||||
const sortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_sort_key'));
|
||||
const orderFromUrl = normalizeSortOrder(searchParams.get('order'));
|
||||
const orderFromStorage = normalizeSortOrder(localStorage.getItem('classroomio_filter_course_order_key'));
|
||||
const sortFromStorage = parseCourseSortValue(localStorage.getItem('classroomio_filter_course_sort_key'));
|
||||
const orderFromStorage = parseCourseSortOrder(localStorage.getItem('classroomio_filter_course_order_key'));
|
||||
|
||||
if (legacySortFromUrl) {
|
||||
selectedId = legacySortFromUrl.selectedId;
|
||||
selectedOrder = legacySortFromUrl.selectedOrder;
|
||||
} else if (sortFromUrl !== '0' || orderFromUrl !== 'desc') {
|
||||
selectedId = sortFromUrl;
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const sortFromUrl = parseCourseSortValue(searchParams.get('sort'));
|
||||
const orderFromUrl = parseCourseSortOrder(searchParams.get('order'));
|
||||
|
||||
if (sortFromUrl !== DEFAULT_COURSE_SORT || orderFromUrl !== DEFAULT_SORT_ORDER) {
|
||||
sortKey = sortFromUrl;
|
||||
selectedOrder = orderFromUrl;
|
||||
} else if (legacySortFromStorage) {
|
||||
selectedId = legacySortFromStorage.selectedId;
|
||||
selectedOrder = legacySortFromStorage.selectedOrder;
|
||||
} else if (sortFromStorage !== '0' || orderFromStorage !== 'desc') {
|
||||
selectedId = sortFromStorage;
|
||||
} else if (sortFromStorage !== DEFAULT_COURSE_SORT || orderFromStorage !== DEFAULT_SORT_ORDER) {
|
||||
sortKey = sortFromStorage;
|
||||
selectedOrder = orderFromStorage;
|
||||
} else {
|
||||
selectedId = oldSortFromStorage;
|
||||
selectedOrder = 'desc';
|
||||
}
|
||||
|
||||
hasInitializedFilters = true;
|
||||
updateFiltersUrl();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -218,10 +188,10 @@
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CoursesPage courses={filteredCourses} bind:searchValue bind:selectedId showSortSelect={false}>
|
||||
<CoursesPage courses={filteredCourses} bind:searchValue bind:sortKey showSortSelect={false}>
|
||||
{#snippet filterControls()}
|
||||
<CourseFilterPopover
|
||||
bind:selectedId
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
{selectedTags}
|
||||
tagGroups={data.tagGroups}
|
||||
|
||||
@@ -66,12 +66,13 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id });
|
||||
await createProfileHook(user);
|
||||
}
|
||||
},
|
||||
update: {
|
||||
after: async (user) => {
|
||||
console.log('update user', user);
|
||||
console.log('[auth] databaseHooks.user.update.after: running', { userId: user.id });
|
||||
await syncUserWithProfile(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ async function isSelfHostedFirstSignup(): Promise<boolean> {
|
||||
* Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org).
|
||||
*/
|
||||
export const createProfileHook = async (user: User) => {
|
||||
console.debug('createProfileHook', user);
|
||||
console.log('[auth] createProfileHook: running', { userId: user.id });
|
||||
|
||||
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
|
||||
console.debug('existingProfile', existingProfile);
|
||||
|
||||
@@ -16,6 +16,7 @@ const afterHooks: Array<(context: AuthMiddlewareContext) => Promise<unknown>> =
|
||||
* This function will be called by better-auth after each request
|
||||
*/
|
||||
export const combineAfterHooks = async (context: AuthMiddlewareContext) => {
|
||||
console.log('[auth] combineAfterHooks: running');
|
||||
for (const hook of afterHooks) {
|
||||
try {
|
||||
await hook(context);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { User } from 'better-auth';
|
||||
import { db } from '@db/drizzle';
|
||||
import { enrollUsersInCourseGroups } from '@db/queries/group';
|
||||
import { getOrgCourseGroups } from '@db/queries/course';
|
||||
import { markUserAndProfileEmailVerified } from '@db/queries/auth';
|
||||
|
||||
const DEFAULT_STUDENT_ROLE_ID = 3;
|
||||
@@ -21,13 +23,25 @@ export async function ensureOrgMembership(
|
||||
): Promise<void> {
|
||||
const emailLower = email.toLowerCase();
|
||||
|
||||
const [existing] = await db
|
||||
const [existingByProfile] = await db
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.profileId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
if (existingByProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** Audience import pre-creates a row with org + email before profile exists; do not insert again. */
|
||||
const [existingByEmail] = await db
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.email, emailLower)))
|
||||
.limit(1);
|
||||
|
||||
if (existingByEmail?.profileId && existingByEmail.profileId !== userId) {
|
||||
console.error('ensureOrgMembership: org member email already linked to another profile');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,20 +52,33 @@ export async function ensureOrgMembership(
|
||||
and(
|
||||
eq(schema.organizationInvite.organizationId, orgId),
|
||||
eq(schema.organizationInvite.email, emailLower),
|
||||
eq(schema.organizationInvite.isRevoked, false)
|
||||
eq(schema.organizationInvite.isRevoked, false),
|
||||
isNull(schema.organizationInvite.acceptedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (invite) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: orgId,
|
||||
profileId: userId,
|
||||
email: emailLower,
|
||||
roleId: invite.roleId,
|
||||
verified: true
|
||||
});
|
||||
if (existingByEmail) {
|
||||
await tx
|
||||
.update(schema.organizationmember)
|
||||
.set({
|
||||
profileId: userId,
|
||||
email: emailLower,
|
||||
roleId: invite.roleId,
|
||||
verified: true
|
||||
})
|
||||
.where(eq(schema.organizationmember.id, existingByEmail.id));
|
||||
} else {
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: orgId,
|
||||
profileId: userId,
|
||||
email: emailLower,
|
||||
roleId: invite.roleId,
|
||||
verified: true
|
||||
});
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(schema.organizationInvite)
|
||||
@@ -65,10 +92,46 @@ export async function ensureOrgMembership(
|
||||
await markUserAndProfileEmailVerified(userId, tx);
|
||||
});
|
||||
|
||||
const metadata = invite.metadata as Record<string, unknown> | null;
|
||||
const courseIds = Array.isArray(metadata?.courseIds) ? (metadata.courseIds as string[]) : [];
|
||||
|
||||
if (courseIds.length > 0) {
|
||||
try {
|
||||
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
|
||||
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
|
||||
await enrollUsersInCourseGroups(validGroupIds, [{ profileId: userId, email: emailLower }], invite.roleId);
|
||||
} catch (error) {
|
||||
console.error('ensureOrgMembership course enrollment error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.debug('User joined org via invite:', orgId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingByEmail) {
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(schema.organizationAuthPolicy)
|
||||
.where(eq(schema.organizationAuthPolicy.organizationId, orgId))
|
||||
.limit(1);
|
||||
|
||||
const defaultRoleId = policy?.defaultRoleId ?? DEFAULT_STUDENT_ROLE_ID;
|
||||
const finalRoleId = roleId ?? defaultRoleId;
|
||||
|
||||
await db
|
||||
.update(schema.organizationmember)
|
||||
.set({
|
||||
profileId: userId,
|
||||
verified: true,
|
||||
roleId: finalRoleId
|
||||
})
|
||||
.where(eq(schema.organizationmember.id, existingByEmail.id));
|
||||
|
||||
console.debug('User linked to existing org membership row:', orgId);
|
||||
return;
|
||||
}
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(schema.organizationAuthPolicy)
|
||||
@@ -94,6 +157,7 @@ export async function ensureOrgMembership(
|
||||
* This runs after profile creation and creates organization membership
|
||||
*/
|
||||
export const ssoProvisioningHook = async (user: User) => {
|
||||
console.log('[auth] ssoProvisioningHook: running', { userId: user.id });
|
||||
if (!user.email) {
|
||||
console.debug('No email for user, skipping SSO provisioning');
|
||||
return;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { hasVerifiedEmailProvider } from './create-profile';
|
||||
|
||||
export const syncUserWithProfile = async (user: User) => {
|
||||
console.debug('syncUserWithProfile', user);
|
||||
console.log('[auth] syncUserWithProfile: running', { userId: user?.id });
|
||||
|
||||
if (!user?.id) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
CREATE TYPE "public"."AUTOMATION_USAGE_CATEGORY" AS ENUM('read', 'write', 'publish');--> statement-breakpoint
|
||||
CREATE TYPE "public"."COURSE_IMPORT_DRAFT_STATUS" AS ENUM('DRAFT', 'PUBLISHED', 'ARCHIVED');--> statement-breakpoint
|
||||
CREATE TYPE "public"."COURSE_IMPORT_SOURCE_TYPE" AS ENUM('prompt', 'pdf', 'course');--> statement-breakpoint
|
||||
CREATE TYPE "public"."ORGANIZATION_API_KEY_TYPE" AS ENUM('mcp', 'api', 'zapier');--> statement-breakpoint
|
||||
CREATE TYPE "public"."SSO_PROVIDER" AS ENUM('OKTA', 'GOOGLE_WORKSPACE', 'AUTH0');--> statement-breakpoint
|
||||
CREATE TABLE "course_import_draft" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"created_by_profile_id" uuid NOT NULL,
|
||||
"source_type" "COURSE_IMPORT_SOURCE_TYPE" NOT NULL,
|
||||
"status" "COURSE_IMPORT_DRAFT_STATUS" DEFAULT 'DRAFT' NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"locale" "LOCALE" DEFAULT 'en' NOT NULL,
|
||||
"idempotency_key" varchar,
|
||||
"summary" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"draft" jsonb NOT NULL,
|
||||
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"sourceArtifacts" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"published_course_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_api_key" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"created_by_profile_id" uuid NOT NULL,
|
||||
"type" "ORGANIZATION_API_KEY_TYPE" NOT NULL,
|
||||
"label" varchar(120) NOT NULL,
|
||||
"secret_prefix" varchar(32) NOT NULL,
|
||||
"secret_hash" text NOT NULL,
|
||||
"scopes" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_auth_policy" (
|
||||
"organization_id" uuid PRIMARY KEY NOT NULL,
|
||||
"force_sso" boolean DEFAULT false NOT NULL,
|
||||
"auto_join_sso_domains" boolean DEFAULT false NOT NULL,
|
||||
"break_glass_enabled" boolean DEFAULT true NOT NULL,
|
||||
"default_role_id" bigint DEFAULT '3' NOT NULL,
|
||||
"role_mapping" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_automation_usage" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"organization_api_key_id" uuid,
|
||||
"type" "ORGANIZATION_API_KEY_TYPE" NOT NULL,
|
||||
"action" varchar(120) NOT NULL,
|
||||
"category" "AUTOMATION_USAGE_CATEGORY" NOT NULL,
|
||||
"credits_consumed" integer DEFAULT 0 NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_sso_config" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"better_auth_provider_id" text NOT NULL,
|
||||
"provider" "SSO_PROVIDER" NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"domain" text NOT NULL,
|
||||
"is_active" boolean DEFAULT false NOT NULL,
|
||||
"created_by_profile_id" uuid NOT NULL,
|
||||
"updated_by_profile_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
CONSTRAINT "organization_sso_config_organization_id_unique" UNIQUE("organization_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_token_auth" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"signing_secret" text NOT NULL,
|
||||
"is_active" boolean DEFAULT false NOT NULL,
|
||||
"created_by_profile_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
CONSTRAINT "organization_token_auth_organization_id_unique" UNIQUE("organization_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sso_provider" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"oidc_config" text,
|
||||
"saml_config" text,
|
||||
"user_id" uuid NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"organization_id" text,
|
||||
"domain" text NOT NULL,
|
||||
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tag" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"group_id" uuid NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"description" text,
|
||||
"color" varchar NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
CONSTRAINT "tag_org_slug_key" UNIQUE("organization_id","slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tag_assignment" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tag_id" uuid NOT NULL,
|
||||
"course_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
CONSTRAINT "tag_assignment_tag_course_key" UNIQUE("tag_id","course_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tag_group" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"description" text,
|
||||
"order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
CONSTRAINT "tag_group_org_slug_key" UNIQUE("organization_id","slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "organization" ALTER COLUMN "theme" SET DEFAULT 'blue';--> statement-breakpoint
|
||||
ALTER TABLE "option" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "organization" ADD COLUMN "disable_signup" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "organization" ADD COLUMN "disable_signup_message" text;--> statement-breakpoint
|
||||
ALTER TABLE "organization" ADD COLUMN "disable_email_password" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "organization" ADD COLUMN "disable_google_auth" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "question" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "question_answer" ADD COLUMN "answer_data" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "submission" ADD COLUMN "grading_state" varchar DEFAULT 'queued' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "submission" ADD COLUMN "overall_status" varchar DEFAULT 'manual_required' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_published_course_id_course_id_fk" FOREIGN KEY ("published_course_id") REFERENCES "public"."course"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_default_role_id_fkey" FOREIGN KEY ("default_role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_api_key_id_organization_api_key_id_fk" FOREIGN KEY ("organization_api_key_id") REFERENCES "public"."organization_api_key"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_updated_by_profile_id_profile_id_fk" FOREIGN KEY ("updated_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tag" ADD CONSTRAINT "tag_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tag" ADD CONSTRAINT "tag_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."tag_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tag_group" ADD CONSTRAINT "tag_group_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_course_import_draft_org_id" ON "course_import_draft" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_course_import_draft_status" ON "course_import_draft" USING btree ("status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "course_import_draft_org_idempotency_key" ON "course_import_draft" USING btree ("organization_id","idempotency_key") WHERE "course_import_draft"."idempotency_key" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_api_key_org_id" ON "organization_api_key" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_api_key_type" ON "organization_api_key" USING btree ("type");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "organization_api_key_secret_hash_unique" ON "organization_api_key" USING btree ("secret_hash");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_auth_policy_org_id" ON "organization_auth_policy" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_org_automation_usage_org_created_at" ON "organization_automation_usage" USING btree ("organization_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_org_automation_usage_key_created_at" ON "organization_automation_usage" USING btree ("organization_api_key_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_org_automation_usage_type_created_at" ON "organization_automation_usage" USING btree ("type","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_sso_config_org_id" ON "organization_sso_config" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_sso_config_domain" ON "organization_sso_config" USING btree ("domain");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_sso_config_provider_id" ON "organization_sso_config" USING btree ("better_auth_provider_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_token_auth_org_id" ON "organization_token_auth" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_tag_organization_id" ON "tag" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_tag_group_id" ON "tag" USING btree ("group_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_tag_assignment_tag_id" ON "tag_assignment" USING btree ("tag_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_tag_assignment_course_id" ON "tag_assignment" USING btree ("course_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_tag_group_organization_id" ON "tag_group" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "organizationmember_org_profile_unique" ON "organizationmember" USING btree ("organization_id","profile_id") WHERE "organizationmember"."profile_id" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "organizationmember_org_email_unique" ON "organizationmember" USING btree ("organization_id","email") WHERE "organizationmember"."email" IS NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1771176910627,
|
||||
"tag": "0006_modern_the_initiative",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1774154467188,
|
||||
"tag": "0007_rainy_vivisector",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as schema from '@db/schema';
|
||||
|
||||
import type { TProfile } from '@db/types';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
|
||||
export const getProfileById = async (id: string) => {
|
||||
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1);
|
||||
@@ -16,6 +16,12 @@ export const getProfileByEmail = async (email: string) => {
|
||||
return profile;
|
||||
};
|
||||
|
||||
export async function getProfilesByEmails(emails: string[]) {
|
||||
if (emails.length === 0) return [];
|
||||
|
||||
return db.select().from(schema.profile).where(inArray(schema.profile.email, emails));
|
||||
}
|
||||
|
||||
export const updateProfile = async (id: string, data: Partial<Omit<TProfile, 'id' | 'createdAt' | 'updatedAt'>>) => {
|
||||
const [updatedProfile] = await db.update(schema.profile).set(data).where(eq(schema.profile.id, id)).returning();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user