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}>
|
<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
|
### Server-Side API Calls
|
||||||
|
|
||||||
Use `.server.ts` files for server-side code to isolate API keys.
|
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 `.server.ts` for server-side API calls with API keys
|
||||||
- Use non-null assertion (`!`) for `user` when `authMiddleware` is present
|
- Use non-null assertion (`!`) for `user` when `authMiddleware` is present
|
||||||
- Put all user-facing copy in translations and reference by key
|
- 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
|
### ❌ DON'T
|
||||||
- Put business logic in routes or queries
|
- 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)
|
- Call RPC client directly in server files (use `.server.ts` classes)
|
||||||
- Add unnecessary null checks for `user` when `authMiddleware` is present
|
- Add unnecessary null checks for `user` when `authMiddleware` is present
|
||||||
- Write plain English strings in components
|
- 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
|
- **Use inline type imports** (e.g. `import('Package').Type` in type positions) — use top-level `import type` instead
|
||||||
|
|
||||||
## Checklist for New Routes
|
## Checklist for New Routes
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { getUserOrgRole } from '@cio/db/queries/organization';
|
|||||||
export const orgMemberMiddleware = async (c: Context, next: Next) => {
|
export const orgMemberMiddleware = async (c: Context, next: Next) => {
|
||||||
try {
|
try {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
console.log('orgMemberMiddleware user', user);
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
@@ -24,7 +23,6 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const orgId = c.req.header('cio-org-id');
|
const orgId = c.req.header('cio-org-id');
|
||||||
console.log('orgMemberMiddleware orgId', orgId);
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
@@ -37,7 +35,6 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roleId = await getUserOrgRole(orgId, user.id);
|
const roleId = await getUserOrgRole(orgId, user.id);
|
||||||
console.log('orgMemberMiddleware roleId', orgId);
|
|
||||||
if (roleId === null) {
|
if (roleId === null) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
ZAssignAudienceCourses,
|
||||||
|
ZAudienceInviteByEmail,
|
||||||
ZCancelOrgPlan,
|
ZCancelOrgPlan,
|
||||||
ZCreateOrgPlan,
|
ZCreateOrgPlan,
|
||||||
ZCreateOrganization,
|
ZCreateOrganization,
|
||||||
@@ -7,12 +9,20 @@ import {
|
|||||||
ZGetOrganizationCoursesQuery,
|
ZGetOrganizationCoursesQuery,
|
||||||
ZGetOrganizations,
|
ZGetOrganizations,
|
||||||
ZGetUserAnalytics,
|
ZGetUserAnalytics,
|
||||||
|
ZImportAudienceMembers,
|
||||||
ZInviteTeamMembers,
|
ZInviteTeamMembers,
|
||||||
ZLMSExercisesParam,
|
ZLMSExercisesParam,
|
||||||
ZRemoveTeamMember,
|
ZRemoveTeamMember,
|
||||||
ZUpdateOrgPlan,
|
ZUpdateOrgPlan,
|
||||||
ZUpdateOrganization
|
ZUpdateOrganization
|
||||||
} from '@cio/utils/validation/organization';
|
} 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 {
|
import {
|
||||||
cancelOrgPlan,
|
cancelOrgPlan,
|
||||||
createOrg,
|
createOrg,
|
||||||
@@ -33,13 +43,13 @@ import {
|
|||||||
} from '@api/services/organization';
|
} from '@api/services/organization';
|
||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { TOrganization } from '@db/types';
|
import { TOrganization } from '@db/types';
|
||||||
import { automationRouter } from '@api/routes/organization/automation';
|
|
||||||
import { assetsRouter } from '@api/routes/organization/assets';
|
import { assetsRouter } from '@api/routes/organization/assets';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
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 { 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 { courseImportRouter } from '@api/routes/organization/course-import';
|
||||||
import { getLMSExercisesService } from '@api/services/exercise';
|
import { getLMSExercisesService } from '@api/services/exercise';
|
||||||
import { handleError } from '@api/utils/errors';
|
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 { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||||
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
|
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 { quizRouter } from '@api/routes/organization/quiz';
|
||||||
import { tagsRouter } from '@api/routes/organization/tags';
|
import { tagsRouter } from '@api/routes/organization/tags';
|
||||||
import { ROLE } from '@cio/utils/constants';
|
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const organizationRouter = new Hono()
|
export const organizationRouter = new Hono()
|
||||||
@@ -100,7 +110,7 @@ export const organizationRouter = new Hono()
|
|||||||
* Gets organization team members (non-students)
|
* Gets organization team members (non-students)
|
||||||
* Requires authentication
|
* Requires authentication
|
||||||
*/
|
*/
|
||||||
.get('/team', authMiddleware, orgMemberMiddleware, async (c) => {
|
.get('/team', authMiddleware, orgTeamMemberMiddleware, async (c) => {
|
||||||
try {
|
try {
|
||||||
const orgId = c.req.header('cio-org-id')!;
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
const team = await getOrgTeam(orgId);
|
const team = await getOrgTeam(orgId);
|
||||||
@@ -168,7 +178,7 @@ export const organizationRouter = new Hono()
|
|||||||
* Gets organization audience (students)
|
* Gets organization audience (students)
|
||||||
* Requires authentication
|
* Requires authentication
|
||||||
*/
|
*/
|
||||||
.get('/audience', authMiddleware, orgMemberMiddleware, async (c) => {
|
.get('/audience', authMiddleware, orgTeamMemberMiddleware, async (c) => {
|
||||||
try {
|
try {
|
||||||
const orgId = c.req.header('cio-org-id')!;
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
const audience = await getOrgAudience(orgId);
|
const audience = await getOrgAudience(orgId);
|
||||||
@@ -184,6 +194,48 @@ export const organizationRouter = new Hono()
|
|||||||
return handleError(c, error, 'Failed to fetch organization audience');
|
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
|
* GET /organization/audience/:userId/analytics
|
||||||
* Gets user analytics for a specific user in an organization
|
* Gets user analytics for a specific user in an organization
|
||||||
@@ -192,7 +244,7 @@ export const organizationRouter = new Hono()
|
|||||||
.get(
|
.get(
|
||||||
'/audience/:userId/analytics',
|
'/audience/:userId/analytics',
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
orgMemberMiddleware,
|
orgTeamMemberMiddleware,
|
||||||
zValidator('param', ZGetUserAnalytics),
|
zValidator('param', ZGetUserAnalytics),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -458,7 +510,6 @@ export const organizationRouter = new Hono()
|
|||||||
*/
|
*/
|
||||||
.put('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZUpdateOrganization), async (c) => {
|
.put('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZUpdateOrganization), async (c) => {
|
||||||
try {
|
try {
|
||||||
console.log('updateOrg');
|
|
||||||
console.log(c.req.valid('json'));
|
console.log(c.req.valid('json'));
|
||||||
const orgId = c.req.header('cio-org-id')!;
|
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');
|
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('/automation', automationRouter)
|
||||||
.route('/course-import', courseImportRouter)
|
.route('/course-import', courseImportRouter)
|
||||||
.route('/tags', tagsRouter)
|
.route('/tags', tagsRouter)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
|||||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
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';
|
import { generateSlug } from '@cio/utils/functions';
|
||||||
|
|
||||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||||
@@ -948,6 +948,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existingMember) {
|
if (existingMember) {
|
||||||
|
await markUserAndProfileEmailVerified(user.id, tx);
|
||||||
|
|
||||||
await recordInviteAudit(invite.id, invite.courseId, 'ACCEPTED', {
|
await recordInviteAudit(invite.id, invite.courseId, 'ACCEPTED', {
|
||||||
actorProfileId: user.id,
|
actorProfileId: user.id,
|
||||||
targetEmail: normalizedEmail,
|
targetEmail: normalizedEmail,
|
||||||
@@ -1029,6 +1031,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
|||||||
email: normalizedEmail
|
email: normalizedEmail
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await markUserAndProfileEmailVerified(user.id, tx);
|
||||||
|
|
||||||
const [consumeInvite] = await tx
|
const [consumeInvite] = await tx
|
||||||
.update(schema.courseInvite)
|
.update(schema.courseInvite)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
|
|||||||
import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash';
|
import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash';
|
||||||
|
|
||||||
import { OrganisationAnalytics } from '@api/types';
|
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> {
|
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
|
||||||
const analytics: OrganisationAnalytics = {
|
const analytics: OrganisationAnalytics = {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
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 {
|
import {
|
||||||
cancelOrganizationPlan,
|
cancelOrganizationPlan,
|
||||||
createOrganizationPlan,
|
createOrganizationPlan,
|
||||||
deleteOrganizationMember,
|
deleteOrganizationMember,
|
||||||
getOrgIdBySiteName,
|
getOrgIdBySiteName,
|
||||||
getFirstOrganizationWithPlans,
|
getFirstOrganizationWithPlans,
|
||||||
|
getLatestOrgInvitesByEmails,
|
||||||
getOrganizationAudience,
|
getOrganizationAudience,
|
||||||
getOrganizationBySiteName,
|
getOrganizationBySiteName,
|
||||||
getOrganizationTeam,
|
getOrganizationTeam,
|
||||||
@@ -32,6 +33,8 @@ import { ROLE } from '@cio/utils/constants';
|
|||||||
import { createOrganizationWithOwner } from '@api/services/onboarding';
|
import { createOrganizationWithOwner } from '@api/services/onboarding';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
|
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
|
* Creates a new organization with the current user as owner
|
||||||
@@ -116,12 +119,26 @@ export async function getOrgTeam(orgId: string) {
|
|||||||
/**
|
/**
|
||||||
* Gets organization audience (students)
|
* Gets organization audience (students)
|
||||||
* @param orgId - The organization ID
|
* @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 {
|
try {
|
||||||
const audience = await getOrganizationAudience(orgId);
|
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) {
|
} catch (error) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
error instanceof Error ? error.message : 'Failed to fetch organization audience',
|
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,
|
getOrganizationInviteByTokenHash,
|
||||||
revokeActiveOrganizationInvitesByEmails
|
revokeActiveOrganizationInvitesByEmails
|
||||||
} from '@cio/db/queries/organization';
|
} 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 { ROLE } from '@cio/utils/constants';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { db } from '@cio/db/drizzle';
|
import { db } from '@cio/db/drizzle';
|
||||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||||
|
import { parseCourseIdsFromInviteMetadata } from '@api/utils/org';
|
||||||
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
||||||
import { sendEmail } from '@cio/email';
|
import { sendEmail } from '@cio/email';
|
||||||
|
|
||||||
@@ -46,12 +49,13 @@ function normalizeEmails(emails: string[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildInviteLink(token: string): string {
|
function buildInviteLink(token: string): string {
|
||||||
return `${getDashboardBaseUrl()}/invite/t/${encodeURIComponent(token)}`;
|
return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRoleLabel(roleId: number): string {
|
function getRoleLabel(roleId: number): string {
|
||||||
if (roleId === ROLE.ADMIN) return 'Admin';
|
if (roleId === ROLE.ADMIN) return 'Admin';
|
||||||
if (roleId === ROLE.TUTOR) return 'Tutor';
|
if (roleId === ROLE.TUTOR) return 'Tutor';
|
||||||
|
if (roleId === ROLE.STUDENT) return 'Student';
|
||||||
return `Role ${roleId}`;
|
return `Role ${roleId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +409,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
|||||||
const siteName = result.organization.siteName || '';
|
const siteName = result.organization.siteName || '';
|
||||||
|
|
||||||
const invite = await getOrganizationInviteByTokenHash(tokenHash);
|
const invite = await getOrganizationInviteByTokenHash(tokenHash);
|
||||||
|
|
||||||
if (invite) {
|
if (invite) {
|
||||||
await recordOrganizationInviteAudit(invite.invite.id, invite.invite.organizationId, 'ACCEPTED', {
|
await recordOrganizationInviteAudit(invite.invite.id, invite.invite.organizationId, 'ACCEPTED', {
|
||||||
actorProfileId: user.id,
|
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 {
|
return {
|
||||||
organizationId: result.organization.id,
|
organizationId: result.organization.id,
|
||||||
roleId: result.roleId,
|
roleId: result.roleId,
|
||||||
alreadyAccepted: result.alreadyAccepted,
|
alreadyAccepted: result.alreadyAccepted,
|
||||||
redirectTo: siteName ? `/org/${siteName}` : '/org'
|
redirectTo
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
// Re-export all types
|
// Re-export all types
|
||||||
export * from './database';
|
export * from './database';
|
||||||
export * from './dash';
|
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 {
|
.prose h1 {
|
||||||
@apply mt-0 text-4xl font-extrabold;
|
@apply mt-0 text-4xl font-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h2 {
|
.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 {
|
.prose h2:first-child {
|
||||||
@@ -71,11 +71,11 @@ html .dark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose h3 {
|
.prose h3 {
|
||||||
@apply mt-8 text-2xl font-semibold;
|
@apply mt-0 text-2xl font-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h4 {
|
.prose h4 {
|
||||||
@apply mt-8 text-xl font-semibold;
|
@apply mt-0 text-xl font-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose p,
|
.prose p,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getSessionData } from '$lib/utils/services/auth/session';
|
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 { applyCspExtensions } from '$lib/utils/csp';
|
||||||
import { type Handle, redirect } from '@sveltejs/kit';
|
import { type Handle, redirect } from '@sveltejs/kit';
|
||||||
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
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 handlePagesRoutes: Handle = async ({ event, resolve }) => {
|
||||||
const { pathname } = event.url;
|
const { pathname } = event.url;
|
||||||
const hasCioCookie = Boolean(getCioCookieString(event.cookies));
|
const hasCioCookie = getHasCioCookies(event.cookies);
|
||||||
|
|
||||||
if (isPublicRoute(pathname)) {
|
if (isPublicRoute(pathname)) {
|
||||||
return resolve(event);
|
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
|
// 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');
|
console.log('no redirect is needed');
|
||||||
return;
|
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">
|
<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 UsersIcon from '@lucide/svelte/icons/users';
|
||||||
import { orgApi } from '$features/org/api/org.svelte';
|
import { orgApi } from '$features/org/api/org.svelte';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { Empty } from '@cio/ui/custom/empty';
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
import { UpgradeBanner } from '$features/ui';
|
import { UpgradeBanner } from '$features/ui';
|
||||||
import { currentOrgMaxAudience } from '$lib/utils/store/org';
|
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 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 {
|
interface Props {
|
||||||
audience?: OrganizationAudience;
|
audience?: OrganizationAudience;
|
||||||
|
courses?: Course[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { audience }: Props = $props();
|
let { audience, courses = [] }: Props = $props();
|
||||||
|
|
||||||
// Initialize audience from prop if provided
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (audience) {
|
if (audience) {
|
||||||
orgApi.audience = audience;
|
orgApi.audience = audience;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const headers = [
|
const headers = $derived([
|
||||||
{ key: 'name', value: $t('audience.name') },
|
{ key: 'name', value: t.get('audience.name') },
|
||||||
{ key: 'email', value: $t('audience.email') },
|
{ key: 'email', value: t.get('audience.email') },
|
||||||
{ key: 'date_joined', value: $t('audience.date_joined') }
|
{ key: 'status', value: t.get('audience.status') },
|
||||||
];
|
{ key: 'date_joined', value: t.get('audience.date_joined') }
|
||||||
let pageSize = $state(5);
|
]);
|
||||||
|
|
||||||
|
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 currentPage = $state(1);
|
||||||
let searchValue = $state('');
|
let searchValue = $state('');
|
||||||
|
let selectedIds = new SvelteSet<string>();
|
||||||
|
let assignModalOpen = $state(false);
|
||||||
|
|
||||||
const filteredRows = $derived(
|
const filteredRows = $derived(
|
||||||
searchValue
|
searchValue
|
||||||
@@ -50,6 +77,45 @@
|
|||||||
const endIndex = $derived(startIndex + pageSize);
|
const endIndex = $derived(startIndex + pageSize);
|
||||||
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
|
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(() => {
|
$effect(() => {
|
||||||
if (filteredRows.length > 0 && currentPage > totalPages) {
|
if (filteredRows.length > 0 && currentPage > totalPages) {
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
@@ -61,90 +127,47 @@
|
|||||||
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
|
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Page.BodyHeader>
|
<AudienceTableToolbar
|
||||||
<Search placeholder="Search..." bind:value={searchValue} class="" />
|
{hasSelection}
|
||||||
</Page.BodyHeader>
|
selectedCount={selectedIds.size}
|
||||||
|
bind:searchValue
|
||||||
|
onOpenAssign={() => (assignModalOpen = true)}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if orgApi.audience.length || orgApi.isLoading}
|
{#if orgApi.audience.length || orgApi.isLoading}
|
||||||
<div class="w-full space-y-4">
|
<div class="w-full space-y-4">
|
||||||
<!-- Table -->
|
|
||||||
{#if orgApi.isLoading}
|
{#if orgApi.isLoading}
|
||||||
<div class="space-y-2">
|
<AudienceTableSkeleton rowCount={pageSize} />
|
||||||
{#each Array(pageSize) as _, i (i)}
|
|
||||||
<Skeleton class="h-12 w-full" />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="rounded-md border">
|
<AudienceTable
|
||||||
<Table.Root>
|
{headers}
|
||||||
<Table.Header>
|
rows={paginatedRows}
|
||||||
<Table.Row>
|
{allPageSelected}
|
||||||
{#each headers as header (header)}
|
{somePageSelected}
|
||||||
<Table.Head>{header.value}</Table.Head>
|
onToggleSelectAll={toggleSelectAll}
|
||||||
{/each}
|
isRowSelected={(id) => selectedIds.has(id)}
|
||||||
</Table.Row>
|
onToggleRow={toggleRow}
|
||||||
</Table.Header>
|
{inviteActionEmail}
|
||||||
<Table.Body>
|
onResendInvite={handleResendInvite}
|
||||||
{#each paginatedRows as row (row.id)}
|
onRevokeInvite={handleRevokeInvite}
|
||||||
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Pagination -->
|
<AudiencePagination
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<Pagination.Root
|
|
||||||
count={filteredRows.length}
|
count={filteredRows.length}
|
||||||
perPage={pageSize}
|
perPage={pageSize}
|
||||||
page={currentPage}
|
page={currentPage}
|
||||||
onPageChange={(page) => (currentPage = page)}
|
onPageChange={(p) => (currentPage = p)}
|
||||||
>
|
/>
|
||||||
{#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>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
|
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
|
||||||
{/if}
|
{/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 AudiencePage } from './audience.svelte';
|
||||||
|
export { default as ImportAudiencePage } from './import.svelte';
|
||||||
export { default as UserAnalyticsPage } from './user-analytics.svelte';
|
export { default as UserAnalyticsPage } from './user-analytics.svelte';
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
|
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
|
||||||
|
|
||||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
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 ChartLineIcon from '@lucide/svelte/icons/chart-line';
|
||||||
import { Progress } from '@cio/ui/base/progress';
|
import { Progress } from '@cio/ui/base/progress';
|
||||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
|
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
|
||||||
|
|
||||||
|
import { BackButton } from '@cio/ui';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { currentOrgPath } from '$lib/utils/store/org';
|
import { currentOrgPath } from '$lib/utils/store/org';
|
||||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||||
@@ -61,10 +61,7 @@
|
|||||||
|
|
||||||
{#if userAnalytics}
|
{#if userAnalytics}
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}>
|
<BackButton href={`${$currentOrgPath}/audience`} label={$t('community.ask.go_back')} />
|
||||||
<ArrowLeftIcon size={16} />
|
|
||||||
{$t('community.ask.go_back')}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-5 py-1">
|
<div class="px-5 py-1">
|
||||||
@@ -116,7 +113,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each filteredCourses as course, index}
|
{#each filteredCourses as course (course.id)}
|
||||||
{#key index}
|
{#key index}
|
||||||
<div
|
<div
|
||||||
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
|
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">
|
<script lang="ts">
|
||||||
import * as Avatar from '@cio/ui/base/avatar';
|
import * as Avatar from '@cio/ui/base/avatar';
|
||||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||||
|
import UserIcon from '@lucide/svelte/icons/user';
|
||||||
import { calDateDiff } from '$lib/utils/functions/date';
|
import { calDateDiff } from '$lib/utils/functions/date';
|
||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import { shortenName } from '$lib/utils/functions/string';
|
|
||||||
import { Vote } from '$features/ui';
|
import { Vote } from '$features/ui';
|
||||||
import { CircleCheckIcon } from '$features/ui/icons';
|
import { CircleCheckIcon } from '$features/ui/icons';
|
||||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||||
@@ -40,11 +40,15 @@
|
|||||||
<header class="flex items-center justify-between p-2 leading-none">
|
<header class="flex items-center justify-between p-2 leading-none">
|
||||||
<div class="flex items-center text-black">
|
<div class="flex items-center text-black">
|
||||||
<Avatar.Root class="size-7">
|
<Avatar.Root class="size-7">
|
||||||
|
{#if comment.author?.avatarUrl}
|
||||||
<Avatar.Image
|
<Avatar.Image
|
||||||
src={comment.author?.avatarUrl ? comment.author.avatarUrl : '/logo-192.png'}
|
src={comment.author.avatarUrl}
|
||||||
alt={comment.author?.fullname ? comment.author.fullname : 'User'}
|
alt={comment.author?.fullname ? comment.author.fullname : 'User'}
|
||||||
/>
|
/>
|
||||||
<Avatar.Fallback>{shortenName(comment.author?.fullname) || 'U'}</Avatar.Fallback>
|
{/if}
|
||||||
|
<Avatar.Fallback>
|
||||||
|
<UserIcon class="ui:size-3.5 ui:text-muted-foreground" />
|
||||||
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
|
|
||||||
<p class="ml-2 text-sm dark:text-white">{comment.author?.fullname}</p>
|
<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 * as Avatar from '@cio/ui/base/avatar';
|
||||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||||
|
import UserIcon from '@lucide/svelte/icons/user';
|
||||||
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { calDateDiff } from '$lib/utils/functions/date';
|
import { calDateDiff } from '$lib/utils/functions/date';
|
||||||
@@ -17,19 +18,20 @@
|
|||||||
import { Vote } from '$features/ui';
|
import { Vote } from '$features/ui';
|
||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import { InputField } from '@cio/ui/custom/input-field';
|
import { InputField } from '@cio/ui/custom/input-field';
|
||||||
import { shortenName } from '$lib/utils/functions/string';
|
|
||||||
import { TextEditor } from '$features/ui';
|
import { TextEditor } from '$features/ui';
|
||||||
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
|
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
|
||||||
|
import { BackButton } from '@cio/ui';
|
||||||
import type { CommunityQuestionSuccess } from '../utils/types';
|
import type { CommunityQuestionSuccess } from '../utils/types';
|
||||||
import { currentCommunityQuestion } from '../utils/store';
|
import { currentCommunityQuestion } from '../utils/store';
|
||||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
backHref?: string;
|
||||||
question?: CommunityQuestionSuccess['data'] | null;
|
question?: CommunityQuestionSuccess['data'] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { question }: Props = $props();
|
let { question, backHref }: Props = $props();
|
||||||
|
|
||||||
let commentEditor: TiptapEditor | undefined = $state();
|
let commentEditor: TiptapEditor | undefined = $state();
|
||||||
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
|
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.Root class="mx-auto w-[90%] md:mx-10 md:max-w-3xl lg:mb-20">
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
|
{#if backHref}
|
||||||
|
<BackButton href={backHref} label={$t('community.ask.go_back')} />
|
||||||
|
{/if}
|
||||||
{#if communityApi.isEditMode}
|
{#if communityApi.isEditMode}
|
||||||
<div class="flex w-full items-center gap-2">
|
<div class="flex w-full items-center gap-2">
|
||||||
<InputField
|
<InputField
|
||||||
@@ -175,13 +180,15 @@
|
|||||||
<div class="flex items-center justify-between p-2 leading-none">
|
<div class="flex items-center justify-between p-2 leading-none">
|
||||||
<div class="flex items-center text-black no-underline hover:underline">
|
<div class="flex items-center text-black no-underline hover:underline">
|
||||||
<Avatar.Root class="h-7 w-7">
|
<Avatar.Root class="h-7 w-7">
|
||||||
|
{#if communityApi.question?.authorAvatarUrl}
|
||||||
<Avatar.Image
|
<Avatar.Image
|
||||||
src={communityApi.question?.authorAvatarUrl
|
src={communityApi.question.authorAvatarUrl}
|
||||||
? communityApi.question?.authorAvatarUrl
|
alt={communityApi.question?.authorFullname ? communityApi.question.authorFullname : 'User'}
|
||||||
: '/logo-192.png'}
|
|
||||||
alt={communityApi.question?.authorFullname ? communityApi.question?.authorFullname : 'User'}
|
|
||||||
/>
|
/>
|
||||||
<Avatar.Fallback>{shortenName(communityApi.question?.authorFullname) || 'U'}</Avatar.Fallback>
|
{/if}
|
||||||
|
<Avatar.Fallback>
|
||||||
|
<UserIcon class="ui:size-3.5 ui:text-muted-foreground" />
|
||||||
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<p class="ml-2 text-sm dark:text-white">{communityApi.question?.authorFullname}</p>
|
<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">
|
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import { Empty } from '@cio/ui/custom/empty';
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
import * as Item from '@cio/ui/base/item';
|
import * as Item from '@cio/ui/base/item';
|
||||||
import { Search } from '@cio/ui/custom/search';
|
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 MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||||
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
|
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
|
||||||
|
|
||||||
@@ -62,10 +64,8 @@
|
|||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
<Select.Item value="">{$t('community.all')}</Select.Item>
|
<Select.Item value="">{$t('community.all')}</Select.Item>
|
||||||
{#each communityApi.courses as course}
|
{#each communityApi.courses as course (course.id)}
|
||||||
{#if course.id}
|
|
||||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
@@ -77,10 +77,10 @@
|
|||||||
<CommunityListLoader />
|
<CommunityListLoader />
|
||||||
<CommunityListLoader />
|
<CommunityListLoader />
|
||||||
{:else}
|
{:else}
|
||||||
{#each filteredQuestions as question}
|
{#each filteredQuestions as question (question.slug)}
|
||||||
<Item.Root variant="outline">
|
<Item.Root variant="outline">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href="{$basePath}/community/{question.slug}" {...props}>
|
<a href={resolve(`${$basePath}/community/[slug]`, { slug: question.slug })} {...props}>
|
||||||
<Vote value={question.votes} />
|
<Vote value={question.votes} />
|
||||||
<Item.Content class="gap-y-0.5">
|
<Item.Content class="gap-y-0.5">
|
||||||
<Item.Title class="mt-0">
|
<Item.Title class="mt-0">
|
||||||
@@ -89,11 +89,15 @@
|
|||||||
<Item.Description>
|
<Item.Description>
|
||||||
{question?.authorFullname} asked {calDateDiff(question.createdAt || new Date())}
|
{question?.authorFullname} asked {calDateDiff(question.createdAt || new Date())}
|
||||||
</Item.Description>
|
</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">
|
<span class="text-muted-foreground p-0 text-xs">
|
||||||
#{question?.courseTitle}
|
#{question?.courseTitle}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</Button>
|
||||||
</Item.Content>
|
</Item.Content>
|
||||||
<Item.Actions>
|
<Item.Actions>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
|||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
|
import { coursesApi } from './courses.svelte';
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { ContentType } from '@cio/utils/constants/content';
|
import { ContentType } from '@cio/utils/constants/content';
|
||||||
import type { CourseMembers } from '../utils/types';
|
import type { CourseMembers } from '../utils/types';
|
||||||
@@ -499,6 +500,7 @@ export class CourseApi extends BaseApiWithErrors {
|
|||||||
}),
|
}),
|
||||||
logContext: 'deleting course',
|
logContext: 'deleting course',
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
|
coursesApi.removeCourseFromLists(courseId);
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
snackbar.success('Course deleted successfully');
|
snackbar.success('Course deleted successfully');
|
||||||
this.success = true;
|
this.success = true;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
UserEnrolledCourses
|
UserEnrolledCourses
|
||||||
} from '$features/course/types';
|
} from '$features/course/types';
|
||||||
|
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API class for course operations
|
* API class for course operations
|
||||||
*/
|
*/
|
||||||
@@ -16,12 +18,19 @@ export class CoursesApi extends BaseApiWithErrors {
|
|||||||
enrolledCourses = $state<UserEnrolledCourses>([]);
|
enrolledCourses = $state<UserEnrolledCourses>([]);
|
||||||
recommendedCourses = $state<RecommendedCourses>([]);
|
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
|
* Fetches org courses for the current organization
|
||||||
* Org ID is automatically added from currentOrg store
|
* Org ID is automatically added from currentOrg store
|
||||||
*/
|
*/
|
||||||
async getOrgCourses(tagSlugs: string[] = []) {
|
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>({
|
return this.execute<GetOrgCoursesRequest>({
|
||||||
requestFn: () =>
|
requestFn: () =>
|
||||||
@@ -32,7 +41,7 @@ export class CoursesApi extends BaseApiWithErrors {
|
|||||||
tags: normalizedTagSlugs.join(',')
|
tags: normalizedTagSlugs.join(',')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: {}
|
: { query: {} }
|
||||||
),
|
),
|
||||||
logContext: 'fetching org courses',
|
logContext: 'fetching org courses',
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export class LessonApi extends BaseApiWithErrors {
|
|||||||
|
|
||||||
translations = $state<Record<string, Record<TLocale, string>>>({});
|
translations = $state<Record<string, Record<TLocale, string>>>({});
|
||||||
note = $state('');
|
note = $state('');
|
||||||
private autoSaveTimeoutId: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a lesson by ID
|
* 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();
|
export const lessonApi = new LessonApi();
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
import EmptyState from './empty-state.svelte';
|
import EmptyState from './empty-state.svelte';
|
||||||
import * as Avatar from '@cio/ui/base/avatar';
|
import * as Avatar from '@cio/ui/base/avatar';
|
||||||
import type { CourseAnalytics } from '$features/course/utils/types';
|
import type { CourseAnalytics } from '$features/course/utils/types';
|
||||||
import { shortenName } from '$lib/utils/functions/string';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
students?: CourseAnalytics['students'];
|
students?: CourseAnalytics['students'];
|
||||||
@@ -37,7 +36,10 @@
|
|||||||
if (!student) return;
|
if (!student) return;
|
||||||
|
|
||||||
goto(
|
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">
|
<div class="flex items-center gap-3">
|
||||||
<Avatar.Root class="size-8">
|
<Avatar.Root class="size-8">
|
||||||
|
{#if student.profile.avatar_url}
|
||||||
<Avatar.Image
|
<Avatar.Image
|
||||||
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'}
|
src={student.profile.avatar_url}
|
||||||
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
|
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
|
||||||
/>
|
/>
|
||||||
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
|
{/if}
|
||||||
|
<Avatar.Fallback>
|
||||||
|
<UserIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||||
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
|
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
|
||||||
import CardDropdown from './card-dropdown.svelte';
|
import CardDropdown from './card-dropdown.svelte';
|
||||||
import CoursePublishBadge from './course-publish-badge.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 { OrgCourses, UserEnrolledCourses } from '$features/course/types';
|
||||||
import type { OrgPublicCourses } from '$features/org/utils/types';
|
import type { OrgPublicCourses } from '$features/org/utils/types';
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@
|
|||||||
title: course.title,
|
title: course.title,
|
||||||
type: course.type,
|
type: course.type,
|
||||||
description: course.description,
|
description: course.description,
|
||||||
isPublished: course.isPublished,
|
isPublished: !!course.isPublished,
|
||||||
pricingData: {
|
pricingData: {
|
||||||
cost: course.cost,
|
cost: course.cost,
|
||||||
discount: course.metadata?.discount || 0,
|
discount: course.metadata?.discount || 0,
|
||||||
@@ -147,25 +148,14 @@
|
|||||||
</Item.Header>
|
</Item.Header>
|
||||||
|
|
||||||
<Item.Content>
|
<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}
|
{title}
|
||||||
</Item.Title>
|
</Item.Title>
|
||||||
|
|
||||||
<Item.Description class="min-h-[63px]">{description}</Item.Description>
|
<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}
|
||||||
{#if !isLMS && courseTags.length > 0}
|
<CourseTagsOverflow tags={courseTags} variant="card" />
|
||||||
{#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}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator class="my-3" />
|
<Separator class="my-3" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<script lang="ts">
|
<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 Popover from '@cio/ui/base/popover';
|
||||||
import * as ToggleGroup from '@cio/ui/base/toggle-group';
|
import * as ToggleGroup from '@cio/ui/base/toggle-group';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
@@ -27,8 +33,8 @@
|
|||||||
type SortOption = (typeof COURSE_SORT_OPTIONS)[number];
|
type SortOption = (typeof COURSE_SORT_OPTIONS)[number];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedId?: string;
|
sortKey?: CourseSortBy;
|
||||||
selectedOrder?: 'asc' | 'desc';
|
selectedOrder?: CourseSortOrder;
|
||||||
selectedTags?: string[];
|
selectedTags?: string[];
|
||||||
tagGroups?: FilterTagGroup[];
|
tagGroups?: FilterTagGroup[];
|
||||||
sortOptions?: readonly SortOption[];
|
sortOptions?: readonly SortOption[];
|
||||||
@@ -38,8 +44,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
selectedId = $bindable('0'),
|
sortKey = $bindable(DEFAULT_COURSE_SORT),
|
||||||
selectedOrder = $bindable('desc' as 'asc' | 'desc'),
|
selectedOrder = $bindable(DEFAULT_SORT_ORDER),
|
||||||
selectedTags = [],
|
selectedTags = [],
|
||||||
tagGroups = [],
|
tagGroups = [],
|
||||||
sortOptions = COURSE_SORT_OPTIONS,
|
sortOptions = COURSE_SORT_OPTIONS,
|
||||||
@@ -50,7 +56,9 @@
|
|||||||
|
|
||||||
let isOpen = $state(false);
|
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) {
|
function isTagSelected(tagSlug: string) {
|
||||||
return selectedTags.includes(tagSlug);
|
return selectedTags.includes(tagSlug);
|
||||||
@@ -96,8 +104,8 @@
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selectedId === option.value ? 'secondary' : 'outline'}
|
variant={sortKey === option.value ? 'secondary' : 'outline'}
|
||||||
onclick={() => (selectedId = option.value)}
|
onclick={() => (sortKey = option.value)}
|
||||||
>
|
>
|
||||||
{$t(option.label)}
|
{$t(option.label)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -115,13 +123,13 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
value={selectedOrder}
|
value={selectedOrder}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value === 'asc' || value === 'desc') {
|
if (value === CourseSortOrder.Asc || value === CourseSortOrder.Desc) {
|
||||||
selectedOrder = value;
|
selectedOrder = value;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
|
<ToggleGroup.Item value={CourseSortOrder.Asc}>{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
|
<ToggleGroup.Item value={CourseSortOrder.Desc}>{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
|
||||||
</ToggleGroup.Root>
|
</ToggleGroup.Root>
|
||||||
</div>
|
</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">
|
<script lang="ts">
|
||||||
import { getExerciseQuestionContractKey } from '@cio/question-types';
|
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 { Grade, ReasonBox } from '$features/ui/question';
|
||||||
import { toExerciseQuestionModel } from './question-type-utils';
|
import { toExerciseQuestionModel } from './question-type-utils';
|
||||||
import { getExerciseQuestionLabels } from './question-labels';
|
import { getExerciseQuestionLabels } from './question-labels';
|
||||||
@@ -8,15 +9,15 @@
|
|||||||
import type { QuestionnaireMetaData } from './store';
|
import type { QuestionnaireMetaData } from './store';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
questions?: any;
|
questions?: Question[];
|
||||||
questionnaireMetaData?: Partial<QuestionnaireMetaData>;
|
questionnaireMetaData?: Partial<QuestionnaireMetaData>;
|
||||||
|
/** Per-question scores (e.g. from `questionnaireMetaData.grades` or grading modal `questionAnswerByPoint`). */
|
||||||
grades?: Record<string, number>;
|
grades?: Record<string, number>;
|
||||||
onSubmit?: any;
|
|
||||||
onPrevious?: any;
|
|
||||||
disableGrading?: boolean;
|
disableGrading?: boolean;
|
||||||
isGradeWithAI?: boolean;
|
isGradeWithAI?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
reasons?: any;
|
/** Optional AI grading copy keyed by question id. */
|
||||||
|
reasons?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -33,12 +34,12 @@
|
|||||||
isGradeWithAI = false;
|
isGradeWithAI = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejectGradeSuggestion(questionId) {
|
function rejectGradeSuggestion(questionId: Question['id']) {
|
||||||
isGradeWithAI = false;
|
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);
|
const questionKey = getExerciseQuestionContractKey(questionModel, fallbackIndex);
|
||||||
return _questionnaireMetaData?.answers?.[questionKey];
|
return _questionnaireMetaData?.answers?.[questionKey];
|
||||||
}
|
}
|
||||||
@@ -59,11 +60,11 @@
|
|||||||
|
|
||||||
{#if disableGrading && !Object.keys(grades || {}).length}
|
{#if disableGrading && !Object.keys(grades || {}).length}
|
||||||
<ExerciseQuestion.QuestionList
|
<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"
|
itemClass="mb-4"
|
||||||
/>
|
/>
|
||||||
{:else if disableGrading && Object.keys(grades || {}).length}
|
{:else if disableGrading && Object.keys(grades || {}).length}
|
||||||
{#each questions as currentQuestion, index}
|
{#each questions as currentQuestion, index (currentQuestion.id)}
|
||||||
{@const questionModel = questionModels[index]}
|
{@const questionModel = questionModels[index]}
|
||||||
<div class="mb-4 space-y-3">
|
<div class="mb-4 space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -73,7 +74,7 @@
|
|||||||
|
|
||||||
<ExerciseQuestion.QuestionRenderer
|
<ExerciseQuestion.QuestionRenderer
|
||||||
contract={{
|
contract={{
|
||||||
mode: 'view',
|
mode: 'review',
|
||||||
question: questionModel,
|
question: questionModel,
|
||||||
answer: getAnswerForQuestion(questionModel, index),
|
answer: getAnswerForQuestion(questionModel, index),
|
||||||
labels: questionLabels,
|
labels: questionLabels,
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each questions as currentQuestion, index}
|
{#each questions as currentQuestion, index (currentQuestion.id)}
|
||||||
{@const questionModel = questionModels[index]}
|
{@const questionModel = questionModels[index]}
|
||||||
<div class="mb-4 space-y-3">
|
<div class="mb-4 space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -93,7 +94,7 @@
|
|||||||
|
|
||||||
<ExerciseQuestion.QuestionRenderer
|
<ExerciseQuestion.QuestionRenderer
|
||||||
contract={{
|
contract={{
|
||||||
mode: 'view',
|
mode: 'review',
|
||||||
question: questionModel,
|
question: questionModel,
|
||||||
answer: getAnswerForQuestion(questionModel, index),
|
answer: getAnswerForQuestion(questionModel, index),
|
||||||
labels: questionLabels,
|
labels: questionLabels,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
|||||||
'common.correct_badge': t.get(
|
'common.correct_badge': t.get(
|
||||||
'course.navItem.lessons.exercises.all_exercises.shared_question.common.correct_badge'
|
'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'),
|
'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.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'),
|
'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(
|
'short_answer.preview.helper': t.get(
|
||||||
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.preview.helper'
|
'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(
|
'numeric.edit.correct_value_label': t.get(
|
||||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_label'
|
'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(
|
'numeric.preview.tolerance_label': t.get(
|
||||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.tolerance_label'
|
'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(
|
'fill_blank.edit.accepted_answers_placeholder': t.get(
|
||||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder'
|
'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(
|
'fill_blank.preview.accepted_answers_label': t.get(
|
||||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.preview.accepted_answers_label'
|
'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(
|
'file_upload.edit.accepted_types_placeholder': t.get(
|
||||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
|
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -81,7 +81,7 @@
|
|||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<ExerciseQuestion.QuestionRenderer
|
<ExerciseQuestion.QuestionRenderer
|
||||||
contract={{
|
contract={{
|
||||||
mode: 'view',
|
mode: 'review',
|
||||||
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
|
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
|
||||||
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
|
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
|
||||||
labels: questionLabels,
|
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 CourseTagPicker } from './course-tag-picker.svelte';
|
||||||
export { default as CreateCourseButton } from './create-course-button.svelte';
|
export { default as CreateCourseButton } from './create-course-button.svelte';
|
||||||
export { default as CourseList } from './list.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 CourseListLoader } from './list-loader.svelte';
|
||||||
export { default as NewCourseModal } from './new-course-modal.svelte';
|
export { default as NewCourseModal } from './new-course-modal.svelte';
|
||||||
export { default as QuestionContainer } from './question-container.svelte';
|
export { default as QuestionContainer } from './question-container.svelte';
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||||
|
import UserIcon from '@lucide/svelte/icons/user';
|
||||||
|
|
||||||
import * as Avatar from '@cio/ui/base/avatar';
|
import * as Avatar from '@cio/ui/base/avatar';
|
||||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||||
import { shortenName } from '$lib/utils/functions/string';
|
|
||||||
import { DeleteModal } from '$features/ui';
|
import { DeleteModal } from '$features/ui';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
|
||||||
@@ -99,11 +99,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex h-full items-start gap-3">
|
<div class="flex h-full items-start gap-3">
|
||||||
<Avatar.Root class="mt-2 size-6!">
|
<Avatar.Root class="mt-2 size-6!">
|
||||||
<Avatar.Image
|
{#if $profile.avatarUrl}
|
||||||
src={$profile.avatarUrl ? $profile.avatarUrl : '/logo-192.png'}
|
<Avatar.Image src={$profile.avatarUrl} alt={$profile.fullname ? $profile.fullname : 'User'} />
|
||||||
alt={$profile.fullname ? $profile.fullname : 'User'}
|
{/if}
|
||||||
/>
|
<Avatar.Fallback>
|
||||||
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback>
|
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||||
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div class="h-full w-full">
|
<div class="h-full w-full">
|
||||||
<TextareaField
|
<TextareaField
|
||||||
@@ -125,11 +126,12 @@
|
|||||||
{#each comments as commentItem (commentItem.id)}
|
{#each comments as commentItem (commentItem.id)}
|
||||||
<div class="mt-2 flex items-start gap-3 pb-2">
|
<div class="mt-2 flex items-start gap-3 pb-2">
|
||||||
<Avatar.Root class="mt-2 size-6!">
|
<Avatar.Root class="mt-2 size-6!">
|
||||||
<Avatar.Image
|
{#if commentItem.avatar}
|
||||||
src={commentItem.avatar ? commentItem.avatar : '/logo-192.png'}
|
<Avatar.Image src={commentItem.avatar} alt={commentItem.name ? commentItem.name : 'User'} />
|
||||||
alt={commentItem.name ? commentItem.name : 'User'}
|
{/if}
|
||||||
/>
|
<Avatar.Fallback>
|
||||||
<Avatar.Fallback>{shortenName(commentItem.name) || 'U'}</Avatar.Fallback>
|
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||||
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
|
|
||||||
<div class="w-full rounded-md border px-3 py-2 dark:border-neutral-700">
|
<div class="w-full rounded-md border px-3 py-2 dark:border-neutral-700">
|
||||||
|
|||||||
+34
-2
@@ -5,6 +5,7 @@
|
|||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||||
import { CircleCheckIcon } from '$features/ui/icons';
|
import { CircleCheckIcon } from '$features/ui/icons';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import * as Tooltip from '@cio/ui/base/tooltip';
|
||||||
import { globalStore } from '$lib/utils/store/app';
|
import { globalStore } from '$lib/utils/store/app';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { courseApi, lessonApi } from '$features/course/api';
|
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>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if showMarkComplete && lessonId}
|
{#if showMarkComplete && lessonId}
|
||||||
<Button
|
<Button
|
||||||
@@ -131,27 +149,41 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Tooltip.Provider>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => goToContent(prevNextContent.prev)}
|
onclick={() => goToContent(prevNextContent.prev)}
|
||||||
disabled={isPrevDisabled}
|
disabled={isPrevDisabled}
|
||||||
aria-label={$t('course.navItem.lessons.prev')}
|
aria-label={$t('course.navItem.lessons.prev')}
|
||||||
title={$t('course.navItem.lessons.prev')}
|
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon size={14} />
|
<ChevronLeftIcon size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content side="bottom" sideOffset={4}>
|
||||||
|
{$t('course.navItem.lessons.prev_shortcut')}
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => goToContent(prevNextContent.next)}
|
onclick={() => goToContent(prevNextContent.next)}
|
||||||
disabled={isNextDisabled}
|
disabled={isNextDisabled}
|
||||||
aria-label={$t('course.navItem.lessons.next')}
|
aria-label={$t('course.navItem.lessons.next')}
|
||||||
title={$t('course.navItem.lessons.next')}
|
|
||||||
>
|
>
|
||||||
<ChevronRightIcon size={14} />
|
<ChevronRightIcon size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content side="bottom" sideOffset={4}>
|
||||||
|
{$t('course.navItem.lessons.next_shortcut')}
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
</div>
|
</div>
|
||||||
|
</Tooltip.Provider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import type { TLocale } from '@cio/db/types';
|
import type { TLocale } from '@cio/db/types';
|
||||||
import AIButton from '$features/course/components/lesson/ai-button.svelte';
|
import AIButton from '$features/course/components/lesson/ai-button.svelte';
|
||||||
import type { Writable } from 'svelte/store';
|
import type { Writable } from 'svelte/store';
|
||||||
|
import { saveDraft } from '$features/course/utils/lesson-draft';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode?: (typeof MODES)[keyof typeof MODES];
|
mode?: (typeof MODES)[keyof typeof MODES];
|
||||||
@@ -33,12 +34,7 @@
|
|||||||
}
|
}
|
||||||
lessonApi.translations[lessonId][lessonApi.currentLocale] = `${content}`;
|
lessonApi.translations[lessonId][lessonApi.currentLocale] = `${content}`;
|
||||||
|
|
||||||
try {
|
saveDraft(lessonId, lessonApi.currentLocale, `${content}`);
|
||||||
localStorage.setItem(`lesson-${lessonId}-${lessonApi.currentLocale}`, `${content}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving lesson note to localStorage', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
lessonApi.isDirty = true;
|
lessonApi.isDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import * as Table from '@cio/ui/base/table';
|
import * as Table from '@cio/ui/base/table';
|
||||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||||
@@ -7,6 +8,7 @@
|
|||||||
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
|
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||||
|
import CourseTagsOverflow from './course-tags-overflow.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -60,29 +62,18 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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 class="font-semibold">{title}</p></Table.Cell>
|
||||||
<Table.Cell class="truncate">
|
<Table.Cell class="truncate">
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{#if !isMobileStore.current}
|
{#if !isMobileStore.current}
|
||||||
<Table.Cell class="truncate">{type}</Table.Cell>
|
<Table.Cell class="truncate">{type}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell class="w-3/12 min-w-0">
|
||||||
{#if tags.length === 0}
|
{#if tags.length === 0}
|
||||||
<span class="ui:text-muted-foreground text-2xs">-</span>
|
<span class="ui:text-muted-foreground text-2xs">-</span>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="ui:text-muted-foreground text-2xs flex max-w-[220px] flex-wrap items-center gap-2">
|
<CourseTagsOverflow {tags} variant="table" />
|
||||||
{#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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{totalLessons}</Table.Cell>
|
<Table.Cell>{totalLessons}</Table.Cell>
|
||||||
|
|||||||
+23
-37
@@ -3,10 +3,10 @@
|
|||||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||||
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
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 { 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';
|
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
||||||
|
|
||||||
@@ -19,72 +19,58 @@
|
|||||||
|
|
||||||
let recipientCsv = $state('');
|
let recipientCsv = $state('');
|
||||||
let sendInviteEmails = $state(true);
|
let sendInviteEmails = $state(true);
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
|
||||||
async function createEmailInvites() {
|
async function inviteNewStudents() {
|
||||||
if (!courseId) return;
|
if (!courseId) return;
|
||||||
if (!recipientCsv.trim()) {
|
if (!recipientCsv.trim()) {
|
||||||
snackbar.error(`${INVITE_MODAL}.snackbar_add_one_recipient`);
|
snackbar.error('audience.import.snackbar_no_emails');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
studentInviteLinkStore.update((s) => ({ ...s, isCreating: true }));
|
isSubmitting = true;
|
||||||
try {
|
try {
|
||||||
const settings = $inviteSettingsStore;
|
const response = await orgApi.importAudienceMembers({
|
||||||
const payload = getInvitePayload(settings);
|
|
||||||
const response = await peopleApi.createStudentInvite(courseId, {
|
|
||||||
...payload,
|
|
||||||
recipientCsv,
|
recipientCsv,
|
||||||
|
courseIds: [courseId],
|
||||||
sendEmail: sendInviteEmails
|
sendEmail: sendInviteEmails
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdCount = response?.invites?.length || 0;
|
if (response) {
|
||||||
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 = '';
|
recipientCsv = '';
|
||||||
onInviteCreated?.();
|
await courseApi.refreshCourse(courseId, $profile.id);
|
||||||
|
await onInviteCreated?.();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create email invites', error);
|
console.error('Failed to invite new students into organization', error);
|
||||||
snackbar.error(`${INVITE_MODAL}.snackbar_failed_create_email_invites`);
|
snackbar.error('audience.import.snackbar_failed');
|
||||||
} finally {
|
} finally {
|
||||||
studentInviteLinkStore.update((s) => ({ ...s, isCreating: false }));
|
isSubmitting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCreating = $derived($studentInviteLinkStore.isCreating);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-md border p-4">
|
<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">
|
<p class="ui:text-muted-foreground mb-2 text-sm">
|
||||||
{$t(`${INVITE_MODAL}.direct_email_description`)}
|
{$t(`${INVITE_MODAL}.invite_new_students_description`)}
|
||||||
</p>
|
</p>
|
||||||
<TextareaField
|
<TextareaField
|
||||||
label={$t(`${INVITE_MODAL}.recipient_emails`)}
|
label={$t('audience.import.emails_label')}
|
||||||
bind:value={recipientCsv}
|
bind:value={recipientCsv}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
placeholder={$t(`${INVITE_MODAL}.recipient_emails_placeholder`)}
|
placeholder={$t('audience.import.emails_placeholder')}
|
||||||
/>
|
/>
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
name="send-invite-emails"
|
name="send-invite-emails"
|
||||||
label={$t(`${INVITE_MODAL}.send_invite_emails`)}
|
label={$t('audience.import.send_email')}
|
||||||
bind:checked={sendInviteEmails}
|
bind:checked={sendInviteEmails}
|
||||||
/>
|
/>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<Button variant="secondary" onclick={createEmailInvites} loading={isCreating}>
|
<Button variant="secondary" onclick={inviteNewStudents} loading={isSubmitting}>
|
||||||
{$t(`${INVITE_MODAL}.create_email_invites`)}
|
{$t(`${INVITE_MODAL}.invite_new_students`)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 { peopleApi } from '$features/course/api';
|
||||||
import { orgApi } from '$features/org/api/org.svelte';
|
import { orgApi } from '$features/org/api/org.svelte';
|
||||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||||
import {
|
import type { OrgStudent, Tutor } from './types';
|
||||||
DEFAULT_INVITE_SETTINGS_STATE,
|
|
||||||
DEFAULT_STUDENT_INVITE_LINK_STATE,
|
|
||||||
inviteSettingsStore,
|
|
||||||
studentInviteLinkStore
|
|
||||||
} from './store';
|
|
||||||
import type { Tutor } from './types';
|
|
||||||
|
|
||||||
import TutorSelectSection from './tutor-select-section.svelte';
|
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 BulkEmailSection from './bulk-email-section.svelte';
|
||||||
import InviteListSection from './invite-list-section.svelte';
|
|
||||||
import type { InviteListItem } from './types';
|
|
||||||
|
|
||||||
let tutors = $state<Tutor[]>([]);
|
let tutors = $state<Tutor[]>([]);
|
||||||
let selectedIds = $state<string[]>([]);
|
let selectedIds = $state<string[]>([]);
|
||||||
@@ -36,21 +28,9 @@
|
|||||||
const selectedTutors = $derived(tutors.filter((t) => selectedIds.includes(t.id.toString())));
|
const selectedTutors = $derived(tutors.filter((t) => selectedIds.includes(t.id.toString())));
|
||||||
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
||||||
|
|
||||||
let existingInvites = $state<InviteListItem[]>([]);
|
let isLoadingStudents = $state(false);
|
||||||
let isLoadingInvites = $state(false);
|
let activeTab = $state<'tutors' | 'students'>('students');
|
||||||
let activeTab = $state<'tutors' | 'students'>('tutors');
|
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, courseApi.group.people));
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTutors(team: OrgTeamMember[]) {
|
function getTutors(team: OrgTeamMember[]) {
|
||||||
const existingTutors = courseApi?.group?.tutors || [];
|
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) {
|
function setTutors(orgId: string | undefined) {
|
||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
untrack(async () => {
|
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() {
|
async function onSubmit() {
|
||||||
if (!selectedTutors.length) {
|
if (!selectedTutors.length) {
|
||||||
goto(resolve(page.url.pathname, {}));
|
goto(resolve(page.url.pathname, {}));
|
||||||
@@ -105,11 +113,8 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!isOpen || !courseId) return;
|
if (!isOpen || !courseId) return;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
activeTab = 'tutors';
|
activeTab = 'students';
|
||||||
activeStudentTab = 'email';
|
void loadStudents($currentOrg.id);
|
||||||
inviteSettingsStore.set({ ...DEFAULT_INVITE_SETTINGS_STATE });
|
|
||||||
studentInviteLinkStore.set({ ...DEFAULT_STUDENT_INVITE_LINK_STATE });
|
|
||||||
void loadInvites();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -149,35 +154,8 @@
|
|||||||
|
|
||||||
<UnderlineTabs.Content value="students">
|
<UnderlineTabs.Content value="students">
|
||||||
<div class="mt-6 space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
<InviteSettingsSection />
|
<ExistingStudentsSection {courseId} students={availableStudents} isLoading={isLoadingStudents} />
|
||||||
|
<BulkEmailSection {courseId} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</UnderlineTabs.Content>
|
</UnderlineTabs.Content>
|
||||||
</UnderlineTabs.Root>
|
</UnderlineTabs.Root>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { StudentInviteListItem, StudentInviteAuditItem } from '../../utils/types';
|
import type { StudentInviteListItem, StudentInviteAuditItem } from '../../utils/types';
|
||||||
import type { OrganizationTeamMembers } from '$features/org/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';
|
import type { TCourseInvitePreset } from '@cio/utils/validation/course/invite';
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
@@ -30,6 +31,9 @@ export type Tutor = Pick<OrgTeamMemberItem, 'id' | 'profileId' | 'email'> & {
|
|||||||
text: OrgTeamMemberItem['fullname'];
|
text: OrgTeamMemberItem['fullname'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OrgAudienceMemberItem = OrganizationAudience[number];
|
||||||
|
export type OrgStudent = OrgAudienceMemberItem;
|
||||||
|
|
||||||
/** Re-export API-driven invite types from course utils */
|
/** Re-export API-driven invite types from course utils */
|
||||||
export type InviteListItem = StudentInviteListItem;
|
export type InviteListItem = StudentInviteListItem;
|
||||||
export type InviteAuditItem = StudentInviteAuditItem;
|
export type InviteAuditItem = StudentInviteAuditItem;
|
||||||
|
|||||||
+2
-6
@@ -4,8 +4,7 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { BackButton } from '@cio/ui';
|
||||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
|
||||||
|
|
||||||
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
|
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
|
||||||
import {
|
import {
|
||||||
@@ -231,10 +230,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sidebar.Group class="pt-0!">
|
<Sidebar.Group class="pt-0!">
|
||||||
<Button variant="link" class="h-fit! justify-start! px-2! py-2!" href={resolve(coursesListPath, {})}>
|
<BackButton href={resolve(coursesListPath, {})} label={$t('org_navigation.courses')} class="px-2! py-2!" />
|
||||||
<ArrowLeftIcon class="custom" />
|
|
||||||
<span class="text-xs">{$t('org_navigation.courses')}</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
{#each navItems as item (item.id)}
|
{#each navItems as item (item.id)}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import { DeleteModal } from '$features/ui';
|
import { DeleteModal } from '$features/ui';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { courseApi } from '$features/course/api';
|
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 { deleteCourseModal, deleteCourseModalInitialState, courseMetaDeta } from '../utils/store';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
|
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
isExplore?: boolean;
|
isExplore?: boolean;
|
||||||
isLMS?: boolean;
|
isLMS?: boolean;
|
||||||
searchValue?: string;
|
searchValue?: string;
|
||||||
selectedId?: string;
|
sortKey?: CourseSortBy;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
showSortSelect?: boolean;
|
showSortSelect?: boolean;
|
||||||
filterControls?: Snippet;
|
filterControls?: Snippet;
|
||||||
@@ -46,22 +47,21 @@
|
|||||||
isExplore = false,
|
isExplore = false,
|
||||||
isLMS = false,
|
isLMS = false,
|
||||||
searchValue = $bindable(''),
|
searchValue = $bindable(''),
|
||||||
selectedId = $bindable('0'),
|
sortKey = $bindable(DEFAULT_COURSE_SORT),
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
showSortSelect = true,
|
showSortSelect = true,
|
||||||
filterControls
|
filterControls
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const filterOptions = $derived([
|
const filterOptions = $derived(
|
||||||
{ value: '0', label: $t('courses.course_filter.date_created') },
|
COURSE_SORT_OPTIONS.map((option) => ({
|
||||||
{ value: '1', label: $t('courses.course_filter.published') },
|
value: option.value,
|
||||||
{ value: '2', label: $t('courses.course_filter.lessons') }
|
label: $t(option.label)
|
||||||
]);
|
}))
|
||||||
|
|
||||||
const selectedLabel = $derived(
|
|
||||||
filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedLabel = $derived(filterOptions.find((opt) => opt.value === sortKey)?.label || filterOptions[0].label);
|
||||||
|
|
||||||
const setViewPreference = (preference: 'grid' | 'list') => {
|
const setViewPreference = (preference: 'grid' | 'list') => {
|
||||||
$courseMetaDeta.view = preference;
|
$courseMetaDeta.view = preference;
|
||||||
if (browser) {
|
if (browser) {
|
||||||
@@ -94,12 +94,12 @@
|
|||||||
isLoading={$deleteCourseModal.isDeleting}
|
isLoading={$deleteCourseModal.isDeleting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Page.BodyHeader align="right">
|
<Page.BodyHeader align="right" class="p-0!">
|
||||||
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
|
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
|
||||||
{#if showSortSelect}
|
{#if showSortSelect}
|
||||||
<Select.Root type="single" bind:value={selectedId}>
|
<Select.Root type="single" bind:value={sortKey}>
|
||||||
<Select.Trigger class="min-w-[150px]">
|
<Select.Trigger class="min-w-[150px]">
|
||||||
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
|
<p>{sortKey ? selectedLabel : filterOptions[0].label}</p>
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each filterOptions as option (option.label)}
|
{#each filterOptions as option (option.label)}
|
||||||
@@ -142,9 +142,9 @@
|
|||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.title')}</Table.Head>
|
<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-[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.lessons')}</Table.Head>
|
||||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</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>
|
<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 { globalStore } from '$lib/utils/store/app';
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
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 LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte';
|
||||||
import { courseApi, lessonApi } from '$features/course/api';
|
import { courseApi, lessonApi } from '$features/course/api';
|
||||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
import type { TLocale } from '@cio/db/types';
|
import type { TLocale } from '@cio/db/types';
|
||||||
import { orderedTabs, tabs as materialTabs } from '$features/course/components/lesson/constants';
|
import { orderedTabs, tabs as materialTabs } from '$features/course/components/lesson/constants';
|
||||||
import { getViewModeComponents } from '$features/course/components/lesson/utils';
|
import { getViewModeComponents } from '$features/course/components/lesson/utils';
|
||||||
|
import { loadDraft, clearDraft } from '$features/course/utils/lesson-draft';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
courseId: string;
|
courseId: string;
|
||||||
@@ -61,9 +62,14 @@
|
|||||||
const mode = $derived($page.url.searchParams.get('mode') === 'edit' ? MODES.edit : MODES.view);
|
const mode = $derived($page.url.searchParams.get('mode') === 'edit' ? MODES.edit : MODES.view);
|
||||||
|
|
||||||
let prevModeParam = $state<string | null>(null);
|
let prevModeParam = $state<string | null>(null);
|
||||||
let isSaving = $state(false);
|
|
||||||
let isDeletingLesson = $state(false);
|
let isDeletingLesson = $state(false);
|
||||||
let isVersionDrawerOpen = $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 lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson');
|
||||||
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
|
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
|
||||||
@@ -80,6 +86,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleMode() {
|
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);
|
setModeQueryParam(mode === MODES.edit ? MODES.view : MODES.edit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +151,7 @@
|
|||||||
|
|
||||||
const isMaterialsEmpty = $derived(tabs.every((tab) => tab.badgeValue === 0));
|
const isMaterialsEmpty = $derived(tabs.every((tab) => tab.badgeValue === 0));
|
||||||
|
|
||||||
let timeoutId: NodeJS.Timeout;
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
let isLoading = writable(false);
|
let isLoading = writable(false);
|
||||||
|
|
||||||
@@ -184,9 +196,6 @@
|
|||||||
async function saveLesson() {
|
async function saveLesson() {
|
||||||
if (!lessonApi.lesson) return false;
|
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([
|
const [isLessonUpdated] = await Promise.all([
|
||||||
lessonApi.update(courseApi.course?.id || '', lessonId, {
|
lessonApi.update(courseApi.course?.id || '', lessonId, {
|
||||||
title: lessonApi.lesson.title || undefined,
|
title: lessonApi.lesson.title || undefined,
|
||||||
@@ -200,6 +209,8 @@
|
|||||||
|
|
||||||
if (isLessonUpdated) {
|
if (isLessonUpdated) {
|
||||||
patchLessonListItemLocally();
|
patchLessonListItemLocally();
|
||||||
|
clearDraft(lessonId, lessonApi.currentLocale);
|
||||||
|
lessonApi.isDirty = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +230,7 @@
|
|||||||
await lessonApi.delete(courseId, lessonId);
|
await lessonApi.delete(courseId, lessonId);
|
||||||
|
|
||||||
if (lessonApi.success) {
|
if (lessonApi.success) {
|
||||||
|
hasUnsavedChanges = false;
|
||||||
await goto(resolve(`/courses/${courseId}/lessons`, {}));
|
await goto(resolve(`/courses/${courseId}/lessons`, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +240,8 @@
|
|||||||
function handleSave(prevMode: string) {
|
function handleSave(prevMode: string) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (prevMode === MODES.edit) {
|
if (prevMode === MODES.edit) {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = undefined;
|
||||||
saveLesson();
|
saveLesson();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -240,11 +254,8 @@
|
|||||||
if (timeoutId) clearTimeout(timeoutId);
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
isSaving = true;
|
|
||||||
timeoutId = setTimeout(async () => {
|
timeoutId = setTimeout(async () => {
|
||||||
await saveLesson();
|
await saveLesson();
|
||||||
|
|
||||||
isSaving = false;
|
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -255,13 +266,41 @@
|
|||||||
autoSave();
|
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.
|
// Only autosave after real edits, not on every reactive update.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (mode !== MODES.edit) return;
|
if (mode !== MODES.edit) return;
|
||||||
if (!lessonApi.lesson || !lessonId) return;
|
if (!lessonApi.lesson || !lessonId) return;
|
||||||
if (!lessonApi.isDirty) return;
|
if (!lessonApi.isDirty) return;
|
||||||
if ($isLoading) return;
|
if ($isLoading) return;
|
||||||
console.log('autoSave');
|
|
||||||
|
|
||||||
autoSave();
|
autoSave();
|
||||||
});
|
});
|
||||||
@@ -270,7 +309,6 @@
|
|||||||
let didHandleExitEdit = false;
|
let didHandleExitEdit = false;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!lessonId) return;
|
if (!lessonId) return;
|
||||||
console.log('didHandleExitEdit');
|
|
||||||
|
|
||||||
const currentParam = $page.url.searchParams.get('mode');
|
const currentParam = $page.url.searchParams.get('mode');
|
||||||
const prev = prevModeParam;
|
const prev = prevModeParam;
|
||||||
@@ -338,8 +376,17 @@
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex-row items-center lg:flex">
|
<div class="flex flex-row items-center gap-2 lg:flex">
|
||||||
<IconButton onclick={toggleMode} disabled={isSaving}>
|
{#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}
|
{#if mode === MODES.edit}
|
||||||
<Save size={20} />
|
<Save size={20} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -452,6 +499,8 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
|
|
||||||
|
<UnsavedChanges bind:hasUnsavedChanges />
|
||||||
|
|
||||||
{#if isVersionDrawerOpen && window.innerWidth >= 1024}
|
{#if isVersionDrawerOpen && window.innerWidth >= 1024}
|
||||||
<LessonVersionHistory
|
<LessonVersionHistory
|
||||||
open={isVersionDrawerOpen}
|
open={isVersionDrawerOpen}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import type { CourseMembers, CourseMember } from '$features/course/utils/types';
|
import type { CourseMembers, CourseMember } from '$features/course/utils/types';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import UserIcon from '@lucide/svelte/icons/user';
|
||||||
import { shortenName } from '$lib/utils/functions/string';
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
import * as Select from '@cio/ui/base/select';
|
import * as Select from '@cio/ui/base/select';
|
||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
@@ -138,11 +139,15 @@
|
|||||||
{#if person.profile}
|
{#if person.profile}
|
||||||
<div class="flex items-start lg:items-center">
|
<div class="flex items-start lg:items-center">
|
||||||
<Avatar.Root class="mr-3">
|
<Avatar.Root class="mr-3">
|
||||||
|
{#if person.profile.avatar_url}
|
||||||
<Avatar.Image
|
<Avatar.Image
|
||||||
src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'}
|
src={person.profile.avatar_url}
|
||||||
alt={person.profile.fullname ? person.profile.fullname : 'User'}
|
alt={person.profile.fullname ? person.profile.fullname : 'User'}
|
||||||
/>
|
/>
|
||||||
<Avatar.Fallback>{shortenName(person.profile.fullname) || 'U'}</Avatar.Fallback>
|
{/if}
|
||||||
|
<Avatar.Fallback>
|
||||||
|
<UserIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||||
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div class="flex flex-col items-start lg:flex-row lg:items-center">
|
<div class="flex flex-col items-start lg:flex-row lg:items-center">
|
||||||
<div class="mr-2">
|
<div class="mr-2">
|
||||||
|
|||||||
@@ -152,7 +152,7 @@
|
|||||||
await submissionApi.updateGrades(courseId, subId, {
|
await submissionApi.updateGrades(courseId, subId, {
|
||||||
answers,
|
answers,
|
||||||
total,
|
total,
|
||||||
feedback,
|
feedback: feedback || undefined,
|
||||||
statusId: STATUS.GRADED
|
statusId: STATUS.GRADED
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,6 +195,8 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleModalClose();
|
||||||
|
|
||||||
isSaving = false;
|
isSaving = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,8 +16,51 @@ export const EXERCISE_TEMPLATE_TAGS = {
|
|||||||
GIT: 'GIT'
|
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 = [
|
export const COURSE_SORT_OPTIONS = [
|
||||||
{ value: '0', label: 'courses.course_filter.date_created' },
|
{ value: CourseSortBy.DateCreated, label: 'courses.course_filter.date_created' },
|
||||||
{ value: '1', label: 'courses.course_filter.published' },
|
{ value: CourseSortBy.Published, label: 'courses.course_filter.published' },
|
||||||
{ value: '2', label: 'courses.course_filter.lessons' }
|
{ value: CourseSortBy.Lessons, label: 'courses.course_filter.lessons' }
|
||||||
] as const;
|
] 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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
@@ -7,15 +8,18 @@
|
|||||||
import { courseMetaDeta } from '$features/course/utils/store';
|
import { courseMetaDeta } from '$features/course/utils/store';
|
||||||
import { coursesApi } from '$features/course/api';
|
import { coursesApi } from '$features/course/api';
|
||||||
import type { RecommendedCourses } from '$features/course/types';
|
import type { RecommendedCourses } from '$features/course/types';
|
||||||
|
import { CourseSortBy, DEFAULT_COURSE_SORT, parseCourseSortValue } from '$features/course/utils/constants';
|
||||||
|
|
||||||
let searchValue = $state('');
|
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(() => {
|
const filteredExploreCourses: RecommendedCourses = $derived.by(() => {
|
||||||
if (selectedId) {
|
|
||||||
localStorage.setItem('classroomio_filter_course_key', selectedId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const coursesFiltered = coursesApi.recommendedCourses.filter((course) => {
|
const coursesFiltered = coursesApi.recommendedCourses.filter((course) => {
|
||||||
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
|
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
|
||||||
return true;
|
return true;
|
||||||
@@ -24,11 +28,13 @@
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selectedId === '0') {
|
if (sortKey === CourseSortBy.DateCreated) {
|
||||||
return coursesFiltered.sort(
|
return coursesFiltered.sort(
|
||||||
(a, b) => new Date(a.createdAt ?? '').getTime() - new Date(b.createdAt ?? '').getTime()
|
(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));
|
return coursesFiltered.sort((a, b) => (b.lessonCount ?? 0) - (a.lessonCount ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +48,7 @@
|
|||||||
$courseMetaDeta.view = courseView;
|
$courseMetaDeta.view = courseView;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedId = localStorage.getItem('classroomio_filter_course_key') || '0';
|
sortKey = parseCourseSortValue(localStorage.getItem('classroomio_filter_course_key'));
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -60,5 +66,5 @@
|
|||||||
isLMS={true}
|
isLMS={true}
|
||||||
isLoading={coursesApi.isLoading}
|
isLoading={coursesApi.isLoading}
|
||||||
bind:searchValue
|
bind:searchValue
|
||||||
bind:selectedId
|
bind:sortKey
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
|
||||||
import type {
|
import type {
|
||||||
|
AssignAudienceCoursesRequest,
|
||||||
DeleteTeamRequest,
|
DeleteTeamRequest,
|
||||||
DomainRequestRequest,
|
DomainRequestRequest,
|
||||||
GetAudienceRequest,
|
GetAudienceRequest,
|
||||||
GetOrgPublicCoursesRequest,
|
GetOrgPublicCoursesRequest,
|
||||||
|
ImportAudienceRequest,
|
||||||
InviteTeamRequest,
|
InviteTeamRequest,
|
||||||
OrgPublicCourses,
|
OrgPublicCourses,
|
||||||
OrganizationAudience,
|
OrganizationAudience,
|
||||||
OrganizationTeamMembers
|
OrganizationTeamMembers,
|
||||||
|
ResendAudienceInviteRequest,
|
||||||
|
RevokeAudienceInviteRequest
|
||||||
} from '../utils/types';
|
} 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 { ZCreateOrganization, ZUpdateOrganization } from '@cio/utils/validation/organization';
|
||||||
import { currentOrg, orgs } from '$lib/utils/store/org';
|
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();
|
export const orgApi = new OrgApi();
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export type OrganizationAudienceSuccess = Extract<InferResponseType<GetAudienceR
|
|||||||
|
|
||||||
export type OrganizationAudience = OrganizationAudienceSuccess['data'];
|
export type OrganizationAudience = OrganizationAudienceSuccess['data'];
|
||||||
|
|
||||||
|
export type OrganizationAudienceMember = OrganizationAudience[number];
|
||||||
|
|
||||||
// Org Public Courses types
|
// Org Public Courses types
|
||||||
export type GetOrgPublicCoursesRequest = typeof classroomio.organization.courses.public.$get;
|
export type GetOrgPublicCoursesRequest = typeof classroomio.organization.courses.public.$get;
|
||||||
export type OrgPublicCoursesResponse = InferResponseType<GetOrgPublicCoursesRequest> | null;
|
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 GetOrgSsoInfoSuccess = Extract<InferResponseType<GetOrgSsoInfoRequest>, { success: true }>;
|
||||||
export type OrgSsoInfo = GetOrgSsoInfoSuccess['data'];
|
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
|
// Token auth types
|
||||||
export type CreateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$post'];
|
export type CreateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$post'];
|
||||||
export type GetTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$get'];
|
export type GetTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$get'];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||||
import AlarmClockIcon from '@lucide/svelte/icons/alarm-clock';
|
import AlarmClockIcon from '@lucide/svelte/icons/alarm-clock';
|
||||||
import MailIcon from '@lucide/svelte/icons/mail';
|
import MailIcon from '@lucide/svelte/icons/mail';
|
||||||
|
import UserIcon from '@lucide/svelte/icons/user';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: UserAnalytics['user'];
|
user: UserAnalytics['user'];
|
||||||
@@ -16,11 +17,12 @@
|
|||||||
<div class="rounded-md border p-5">
|
<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">
|
<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.Root class="h-16 w-16">
|
||||||
<Avatar.Image
|
{#if user.avatarUrl}
|
||||||
src={user.avatarUrl ? user.avatarUrl : '/logo-192.png'}
|
<Avatar.Image src={user.avatarUrl} alt={user.fullName ? user.fullName : 'User'} />
|
||||||
alt={user.fullName ? user.fullName : 'User'}
|
{/if}
|
||||||
/>
|
<Avatar.Fallback>
|
||||||
<Avatar.Fallback>{user.fullName ? user.fullName : 'User'}</Avatar.Fallback>
|
<UserIcon class="ui:size-8 ui:text-muted-foreground" />
|
||||||
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div class="flex flex-col space-y-2">
|
<div class="flex flex-col space-y-2">
|
||||||
<p class="text-center text-2xl md:text-left dark:text-white">
|
<p class="text-center text-2xl md:text-left dark:text-white">
|
||||||
|
|||||||
+7
-6
@@ -2,12 +2,12 @@
|
|||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
|
import UserIcon from '@lucide/svelte/icons/user';
|
||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import ReviewFormEditor from './review-form-editor.svelte';
|
import ReviewFormEditor from './review-form-editor.svelte';
|
||||||
import * as Avatar from '@cio/ui/base/avatar';
|
import * as Avatar from '@cio/ui/base/avatar';
|
||||||
import { processErrors } from '$lib/utils/functions/validator';
|
import { processErrors } from '$lib/utils/functions/validator';
|
||||||
import { shortenName } from '$lib/utils/functions/string';
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import type { Course } from '$features/course/utils/types';
|
import type { Course } from '$features/course/utils/types';
|
||||||
|
|
||||||
@@ -98,11 +98,12 @@
|
|||||||
<!-- the headers -->
|
<!-- the headers -->
|
||||||
<div class="flex w-full items-center justify-between">
|
<div class="flex w-full items-center justify-between">
|
||||||
<Avatar.Root class="mt-1 h-10 w-10">
|
<Avatar.Root class="mt-1 h-10 w-10">
|
||||||
<Avatar.Image
|
{#if review.avatar_url}
|
||||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Reviewer'} />
|
||||||
alt={review.name ? review.name : 'Reviewer'}
|
{/if}
|
||||||
/>
|
<Avatar.Fallback>
|
||||||
<Avatar.Fallback>{shortenName(review.name) || 'R'}</Avatar.Fallback>
|
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||||
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<p class="text-sm">{review.name}</p>
|
<p class="text-sm">{review.name}</p>
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
import { cn } from '@cio/ui/tools';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -7,8 +9,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { id, children }: Props = $props();
|
let { id, children }: Props = $props();
|
||||||
|
|
||||||
|
let includesHash = $derived(page.url.hash?.includes(id) ?? false);
|
||||||
</script>
|
</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()}
|
{@render children()}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||||
import PlayIcon from '@lucide/svelte/icons/play';
|
import PlayIcon from '@lucide/svelte/icons/play';
|
||||||
|
import UserIcon from '@lucide/svelte/icons/user';
|
||||||
|
|
||||||
import { getLectureNo } from '$features/course/utils/functions';
|
import { getLectureNo } from '$features/course/utils/functions';
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
@@ -31,7 +32,6 @@
|
|||||||
import * as Avatar from '@cio/ui/base/avatar';
|
import * as Avatar from '@cio/ui/base/avatar';
|
||||||
import PricingSection from './components/pricing-section.svelte';
|
import PricingSection from './components/pricing-section.svelte';
|
||||||
import NavSection from './components/nav-section.svelte';
|
import NavSection from './components/nav-section.svelte';
|
||||||
import { shortenName } from '$lib/utils/functions/string';
|
|
||||||
import SectionsDisplay from './components/sections-display.svelte';
|
import SectionsDisplay from './components/sections-display.svelte';
|
||||||
import { observeIntersection } from './components/intersection-observer';
|
import { observeIntersection } from './components/intersection-observer';
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
<!-- Course Details -->
|
<!-- Course Details -->
|
||||||
<div class="course-content w-full p-3 lg:mr-10 lg:w-10/12">
|
<div class="course-content w-full p-3 lg:mr-10 lg:w-10/12">
|
||||||
<!-- Navigation -->
|
<!-- 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>
|
<UnderlineTabs.List>
|
||||||
{#each navItems as navItem (navItem.key)}
|
{#each navItems as navItem (navItem.key)}
|
||||||
<UnderlineTabs.Trigger value={navItem.key}>
|
<UnderlineTabs.Trigger value={navItem.key}>
|
||||||
@@ -316,15 +316,14 @@
|
|||||||
<!-- review -->
|
<!-- review -->
|
||||||
<div class="item-start my-2 flex w-2/4 flex-row">
|
<div class="item-start my-2 flex w-2/4 flex-row">
|
||||||
<!-- image container -->
|
<!-- image container -->
|
||||||
{#if review.avatar_url}
|
|
||||||
<Avatar.Root class="mt-1 size-10">
|
<Avatar.Root class="mt-1 size-10">
|
||||||
<Avatar.Image
|
{#if review.avatar_url}
|
||||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
|
||||||
alt={review.name ? review.name : 'Avatar'}
|
|
||||||
/>
|
|
||||||
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
|
|
||||||
</Avatar.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<Avatar.Fallback>
|
||||||
|
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
|
||||||
<!-- profile content -->
|
<!-- profile content -->
|
||||||
<div class="w-11/12 pl-2.5">
|
<div class="w-11/12 pl-2.5">
|
||||||
@@ -385,15 +384,14 @@
|
|||||||
<!-- review -->
|
<!-- review -->
|
||||||
<div class="item-start my-2 flex w-full flex-row">
|
<div class="item-start my-2 flex w-full flex-row">
|
||||||
<!-- image container -->
|
<!-- image container -->
|
||||||
{#if review.avatar_url}
|
|
||||||
<Avatar.Root class="mt-1 h-10 w-10">
|
<Avatar.Root class="mt-1 h-10 w-10">
|
||||||
<Avatar.Image
|
{#if review.avatar_url}
|
||||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
|
||||||
alt={review.name ? review.name : 'Avatar'}
|
|
||||||
/>
|
|
||||||
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
|
|
||||||
</Avatar.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<Avatar.Fallback>
|
||||||
|
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
|
||||||
<!-- profile content -->
|
<!-- profile content -->
|
||||||
<div class="w-11/12 pl-2.5">
|
<div class="w-11/12 pl-2.5">
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const landingPageStore = writable({
|
|||||||
id: 1,
|
id: 1,
|
||||||
show: true,
|
show: true,
|
||||||
name: 'Adeyemi Adetunji',
|
name: 'Adeyemi Adetunji',
|
||||||
avatar_url: '/logo-192.png',
|
avatar_url: '',
|
||||||
rating: 1,
|
rating: 1,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
description: 'rice ventore officiis maxime laudantium recusandae sit dolor.'
|
description: 'rice ventore officiis maxime laudantium recusandae sit dolor.'
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { basePath, globalStore } from '$lib/utils/store/app';
|
import { basePath, globalStore } from '$lib/utils/store/app';
|
||||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||||
@@ -12,7 +14,6 @@
|
|||||||
import ThemeToggle from './theme-toggle.svelte';
|
import ThemeToggle from './theme-toggle.svelte';
|
||||||
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { logout } from '$lib/utils/functions/logout';
|
|
||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
|
|
||||||
const sidebar = useSidebar();
|
const sidebar = useSidebar();
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
|
|
||||||
{#snippet usertrigger()}
|
{#snippet usertrigger()}
|
||||||
<DropdownMenu.Label class="p-0">
|
<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()}
|
{@render avatarblock()}
|
||||||
</a>
|
</a>
|
||||||
</DropdownMenu.Label>
|
</DropdownMenu.Label>
|
||||||
@@ -122,7 +123,7 @@
|
|||||||
|
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
<DropdownMenu.Item onclick={() => logout()}>
|
<DropdownMenu.Item onclick={() => goto(resolve(`/logout`, {}))}>
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<LogOutIcon />
|
<LogOutIcon />
|
||||||
<p class="text-sm">{$t('settings.profile.logout')}</p>
|
<p class="text-sm">{$t('settings.profile.logout')}</p>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const ROUTE = {
|
|||||||
COURSE: '/course',
|
COURSE: '/course',
|
||||||
LOGIN: '/login',
|
LOGIN: '/login',
|
||||||
SIGN_UP: '/signup',
|
SIGN_UP: '/signup',
|
||||||
INVITE_TEACHER: '/invite/t',
|
INVITE: '/invite',
|
||||||
PROFILE: '/profile',
|
PROFILE: '/profile',
|
||||||
PEOPLE: '/people',
|
PEOPLE: '/people',
|
||||||
DISCUSSIONS: '/discussions',
|
DISCUSSIONS: '/discussions',
|
||||||
@@ -25,7 +25,7 @@ export const PUBLIC_ROUTES = [
|
|||||||
ROUTE.LOGIN,
|
ROUTE.LOGIN,
|
||||||
ROUTE.LOGOUT,
|
ROUTE.LOGOUT,
|
||||||
ROUTE.SIGN_UP,
|
ROUTE.SIGN_UP,
|
||||||
`^${ROUTE.INVITE_TEACHER}/.*`,
|
`^${ROUTE.INVITE}/.*`,
|
||||||
ROUTE.FORGOT,
|
ROUTE.FORGOT,
|
||||||
ROUTE.RESET,
|
ROUTE.RESET,
|
||||||
`^${ROUTE.PAGES}/.*`,
|
`^${ROUTE.PAGES}/.*`,
|
||||||
@@ -42,7 +42,7 @@ export const ROUTES_TO_HIDE_NAV = [
|
|||||||
`^${ROUTE.LOGIN}$`,
|
`^${ROUTE.LOGIN}$`,
|
||||||
`^${ROUTE.SIGN_UP}$`,
|
`^${ROUTE.SIGN_UP}$`,
|
||||||
ROUTE.LMS_HOME,
|
ROUTE.LMS_HOME,
|
||||||
`^${ROUTE.INVITE_TEACHER}/.*`,
|
`^${ROUTE.INVITE}/.*`,
|
||||||
`^/course/.*/enroll$`,
|
`^/course/.*/enroll$`,
|
||||||
`^${ROUTE.FORGOT}$`,
|
`^${ROUTE.FORGOT}$`,
|
||||||
`^${ROUTE.RESET}$`,
|
`^${ROUTE.RESET}$`,
|
||||||
|
|||||||
@@ -11,3 +11,14 @@ export function getCioCookieString(cookies: Cookies): string {
|
|||||||
.map((c) => `${c.name}=${c.value}`)
|
.map((c) => `${c.name}=${c.value}`)
|
||||||
.join('; ');
|
.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",
|
"name": "Navn",
|
||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
"date_joined": "Dato tilmeldt",
|
"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": {
|
"community": {
|
||||||
"title": "Fællesskab",
|
"title": "Fællesskab",
|
||||||
@@ -940,6 +987,8 @@
|
|||||||
"complete": "Komplet",
|
"complete": "Komplet",
|
||||||
"next": "Næste",
|
"next": "Næste",
|
||||||
"prev": "Prev",
|
"prev": "Prev",
|
||||||
|
"prev_shortcut": "Forrige (←)",
|
||||||
|
"next_shortcut": "Næste (→)",
|
||||||
"disabled": "Funktionen er deaktiveret",
|
"disabled": "Funktionen er deaktiveret",
|
||||||
"version_history": {
|
"version_history": {
|
||||||
"title": "Versionshistorik",
|
"title": "Versionshistorik",
|
||||||
@@ -1200,6 +1249,9 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"accepted_answers_placeholder": "Accepterede svar (kommasepareret)"
|
"accepted_answers_placeholder": "Accepterede svar (kommasepareret)"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Indsendt:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"numeric": {
|
"numeric": {
|
||||||
@@ -1217,6 +1269,11 @@
|
|||||||
"tolerance_info": "Tilladt forskel fra den korrekte værdi (f.eks. accepterer 0,5 +/-0,5).",
|
"tolerance_info": "Tilladt forskel fra den korrekte værdi (f.eks. accepterer 0,5 +/-0,5).",
|
||||||
"correct_value_placeholder": "Korrekt værdi",
|
"correct_value_placeholder": "Korrekt værdi",
|
||||||
"tolerance_placeholder": "Tolerance"
|
"tolerance_placeholder": "Tolerance"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Indsendt:",
|
||||||
|
"in_range": "Inden for tolerance",
|
||||||
|
"out_of_range": "Ydre tolerance"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"short_answer": {
|
"short_answer": {
|
||||||
@@ -1228,6 +1285,10 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"instructions_placeholder": "Valgfri instruktioner"
|
"instructions_placeholder": "Valgfri instruktioner"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Indsendt:",
|
||||||
|
"accepted_label": "Accepterede svar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"true_false": {
|
"true_false": {
|
||||||
@@ -1291,8 +1352,9 @@
|
|||||||
"option_prefix": "Mulighed",
|
"option_prefix": "Mulighed",
|
||||||
"remove": "Fjern",
|
"remove": "Fjern",
|
||||||
"add_option": "Tilføj mulighed",
|
"add_option": "Tilføj mulighed",
|
||||||
"correct_badge": "(korrekt)",
|
"correct_badge": "korrekt",
|
||||||
"not_set": "Ikke indstillet"
|
"not_set": "Ikke indstillet",
|
||||||
|
"incorrect_badge": "Forkert"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1473,7 +1535,10 @@
|
|||||||
"cancel": "Annuller"
|
"cancel": "Annuller"
|
||||||
},
|
},
|
||||||
"content_locked_title": "Dette indhold er låst",
|
"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": {
|
"news_feed": {
|
||||||
"heading": "Nyhedsfeed",
|
"heading": "Nyhedsfeed",
|
||||||
@@ -1582,6 +1647,7 @@
|
|||||||
"unpublished": "Upubliceret",
|
"unpublished": "Upubliceret",
|
||||||
"continue_course": "Fortsæt",
|
"continue_course": "Fortsæt",
|
||||||
"learn_more": "Få flere oplysninger",
|
"learn_more": "Få flere oplysninger",
|
||||||
|
"tags_overflow_aria": "{count} flere tags",
|
||||||
"list_view": {
|
"list_view": {
|
||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
"description": "Beskrivelse",
|
"description": "Beskrivelse",
|
||||||
@@ -2245,7 +2311,8 @@
|
|||||||
"email_password_disabled_message": "E-mail/adgangskodelogin er deaktiveret for denne organisation.",
|
"email_password_disabled_message": "E-mail/adgangskodelogin er deaktiveret for denne organisation.",
|
||||||
"google_auth_disabled": "Google-godkendelse er deaktiveret.",
|
"google_auth_disabled": "Google-godkendelse er deaktiveret.",
|
||||||
"signup_disabled_error": "Tilmelding er deaktiveret for denne organisation. Kontakt venligst din administrator.",
|
"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": {
|
"tabs": {
|
||||||
"general": "Generelt",
|
"general": "Generelt",
|
||||||
@@ -2471,7 +2538,8 @@
|
|||||||
"edit_group": "Rediger gruppe",
|
"edit_group": "Rediger gruppe",
|
||||||
"edit_tag": "Rediger tag",
|
"edit_tag": "Rediger tag",
|
||||||
"save": "Gem",
|
"save": "Gem",
|
||||||
"cancel": "Annuller"
|
"cancel": "Annuller",
|
||||||
|
"actions_menu": "Tag handlinger"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"not_available": "N/A"
|
"not_available": "N/A"
|
||||||
@@ -2480,9 +2548,13 @@
|
|||||||
"invite": {
|
"invite": {
|
||||||
"organization": {
|
"organization": {
|
||||||
"page_title": "Tilmeld dig {orgName} på ClassroomIO",
|
"page_title": "Tilmeld dig {orgName} på ClassroomIO",
|
||||||
"role_label": "Rolle: {role}",
|
"invitation_student": "Du er blevet inviteret til {orgName} som elev.",
|
||||||
"email_label": "Inviteret e-mail: {email}",
|
"invitation_teacher": "Du er blevet inviteret til {orgName} som lærer.",
|
||||||
"join_button": "Deltag i organisationen",
|
"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": {
|
"messages": {
|
||||||
"email_mismatch": "Du er logget ind med en anden e-mail end denne invitation.",
|
"email_mismatch": "Du er logget ind med en anden e-mail end denne invitation.",
|
||||||
"expired": "Dette invitationslink er udløbet.",
|
"expired": "Dette invitationslink er udløbet.",
|
||||||
@@ -2520,6 +2592,17 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Create key"
|
"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": {
|
"keys": {
|
||||||
@@ -2534,7 +2617,11 @@
|
|||||||
"never_used": "Aldrig",
|
"never_used": "Aldrig",
|
||||||
"scopes_label": "Omfang:",
|
"scopes_label": "Omfang:",
|
||||||
"revoke_confirm": "Vil du tilbagekalde denne automatiseringsnøgle?",
|
"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": {
|
"clients": {
|
||||||
"claude_code": "Claude kode",
|
"claude_code": "Claude kode",
|
||||||
|
|||||||
@@ -475,7 +475,54 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"date_joined": "Datum des Beitritts",
|
"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": {
|
"community": {
|
||||||
"title": "Gemeinschaft",
|
"title": "Gemeinschaft",
|
||||||
@@ -940,6 +987,8 @@
|
|||||||
"complete": "Komplett",
|
"complete": "Komplett",
|
||||||
"next": "Als nächstes",
|
"next": "Als nächstes",
|
||||||
"prev": "Vorher",
|
"prev": "Vorher",
|
||||||
|
"prev_shortcut": "Zurück (←)",
|
||||||
|
"next_shortcut": "Weiter (→)",
|
||||||
"disabled": "Funktion ist deaktiviert",
|
"disabled": "Funktion ist deaktiviert",
|
||||||
"version_history": {
|
"version_history": {
|
||||||
"title": "Versionsgeschichte",
|
"title": "Versionsgeschichte",
|
||||||
@@ -1200,6 +1249,9 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"accepted_answers_placeholder": "Akzeptierte Antworten (durch Kommas getrennt)"
|
"accepted_answers_placeholder": "Akzeptierte Antworten (durch Kommas getrennt)"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Eingereicht:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"numeric": {
|
"numeric": {
|
||||||
@@ -1217,6 +1269,11 @@
|
|||||||
"tolerance_info": "Zulässige Abweichung vom korrekten Wert (Beispiel: 0,5 akzeptiert +/-0,5).",
|
"tolerance_info": "Zulässige Abweichung vom korrekten Wert (Beispiel: 0,5 akzeptiert +/-0,5).",
|
||||||
"correct_value_placeholder": "Korrekter Wert",
|
"correct_value_placeholder": "Korrekter Wert",
|
||||||
"tolerance_placeholder": "Toleranz"
|
"tolerance_placeholder": "Toleranz"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Eingereicht:",
|
||||||
|
"in_range": "Innerhalb der Toleranz",
|
||||||
|
"out_of_range": "Außerhalb der Toleranz"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"short_answer": {
|
"short_answer": {
|
||||||
@@ -1228,6 +1285,10 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"instructions_placeholder": "Optionale Anweisungen"
|
"instructions_placeholder": "Optionale Anweisungen"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Eingereicht:",
|
||||||
|
"accepted_label": "Akzeptierte Antworten"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"true_false": {
|
"true_false": {
|
||||||
@@ -1291,8 +1352,9 @@
|
|||||||
"option_prefix": "Option",
|
"option_prefix": "Option",
|
||||||
"remove": "Entfernen",
|
"remove": "Entfernen",
|
||||||
"add_option": "Option hinzufügen",
|
"add_option": "Option hinzufügen",
|
||||||
"correct_badge": "(Richtig)",
|
"correct_badge": "Richtig",
|
||||||
"not_set": "Nicht festgelegt"
|
"not_set": "Nicht festgelegt",
|
||||||
|
"incorrect_badge": "Falsch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1473,7 +1535,10 @@
|
|||||||
"cancel": "Abbrechen"
|
"cancel": "Abbrechen"
|
||||||
},
|
},
|
||||||
"content_locked_title": "Dieser Inhalt ist gesperrt",
|
"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": {
|
"news_feed": {
|
||||||
"heading": "Newsfeed",
|
"heading": "Newsfeed",
|
||||||
@@ -1582,6 +1647,7 @@
|
|||||||
"unpublished": "Unveröffentlicht",
|
"unpublished": "Unveröffentlicht",
|
||||||
"continue_course": "Weiter",
|
"continue_course": "Weiter",
|
||||||
"learn_more": "Erfahren Sie mehr",
|
"learn_more": "Erfahren Sie mehr",
|
||||||
|
"tags_overflow_aria": "{count} weitere Tags",
|
||||||
"list_view": {
|
"list_view": {
|
||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
"description": "Beschreibung",
|
"description": "Beschreibung",
|
||||||
@@ -2245,7 +2311,8 @@
|
|||||||
"email_password_disabled_message": "Die E-Mail-/Passwort-Anmeldung ist für diese Organisation deaktiviert.",
|
"email_password_disabled_message": "Die E-Mail-/Passwort-Anmeldung ist für diese Organisation deaktiviert.",
|
||||||
"google_auth_disabled": "Die Google-Authentifizierung ist 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.",
|
"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": {
|
"tabs": {
|
||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
@@ -2471,7 +2538,8 @@
|
|||||||
"edit_group": "Gruppe bearbeiten",
|
"edit_group": "Gruppe bearbeiten",
|
||||||
"edit_tag": "Tag bearbeiten",
|
"edit_tag": "Tag bearbeiten",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"cancel": "Abbrechen"
|
"cancel": "Abbrechen",
|
||||||
|
"actions_menu": "Markieren Sie Aktionen"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"not_available": "N/A"
|
"not_available": "N/A"
|
||||||
@@ -2480,9 +2548,13 @@
|
|||||||
"invite": {
|
"invite": {
|
||||||
"organization": {
|
"organization": {
|
||||||
"page_title": "Treten Sie {orgName} auf ClassroomIO bei",
|
"page_title": "Treten Sie {orgName} auf ClassroomIO bei",
|
||||||
"role_label": "Rolle: {role}",
|
"invitation_student": "Sie wurden zu {orgName} als Schüler/in eingeladen.",
|
||||||
"email_label": "Eingeladene E-Mail: {email}",
|
"invitation_teacher": "Sie wurden zu {orgName} als Lehrer/in eingeladen.",
|
||||||
"join_button": "Treten Sie der Organisation bei",
|
"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": {
|
"messages": {
|
||||||
"email_mismatch": "Sie sind mit einer anderen E-Mail-Adresse als dieser Einladung angemeldet.",
|
"email_mismatch": "Sie sind mit einer anderen E-Mail-Adresse als dieser Einladung angemeldet.",
|
||||||
"expired": "Dieser Einladungslink ist abgelaufen.",
|
"expired": "Dieser Einladungslink ist abgelaufen.",
|
||||||
@@ -2520,6 +2592,17 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Create key"
|
"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": {
|
"keys": {
|
||||||
@@ -2534,7 +2617,11 @@
|
|||||||
"never_used": "Niemals",
|
"never_used": "Niemals",
|
||||||
"scopes_label": "Geltungsbereiche:",
|
"scopes_label": "Geltungsbereiche:",
|
||||||
"revoke_confirm": "Diesen Automatisierungsschlüssel widerrufen?",
|
"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": {
|
"clients": {
|
||||||
"claude_code": "Claude Code",
|
"claude_code": "Claude Code",
|
||||||
|
|||||||
@@ -902,6 +902,7 @@
|
|||||||
"unpublished": "Unpublished",
|
"unpublished": "Unpublished",
|
||||||
"continue_course": "Continue",
|
"continue_course": "Continue",
|
||||||
"learn_more": "Learn more",
|
"learn_more": "Learn more",
|
||||||
|
"tags_overflow_aria": "{count} more tags",
|
||||||
"list_view": {
|
"list_view": {
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
@@ -1084,6 +1085,8 @@
|
|||||||
"complete": "Complete",
|
"complete": "Complete",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"prev": "Prev",
|
"prev": "Prev",
|
||||||
|
"prev_shortcut": "Previous (←)",
|
||||||
|
"next_shortcut": "Next (→)",
|
||||||
"materials": {
|
"materials": {
|
||||||
"body_heading": "No note, video or slide added for this lesson yet",
|
"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",
|
"body_content": "Share your knowledge with your students by creating engaging content. Start by clicking on",
|
||||||
@@ -1345,7 +1348,8 @@
|
|||||||
"option_prefix": "Option",
|
"option_prefix": "Option",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"add_option": "Add option",
|
"add_option": "Add option",
|
||||||
"correct_badge": "(Correct)",
|
"correct_badge": "Correct",
|
||||||
|
"incorrect_badge": "Incorrect",
|
||||||
"not_set": "Not set"
|
"not_set": "Not set"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
@@ -1414,6 +1418,10 @@
|
|||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"helper": "Free-text answer question."
|
"helper": "Free-text answer question."
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Submitted:",
|
||||||
|
"accepted_label": "Accepted answers"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"numeric": {
|
"numeric": {
|
||||||
@@ -1431,6 +1439,11 @@
|
|||||||
"preview": {
|
"preview": {
|
||||||
"correct_value_label": "Correct value",
|
"correct_value_label": "Correct value",
|
||||||
"tolerance_label": "Tolerance"
|
"tolerance_label": "Tolerance"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Submitted:",
|
||||||
|
"in_range": "Within tolerance",
|
||||||
|
"out_of_range": "Outside tolerance"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fill_blank": {
|
"fill_blank": {
|
||||||
@@ -1442,6 +1455,9 @@
|
|||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"accepted_answers_label": "Accepted answers"
|
"accepted_answers_label": "Accepted answers"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Submitted:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"file_upload": {
|
"file_upload": {
|
||||||
@@ -2015,6 +2031,12 @@
|
|||||||
"manage": "Manage all your students here and stay connected with them",
|
"manage": "Manage all your students here and stay connected with them",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"search_placeholder": "Search…",
|
||||||
|
"status": "Status",
|
||||||
|
"status_active": "Active",
|
||||||
|
"status_pending": "Pending",
|
||||||
|
"status_expired": "Expired",
|
||||||
|
"status_revoked": "Revoked",
|
||||||
"date_joined": "Date Joined",
|
"date_joined": "Date Joined",
|
||||||
"upgrade": "Upgrade your plan to invite more students",
|
"upgrade": "Upgrade your plan to invite more students",
|
||||||
"select_all": "Select all",
|
"select_all": "Select all",
|
||||||
@@ -2048,6 +2070,14 @@
|
|||||||
"snackbar_success": "Assigned {assigned} users to courses ({alreadyEnrolled} already enrolled, {emailsSent} emails sent)",
|
"snackbar_success": "Assigned {assigned} users to courses ({alreadyEnrolled} already enrolled, {emailsSent} emails sent)",
|
||||||
"snackbar_no_courses": "Please select at least one course",
|
"snackbar_no_courses": "Please select at least one course",
|
||||||
"snackbar_failed": "Failed to assign courses"
|
"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": {
|
"analytics": {
|
||||||
@@ -2453,9 +2483,13 @@
|
|||||||
"invite": {
|
"invite": {
|
||||||
"organization": {
|
"organization": {
|
||||||
"page_title": "Join {orgName} on ClassroomIO",
|
"page_title": "Join {orgName} on ClassroomIO",
|
||||||
"role_label": "Role: {role}",
|
"invitation_student": "You've been invited into {orgName} as a student.",
|
||||||
"email_label": "Invited email: {email}",
|
"invitation_teacher": "You've been invited into {orgName} as a teacher.",
|
||||||
"join_button": "Join Organization",
|
"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": {
|
"messages": {
|
||||||
"email_mismatch": "You are logged in with a different email from this invite.",
|
"email_mismatch": "You are logged in with a different email from this invite.",
|
||||||
"expired": "This invite link has expired.",
|
"expired": "This invite link has expired.",
|
||||||
|
|||||||
@@ -475,7 +475,54 @@
|
|||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
"email": "Correo electrónico",
|
"email": "Correo electrónico",
|
||||||
"date_joined": "Fecha de incorporación",
|
"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": {
|
"community": {
|
||||||
"title": "Comunidad",
|
"title": "Comunidad",
|
||||||
@@ -940,6 +987,8 @@
|
|||||||
"complete": "completo",
|
"complete": "completo",
|
||||||
"next": "Siguiente",
|
"next": "Siguiente",
|
||||||
"prev": "Anterior",
|
"prev": "Anterior",
|
||||||
|
"prev_shortcut": "Anterior (←)",
|
||||||
|
"next_shortcut": "Siguiente (→)",
|
||||||
"disabled": "La característica está deshabilitada",
|
"disabled": "La característica está deshabilitada",
|
||||||
"version_history": {
|
"version_history": {
|
||||||
"title": "Historial de versiones",
|
"title": "Historial de versiones",
|
||||||
@@ -1200,6 +1249,9 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"accepted_answers_placeholder": "Respuestas aceptadas (separadas por comas)"
|
"accepted_answers_placeholder": "Respuestas aceptadas (separadas por comas)"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Enviado:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"numeric": {
|
"numeric": {
|
||||||
@@ -1217,6 +1269,11 @@
|
|||||||
"tolerance_info": "Diferencia permitida con respecto al valor correcto (por ejemplo, 0,5 acepta +/-0,5).",
|
"tolerance_info": "Diferencia permitida con respecto al valor correcto (por ejemplo, 0,5 acepta +/-0,5).",
|
||||||
"correct_value_placeholder": "Valor correcto",
|
"correct_value_placeholder": "Valor correcto",
|
||||||
"tolerance_placeholder": "Tolerancia"
|
"tolerance_placeholder": "Tolerancia"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Enviado:",
|
||||||
|
"in_range": "Dentro de la tolerancia",
|
||||||
|
"out_of_range": "Tolerancia exterior"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"short_answer": {
|
"short_answer": {
|
||||||
@@ -1228,6 +1285,10 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"instructions_placeholder": "Instrucciones opcionales"
|
"instructions_placeholder": "Instrucciones opcionales"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Enviado:",
|
||||||
|
"accepted_label": "Respuestas aceptadas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"true_false": {
|
"true_false": {
|
||||||
@@ -1291,8 +1352,9 @@
|
|||||||
"option_prefix": "Opción",
|
"option_prefix": "Opción",
|
||||||
"remove": "Quitar",
|
"remove": "Quitar",
|
||||||
"add_option": "Agregar opción",
|
"add_option": "Agregar opción",
|
||||||
"correct_badge": "(Correcto)",
|
"correct_badge": "Correcto",
|
||||||
"not_set": "No establecido"
|
"not_set": "No establecido",
|
||||||
|
"incorrect_badge": "Incorrecto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1473,7 +1535,10 @@
|
|||||||
"cancel": "Cancelar"
|
"cancel": "Cancelar"
|
||||||
},
|
},
|
||||||
"content_locked_title": "Este contenido está bloqueado.",
|
"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": {
|
"news_feed": {
|
||||||
"heading": "Noticias",
|
"heading": "Noticias",
|
||||||
@@ -1582,6 +1647,7 @@
|
|||||||
"unpublished": "Inédito",
|
"unpublished": "Inédito",
|
||||||
"continue_course": "Continuar",
|
"continue_course": "Continuar",
|
||||||
"learn_more": "Más información",
|
"learn_more": "Más información",
|
||||||
|
"tags_overflow_aria": "{count} etiquetas más",
|
||||||
"list_view": {
|
"list_view": {
|
||||||
"title": "Título",
|
"title": "Título",
|
||||||
"description": "Descripción",
|
"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.",
|
"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.",
|
"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.",
|
"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": {
|
"tabs": {
|
||||||
"general": "generales",
|
"general": "generales",
|
||||||
@@ -2471,7 +2538,8 @@
|
|||||||
"edit_group": "Editar grupo",
|
"edit_group": "Editar grupo",
|
||||||
"edit_tag": "Editar etiqueta",
|
"edit_tag": "Editar etiqueta",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"cancel": "Cancelar"
|
"cancel": "Cancelar",
|
||||||
|
"actions_menu": "Acciones de etiqueta"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"not_available": "N/A"
|
"not_available": "N/A"
|
||||||
@@ -2480,9 +2548,13 @@
|
|||||||
"invite": {
|
"invite": {
|
||||||
"organization": {
|
"organization": {
|
||||||
"page_title": "Únase a {orgName} en ClassroomIO",
|
"page_title": "Únase a {orgName} en ClassroomIO",
|
||||||
"role_label": "Rol: {rol}",
|
"invitation_student": "Has sido invitado a {orgName} como estudiante.",
|
||||||
"email_label": "Correo electrónico invitado: {email}",
|
"invitation_teacher": "Has sido invitado a {orgName} como profesor.",
|
||||||
"join_button": "Unirse a la organización",
|
"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": {
|
"messages": {
|
||||||
"email_mismatch": "Has iniciado sesión con un correo electrónico diferente al de esta invitación.",
|
"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.",
|
"expired": "Este enlace de invitación ha caducado.",
|
||||||
@@ -2520,6 +2592,17 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Create key"
|
"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": {
|
"keys": {
|
||||||
@@ -2534,7 +2617,11 @@
|
|||||||
"never_used": "nunca",
|
"never_used": "nunca",
|
||||||
"scopes_label": "Alcances:",
|
"scopes_label": "Alcances:",
|
||||||
"revoke_confirm": "¿Revocar esta clave de automatización?",
|
"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": {
|
"clients": {
|
||||||
"claude_code": "Código Claude",
|
"claude_code": "Código Claude",
|
||||||
|
|||||||
@@ -475,7 +475,54 @@
|
|||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"email": "Courriel",
|
"email": "Courriel",
|
||||||
"date_joined": "Date d'adhésion",
|
"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": {
|
"community": {
|
||||||
"title": "Communauté",
|
"title": "Communauté",
|
||||||
@@ -940,6 +987,8 @@
|
|||||||
"complete": "Terminé",
|
"complete": "Terminé",
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
"prev": "Précédent",
|
"prev": "Précédent",
|
||||||
|
"prev_shortcut": "Précédent (←)",
|
||||||
|
"next_shortcut": "Suivant (→)",
|
||||||
"disabled": "La fonctionnalité est désactivée",
|
"disabled": "La fonctionnalité est désactivée",
|
||||||
"version_history": {
|
"version_history": {
|
||||||
"title": "Historique des versions",
|
"title": "Historique des versions",
|
||||||
@@ -1200,6 +1249,9 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"accepted_answers_placeholder": "Réponses acceptées (séparées par des virgules)"
|
"accepted_answers_placeholder": "Réponses acceptées (séparées par des virgules)"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Soumis :"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"numeric": {
|
"numeric": {
|
||||||
@@ -1217,6 +1269,11 @@
|
|||||||
"tolerance_info": "Différence autorisée par rapport à la valeur correcte (par exemple, 0,5 accepte +/-0,5).",
|
"tolerance_info": "Différence autorisée par rapport à la valeur correcte (par exemple, 0,5 accepte +/-0,5).",
|
||||||
"correct_value_placeholder": "Valeur correcte",
|
"correct_value_placeholder": "Valeur correcte",
|
||||||
"tolerance_placeholder": "Tolérance"
|
"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": {
|
"short_answer": {
|
||||||
@@ -1228,6 +1285,10 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"instructions_placeholder": "Instructions facultatives"
|
"instructions_placeholder": "Instructions facultatives"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Soumis :",
|
||||||
|
"accepted_label": "Réponses acceptées"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"true_false": {
|
"true_false": {
|
||||||
@@ -1291,8 +1352,9 @@
|
|||||||
"option_prefix": "Options",
|
"option_prefix": "Options",
|
||||||
"remove": "Supprimer",
|
"remove": "Supprimer",
|
||||||
"add_option": "Ajouter une option",
|
"add_option": "Ajouter une option",
|
||||||
"correct_badge": "(C'est vrai)",
|
"correct_badge": "C'est vrai",
|
||||||
"not_set": "Non défini"
|
"not_set": "Non défini",
|
||||||
|
"incorrect_badge": "Incorrect"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1473,7 +1535,10 @@
|
|||||||
"cancel": "Annuler"
|
"cancel": "Annuler"
|
||||||
},
|
},
|
||||||
"content_locked_title": "Ce contenu est verrouillé",
|
"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": {
|
"news_feed": {
|
||||||
"heading": "Fil d'actualité",
|
"heading": "Fil d'actualité",
|
||||||
@@ -1582,6 +1647,7 @@
|
|||||||
"unpublished": "Inédit",
|
"unpublished": "Inédit",
|
||||||
"continue_course": "Continuer",
|
"continue_course": "Continuer",
|
||||||
"learn_more": "En savoir plus",
|
"learn_more": "En savoir plus",
|
||||||
|
"tags_overflow_aria": "{count} autres tags",
|
||||||
"list_view": {
|
"list_view": {
|
||||||
"title": "Titre",
|
"title": "Titre",
|
||||||
"description": "Descriptif",
|
"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.",
|
"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.",
|
"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.",
|
"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": {
|
"tabs": {
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
@@ -2471,7 +2538,8 @@
|
|||||||
"edit_group": "Modifier le groupe",
|
"edit_group": "Modifier le groupe",
|
||||||
"edit_tag": "Modifier la balise",
|
"edit_tag": "Modifier la balise",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"cancel": "Annuler"
|
"cancel": "Annuler",
|
||||||
|
"actions_menu": "Actions de balise"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"not_available": "N/D"
|
"not_available": "N/D"
|
||||||
@@ -2480,9 +2548,13 @@
|
|||||||
"invite": {
|
"invite": {
|
||||||
"organization": {
|
"organization": {
|
||||||
"page_title": "Rejoignez {orgName} sur ClassroomIO",
|
"page_title": "Rejoignez {orgName} sur ClassroomIO",
|
||||||
"role_label": "Rôle : {rôle}",
|
"invitation_student": "Vous avez été invité(e) dans {orgName} en tant qu'étudiant.",
|
||||||
"email_label": "E-mail invité : {email}",
|
"invitation_teacher": "Vous avez été invité(e) dans {orgName} en tant qu'enseignant.",
|
||||||
"join_button": "Rejoindre l'organisation",
|
"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": {
|
"messages": {
|
||||||
"email_mismatch": "Vous êtes connecté avec un e-mail différent de celui de cette invitation.",
|
"email_mismatch": "Vous êtes connecté avec un e-mail différent de celui de cette invitation.",
|
||||||
"expired": "Ce lien d'invitation a expiré.",
|
"expired": "Ce lien d'invitation a expiré.",
|
||||||
@@ -2520,6 +2592,17 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Create key"
|
"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": {
|
"keys": {
|
||||||
@@ -2534,7 +2617,11 @@
|
|||||||
"never_used": "Jamais",
|
"never_used": "Jamais",
|
||||||
"scopes_label": "Portées :",
|
"scopes_label": "Portées :",
|
||||||
"revoke_confirm": "Révoquer cette clé d'automatisation ?",
|
"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": {
|
"clients": {
|
||||||
"claude_code": "Claude Code",
|
"claude_code": "Claude Code",
|
||||||
|
|||||||
@@ -475,7 +475,54 @@
|
|||||||
"name": "नाम",
|
"name": "नाम",
|
||||||
"email": "ईमेल",
|
"email": "ईमेल",
|
||||||
"date_joined": "शामिल होने की तिथि",
|
"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": {
|
"community": {
|
||||||
"title": "समुदाय",
|
"title": "समुदाय",
|
||||||
@@ -940,6 +987,8 @@
|
|||||||
"complete": "पूर्ण",
|
"complete": "पूर्ण",
|
||||||
"next": "अगला",
|
"next": "अगला",
|
||||||
"prev": "पिछला",
|
"prev": "पिछला",
|
||||||
|
"prev_shortcut": "पिछला (←)",
|
||||||
|
"next_shortcut": "अगला (→)",
|
||||||
"disabled": "सुविधा अक्षम है",
|
"disabled": "सुविधा अक्षम है",
|
||||||
"version_history": {
|
"version_history": {
|
||||||
"title": "संस्करण इतिहास",
|
"title": "संस्करण इतिहास",
|
||||||
@@ -1200,6 +1249,9 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"accepted_answers_placeholder": "स्वीकृत उत्तर (अल्पविराम से अलग)"
|
"accepted_answers_placeholder": "स्वीकृत उत्तर (अल्पविराम से अलग)"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "प्रस्तुत:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"numeric": {
|
"numeric": {
|
||||||
@@ -1217,6 +1269,11 @@
|
|||||||
"tolerance_info": "सही मान से स्वीकार्य अंतर (उदाहरण के लिए, 0.5 +/-0.5 स्वीकार करता है)।",
|
"tolerance_info": "सही मान से स्वीकार्य अंतर (उदाहरण के लिए, 0.5 +/-0.5 स्वीकार करता है)।",
|
||||||
"correct_value_placeholder": "सही मूल्य",
|
"correct_value_placeholder": "सही मूल्य",
|
||||||
"tolerance_placeholder": "सहनशीलता"
|
"tolerance_placeholder": "सहनशीलता"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "प्रस्तुत:",
|
||||||
|
"in_range": "सहनशीलता के भीतर",
|
||||||
|
"out_of_range": "बाहर की सहनशीलता"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"short_answer": {
|
"short_answer": {
|
||||||
@@ -1228,6 +1285,10 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"instructions_placeholder": "वैकल्पिक निर्देश"
|
"instructions_placeholder": "वैकल्पिक निर्देश"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "प्रस्तुत:",
|
||||||
|
"accepted_label": "स्वीकृत उत्तर"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"true_false": {
|
"true_false": {
|
||||||
@@ -1291,8 +1352,9 @@
|
|||||||
"option_prefix": "विकल्प",
|
"option_prefix": "विकल्प",
|
||||||
"remove": "हटाओ",
|
"remove": "हटाओ",
|
||||||
"add_option": "विकल्प जोड़ें",
|
"add_option": "विकल्प जोड़ें",
|
||||||
"correct_badge": "(सही)",
|
"correct_badge": "सही",
|
||||||
"not_set": "सेट नहीं"
|
"not_set": "सेट नहीं",
|
||||||
|
"incorrect_badge": "गलत"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1473,7 +1535,10 @@
|
|||||||
"cancel": "रद्द करें"
|
"cancel": "रद्द करें"
|
||||||
},
|
},
|
||||||
"content_locked_title": "यह सामग्री लॉक है",
|
"content_locked_title": "यह सामग्री लॉक है",
|
||||||
"content_locked_description": "आपके प्रशिक्षक ने अभी तक इस सामग्री को अनलॉक नहीं किया है। पिछली सामग्री पूरी करें या बाद में दोबारा जाँचें।"
|
"content_locked_description": "आपके प्रशिक्षक ने अभी तक इस सामग्री को अनलॉक नहीं किया है। पिछली सामग्री पूरी करें या बाद में दोबारा जाँचें।",
|
||||||
|
"saving": "सहेजा जा रहा है...",
|
||||||
|
"saved": "सहेजा गया",
|
||||||
|
"draft_recovery": "इस पाठ के लिए एक सहेजा न गया ड्राफ्ट मिला। क्या आप इसे पुनर्स्थापित करना चाहेंगे?"
|
||||||
},
|
},
|
||||||
"news_feed": {
|
"news_feed": {
|
||||||
"heading": "समाचार फ़ीड",
|
"heading": "समाचार फ़ीड",
|
||||||
@@ -1582,6 +1647,7 @@
|
|||||||
"unpublished": "अप्रकाशित",
|
"unpublished": "अप्रकाशित",
|
||||||
"continue_course": "जारी रखें",
|
"continue_course": "जारी रखें",
|
||||||
"learn_more": "और जानें",
|
"learn_more": "और जानें",
|
||||||
|
"tags_overflow_aria": "{count} और टैग",
|
||||||
"list_view": {
|
"list_view": {
|
||||||
"title": "शीर्षक",
|
"title": "शीर्षक",
|
||||||
"description": "विवरण",
|
"description": "विवरण",
|
||||||
@@ -2245,7 +2311,8 @@
|
|||||||
"email_password_disabled_message": "इस संगठन के लिए ईमेल/पासवर्ड लॉगिन अक्षम है।",
|
"email_password_disabled_message": "इस संगठन के लिए ईमेल/पासवर्ड लॉगिन अक्षम है।",
|
||||||
"google_auth_disabled": "Google प्रमाणीकरण अक्षम है.",
|
"google_auth_disabled": "Google प्रमाणीकरण अक्षम है.",
|
||||||
"signup_disabled_error": "इस संगठन के लिए साइनअप अक्षम है. कृपया अपने व्यवस्थापक से संपर्क करें.",
|
"signup_disabled_error": "इस संगठन के लिए साइनअप अक्षम है. कृपया अपने व्यवस्थापक से संपर्क करें.",
|
||||||
"or_continue_with": "या ईमेल और पासवर्ड के साथ जारी रखें"
|
"or_continue_with": "या ईमेल और पासवर्ड के साथ जारी रखें",
|
||||||
|
"sso_enabled": "इस संगठन के लिए {प्रदाता} सक्षम है"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"general": "सामान्य",
|
"general": "सामान्य",
|
||||||
@@ -2471,7 +2538,8 @@
|
|||||||
"edit_group": "समूह संपादित करें",
|
"edit_group": "समूह संपादित करें",
|
||||||
"edit_tag": "टैग संपादित करें",
|
"edit_tag": "टैग संपादित करें",
|
||||||
"save": "सहेजें",
|
"save": "सहेजें",
|
||||||
"cancel": "रद्द करें"
|
"cancel": "रद्द करें",
|
||||||
|
"actions_menu": "क्रियाएँ टैग करें"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"not_available": "एन/ए"
|
"not_available": "एन/ए"
|
||||||
@@ -2480,9 +2548,13 @@
|
|||||||
"invite": {
|
"invite": {
|
||||||
"organization": {
|
"organization": {
|
||||||
"page_title": "ClassroomIO पर {orgName} से जुड़ें",
|
"page_title": "ClassroomIO पर {orgName} से जुड़ें",
|
||||||
"role_label": "भूमिका: {भूमिका}",
|
"invitation_student": "आपको {orgName} में छात्र के रूप में आमंत्रित किया गया है।",
|
||||||
"email_label": "आमंत्रित ईमेल: {ईमेल}",
|
"invitation_teacher": "आपको {orgName} में शिक्षक के रूप में आमंत्रित किया गया है।",
|
||||||
"join_button": "संगठन से जुड़ें",
|
"invitation_admin": "आपको {orgName} में व्यवस्थापक के रूप में आमंत्रित किया गया है।",
|
||||||
|
"invitation_fallback": "आपको {orgName} में {role} के रूप में आमंत्रित किया गया है।",
|
||||||
|
"email_line": "ईमेल: {email}",
|
||||||
|
"accept_button": "निमंत्रण स्वीकार करें",
|
||||||
|
"go_to_dashboard": "डैशबोर्ड पर जाएं",
|
||||||
"messages": {
|
"messages": {
|
||||||
"email_mismatch": "आप इस आमंत्रण से भिन्न ईमेल से लॉग इन हैं।",
|
"email_mismatch": "आप इस आमंत्रण से भिन्न ईमेल से लॉग इन हैं।",
|
||||||
"expired": "यह आमंत्रण लिंक समाप्त हो गया है.",
|
"expired": "यह आमंत्रण लिंक समाप्त हो गया है.",
|
||||||
@@ -2520,6 +2592,17 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Create key"
|
"save": "Create key"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"title": "उपयोग और सीमाएँ",
|
||||||
|
"description": "एमसीपी हर योजना पर उपलब्ध है, लेकिन उपयोग को मासिक स्वचालन क्रेडिट और प्रमुख सीमाओं के साथ नियंत्रित किया जाता है।",
|
||||||
|
"credits_title": "क्रेडिट शेष",
|
||||||
|
"credits_description": "MCP क्रियाओं को बदलने से क्रेडिट की खपत होती है। पढ़ें क्रियाएँ निःशुल्क हैं।",
|
||||||
|
"keys_title": "सक्रिय कुंजियाँ",
|
||||||
|
"keys_description": "प्रति एकीकरण एक एमसीपी कुंजी का उपयोग करें ताकि पहुंच को घुमाया जा सके या सफाई से रद्द किया जा सके।",
|
||||||
|
"rate_limits_title": "दर सीमा",
|
||||||
|
"rate_limits_description": "प्रति मिनट पढ़ने/लिखने/प्रकाशित करने के अनुरोधों की अनुमति है।",
|
||||||
|
"used_label": "प्रयुक्त:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
@@ -2534,7 +2617,11 @@
|
|||||||
"never_used": "कभी नहीं",
|
"never_used": "कभी नहीं",
|
||||||
"scopes_label": "दायरा:",
|
"scopes_label": "दायरा:",
|
||||||
"revoke_confirm": "इस स्वचालन कुंजी को निरस्त करें?",
|
"revoke_confirm": "इस स्वचालन कुंजी को निरस्त करें?",
|
||||||
"rotate_confirm": "इस स्वचालन कुंजी को घुमाएँ?"
|
"rotate_confirm": "इस स्वचालन कुंजी को घुमाएँ?",
|
||||||
|
"table_key": "कुंजी",
|
||||||
|
"table_status": "स्थिति",
|
||||||
|
"table_created": "बनाया गया",
|
||||||
|
"table_last_used": "अंतिम बार उपयोग किया गया"
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
"claude_code": "क्लाउड कोड",
|
"claude_code": "क्लाउड कोड",
|
||||||
|
|||||||
@@ -475,7 +475,54 @@
|
|||||||
"name": "Imię",
|
"name": "Imię",
|
||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
"date_joined": "Data dołączenia",
|
"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": {
|
"community": {
|
||||||
"title": "Społeczność",
|
"title": "Społeczność",
|
||||||
@@ -940,6 +987,8 @@
|
|||||||
"complete": "Kompletny",
|
"complete": "Kompletny",
|
||||||
"next": "Następny",
|
"next": "Następny",
|
||||||
"prev": "Poprzednia",
|
"prev": "Poprzednia",
|
||||||
|
"prev_shortcut": "Poprzedni (←)",
|
||||||
|
"next_shortcut": "Następny (→)",
|
||||||
"disabled": "Funkcja jest wyłączona",
|
"disabled": "Funkcja jest wyłączona",
|
||||||
"version_history": {
|
"version_history": {
|
||||||
"title": "Historia wersji",
|
"title": "Historia wersji",
|
||||||
@@ -1200,6 +1249,9 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"accepted_answers_placeholder": "Zaakceptowane odpowiedzi (oddzielone przecinkami)"
|
"accepted_answers_placeholder": "Zaakceptowane odpowiedzi (oddzielone przecinkami)"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Przesłane:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"numeric": {
|
"numeric": {
|
||||||
@@ -1217,6 +1269,11 @@
|
|||||||
"tolerance_info": "Dopuszczalna różnica od prawidłowej wartości (na przykład 0,5 akceptuje +/-0,5).",
|
"tolerance_info": "Dopuszczalna różnica od prawidłowej wartości (na przykład 0,5 akceptuje +/-0,5).",
|
||||||
"correct_value_placeholder": "Prawidłowa wartość",
|
"correct_value_placeholder": "Prawidłowa wartość",
|
||||||
"tolerance_placeholder": "Tolerancja"
|
"tolerance_placeholder": "Tolerancja"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Przesłane:",
|
||||||
|
"in_range": "W ramach tolerancji",
|
||||||
|
"out_of_range": "Tolerancja zewnętrzna"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"short_answer": {
|
"short_answer": {
|
||||||
@@ -1228,6 +1285,10 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"instructions_placeholder": "Instrukcje opcjonalne"
|
"instructions_placeholder": "Instrukcje opcjonalne"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Przesłane:",
|
||||||
|
"accepted_label": "Zaakceptowane odpowiedzi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"true_false": {
|
"true_false": {
|
||||||
@@ -1291,8 +1352,9 @@
|
|||||||
"option_prefix": "Opcja",
|
"option_prefix": "Opcja",
|
||||||
"remove": "Usuń",
|
"remove": "Usuń",
|
||||||
"add_option": "Dodaj opcję",
|
"add_option": "Dodaj opcję",
|
||||||
"correct_badge": "(Poprawnie)",
|
"correct_badge": "Poprawnie",
|
||||||
"not_set": "Nie ustawiono"
|
"not_set": "Nie ustawiono",
|
||||||
|
"incorrect_badge": "Niepoprawne"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1473,7 +1535,10 @@
|
|||||||
"cancel": "Anuluj"
|
"cancel": "Anuluj"
|
||||||
},
|
},
|
||||||
"content_locked_title": "Ta zawartość jest zablokowana",
|
"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": {
|
"news_feed": {
|
||||||
"heading": "Kanał wiadomości",
|
"heading": "Kanał wiadomości",
|
||||||
@@ -1582,6 +1647,7 @@
|
|||||||
"unpublished": "Niepublikowane",
|
"unpublished": "Niepublikowane",
|
||||||
"continue_course": "Kontynuuj",
|
"continue_course": "Kontynuuj",
|
||||||
"learn_more": "Dowiedz się więcej",
|
"learn_more": "Dowiedz się więcej",
|
||||||
|
"tags_overflow_aria": "Jeszcze {count} tagów",
|
||||||
"list_view": {
|
"list_view": {
|
||||||
"title": "Tytuł",
|
"title": "Tytuł",
|
||||||
"description": "Opis",
|
"description": "Opis",
|
||||||
@@ -2245,7 +2311,8 @@
|
|||||||
"email_password_disabled_message": "Logowanie za pomocą adresu e-mail/hasła jest wyłączone w tej organizacji.",
|
"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.",
|
"google_auth_disabled": "Uwierzytelnianie Google jest wyłączone.",
|
||||||
"signup_disabled_error": "Rejestracja w tej organizacji jest wyłączona. Skontaktuj się ze swoim administratorem.",
|
"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": {
|
"tabs": {
|
||||||
"general": "Generał",
|
"general": "Generał",
|
||||||
@@ -2471,7 +2538,8 @@
|
|||||||
"edit_group": "Edytuj grupę",
|
"edit_group": "Edytuj grupę",
|
||||||
"edit_tag": "Edytuj znacznik",
|
"edit_tag": "Edytuj znacznik",
|
||||||
"save": "Zapisz",
|
"save": "Zapisz",
|
||||||
"cancel": "Anuluj"
|
"cancel": "Anuluj",
|
||||||
|
"actions_menu": "Oznacz działania"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"not_available": "Nie dotyczy"
|
"not_available": "Nie dotyczy"
|
||||||
@@ -2480,9 +2548,13 @@
|
|||||||
"invite": {
|
"invite": {
|
||||||
"organization": {
|
"organization": {
|
||||||
"page_title": "Dołącz do organizacji {orgName} w ClassroomIO",
|
"page_title": "Dołącz do organizacji {orgName} w ClassroomIO",
|
||||||
"role_label": "Rola: {rola}",
|
"invitation_student": "Zostałeś zaproszony do {orgName} jako uczeń.",
|
||||||
"email_label": "Zaproszony e-mail: {email}",
|
"invitation_teacher": "Zostałeś zaproszony do {orgName} jako nauczyciel.",
|
||||||
"join_button": "Dołącz do organizacji",
|
"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": {
|
"messages": {
|
||||||
"email_mismatch": "Jesteś zalogowany przy użyciu innego adresu e-mail niż to zaproszenie.",
|
"email_mismatch": "Jesteś zalogowany przy użyciu innego adresu e-mail niż to zaproszenie.",
|
||||||
"expired": "Ten link z zaproszeniem wygasł.",
|
"expired": "Ten link z zaproszeniem wygasł.",
|
||||||
@@ -2520,6 +2592,17 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Create key"
|
"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": {
|
"keys": {
|
||||||
@@ -2534,7 +2617,11 @@
|
|||||||
"never_used": "Nigdy",
|
"never_used": "Nigdy",
|
||||||
"scopes_label": "Zakresy:",
|
"scopes_label": "Zakresy:",
|
||||||
"revoke_confirm": "Unieważnić ten klucz automatyzacji?",
|
"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": {
|
"clients": {
|
||||||
"claude_code": "Claude'a Koda",
|
"claude_code": "Claude'a Koda",
|
||||||
|
|||||||
@@ -475,7 +475,54 @@
|
|||||||
"name": "Nome",
|
"name": "Nome",
|
||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
"date_joined": "Data de adesão",
|
"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": {
|
"community": {
|
||||||
"title": "Comunidade",
|
"title": "Comunidade",
|
||||||
@@ -940,6 +987,8 @@
|
|||||||
"complete": "Completo",
|
"complete": "Completo",
|
||||||
"next": "Próximo",
|
"next": "Próximo",
|
||||||
"prev": "Anterior",
|
"prev": "Anterior",
|
||||||
|
"prev_shortcut": "Anterior (←)",
|
||||||
|
"next_shortcut": "Próximo (→)",
|
||||||
"disabled": "O recurso está desativado",
|
"disabled": "O recurso está desativado",
|
||||||
"version_history": {
|
"version_history": {
|
||||||
"title": "Histórico de versões",
|
"title": "Histórico de versões",
|
||||||
@@ -1200,6 +1249,9 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"accepted_answers_placeholder": "Respostas aceitas (separadas por vírgula)"
|
"accepted_answers_placeholder": "Respostas aceitas (separadas por vírgula)"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Enviado:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"numeric": {
|
"numeric": {
|
||||||
@@ -1217,6 +1269,11 @@
|
|||||||
"tolerance_info": "Diferença permitida do valor correto (por exemplo, 0,5 aceita +/-0,5).",
|
"tolerance_info": "Diferença permitida do valor correto (por exemplo, 0,5 aceita +/-0,5).",
|
||||||
"correct_value_placeholder": "Valor correto",
|
"correct_value_placeholder": "Valor correto",
|
||||||
"tolerance_placeholder": "Tolerância"
|
"tolerance_placeholder": "Tolerância"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Enviado:",
|
||||||
|
"in_range": "Dentro da tolerância",
|
||||||
|
"out_of_range": "Tolerância externa"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"short_answer": {
|
"short_answer": {
|
||||||
@@ -1228,6 +1285,10 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"instructions_placeholder": "Instruções opcionais"
|
"instructions_placeholder": "Instruções opcionais"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Enviado:",
|
||||||
|
"accepted_label": "Respostas aceitas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"true_false": {
|
"true_false": {
|
||||||
@@ -1291,8 +1352,9 @@
|
|||||||
"option_prefix": "Opção",
|
"option_prefix": "Opção",
|
||||||
"remove": "Remover",
|
"remove": "Remover",
|
||||||
"add_option": "Adicionar opção",
|
"add_option": "Adicionar opção",
|
||||||
"correct_badge": "(Correto)",
|
"correct_badge": "Correto",
|
||||||
"not_set": "Não definido"
|
"not_set": "Não definido",
|
||||||
|
"incorrect_badge": "Incorreto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1473,7 +1535,10 @@
|
|||||||
"cancel": "Cancelar"
|
"cancel": "Cancelar"
|
||||||
},
|
},
|
||||||
"content_locked_title": "Este conteúdo está bloqueado",
|
"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": {
|
"news_feed": {
|
||||||
"heading": "Feed de notícias",
|
"heading": "Feed de notícias",
|
||||||
@@ -1582,6 +1647,7 @@
|
|||||||
"unpublished": "Não publicado",
|
"unpublished": "Não publicado",
|
||||||
"continue_course": "Continuar",
|
"continue_course": "Continuar",
|
||||||
"learn_more": "Saiba mais",
|
"learn_more": "Saiba mais",
|
||||||
|
"tags_overflow_aria": "Mais {count} tags",
|
||||||
"list_view": {
|
"list_view": {
|
||||||
"title": "Título",
|
"title": "Título",
|
||||||
"description": "Descrição",
|
"description": "Descrição",
|
||||||
@@ -2245,7 +2311,8 @@
|
|||||||
"email_password_disabled_message": "O login por e-mail/senha está desativado para esta organização.",
|
"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.",
|
"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.",
|
"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": {
|
"tabs": {
|
||||||
"general": "Geral",
|
"general": "Geral",
|
||||||
@@ -2471,7 +2538,8 @@
|
|||||||
"edit_group": "Editar grupo",
|
"edit_group": "Editar grupo",
|
||||||
"edit_tag": "Editar etiqueta",
|
"edit_tag": "Editar etiqueta",
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
"cancel": "Cancelar"
|
"cancel": "Cancelar",
|
||||||
|
"actions_menu": "Ações de tags"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"not_available": "N/A"
|
"not_available": "N/A"
|
||||||
@@ -2480,9 +2548,13 @@
|
|||||||
"invite": {
|
"invite": {
|
||||||
"organization": {
|
"organization": {
|
||||||
"page_title": "Junte-se a {orgName} no ClassroomIO",
|
"page_title": "Junte-se a {orgName} no ClassroomIO",
|
||||||
"role_label": "Função: {função}",
|
"invitation_student": "Você foi convidado para {orgName} como aluno.",
|
||||||
"email_label": "E-mail convidado: {email}",
|
"invitation_teacher": "Você foi convidado para {orgName} como professor.",
|
||||||
"join_button": "Junte-se à organização",
|
"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": {
|
"messages": {
|
||||||
"email_mismatch": "Você está conectado com um e-mail diferente deste convite.",
|
"email_mismatch": "Você está conectado com um e-mail diferente deste convite.",
|
||||||
"expired": "Este link de convite expirou.",
|
"expired": "Este link de convite expirou.",
|
||||||
@@ -2520,6 +2592,17 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Create key"
|
"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": {
|
"keys": {
|
||||||
@@ -2534,7 +2617,11 @@
|
|||||||
"never_used": "Nunca",
|
"never_used": "Nunca",
|
||||||
"scopes_label": "Escopos:",
|
"scopes_label": "Escopos:",
|
||||||
"revoke_confirm": "Revogar esta chave de automação?",
|
"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": {
|
"clients": {
|
||||||
"claude_code": "Código Claude",
|
"claude_code": "Código Claude",
|
||||||
|
|||||||
@@ -475,7 +475,54 @@
|
|||||||
"name": "Имя",
|
"name": "Имя",
|
||||||
"email": "электронная почта",
|
"email": "электронная почта",
|
||||||
"date_joined": "Дата присоединения",
|
"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": {
|
"community": {
|
||||||
"title": "Сообщество",
|
"title": "Сообщество",
|
||||||
@@ -940,6 +987,8 @@
|
|||||||
"complete": "Завершить",
|
"complete": "Завершить",
|
||||||
"next": "Далее",
|
"next": "Далее",
|
||||||
"prev": "Предыдущий",
|
"prev": "Предыдущий",
|
||||||
|
"prev_shortcut": "Назад (←)",
|
||||||
|
"next_shortcut": "Далее (→)",
|
||||||
"disabled": "Функция отключена",
|
"disabled": "Функция отключена",
|
||||||
"version_history": {
|
"version_history": {
|
||||||
"title": "История версий",
|
"title": "История версий",
|
||||||
@@ -1200,6 +1249,9 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"accepted_answers_placeholder": "Принятые ответы (через запятую)"
|
"accepted_answers_placeholder": "Принятые ответы (через запятую)"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Отправлено:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"numeric": {
|
"numeric": {
|
||||||
@@ -1217,6 +1269,11 @@
|
|||||||
"tolerance_info": "Допустимое отклонение от правильного значения (например, 0,5 принимает +/-0,5).",
|
"tolerance_info": "Допустимое отклонение от правильного значения (например, 0,5 принимает +/-0,5).",
|
||||||
"correct_value_placeholder": "Правильное значение",
|
"correct_value_placeholder": "Правильное значение",
|
||||||
"tolerance_placeholder": "Толерантность"
|
"tolerance_placeholder": "Толерантность"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Отправлено:",
|
||||||
|
"in_range": "В пределах допуска",
|
||||||
|
"out_of_range": "Внешняя толерантность"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"short_answer": {
|
"short_answer": {
|
||||||
@@ -1228,6 +1285,10 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"instructions_placeholder": "Дополнительные инструкции"
|
"instructions_placeholder": "Дополнительные инструкции"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"your_answer_label": "Отправлено:",
|
||||||
|
"accepted_label": "Принятые ответы"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"true_false": {
|
"true_false": {
|
||||||
@@ -1291,8 +1352,9 @@
|
|||||||
"option_prefix": "Вариант",
|
"option_prefix": "Вариант",
|
||||||
"remove": "Удалить",
|
"remove": "Удалить",
|
||||||
"add_option": "Добавить вариант",
|
"add_option": "Добавить вариант",
|
||||||
"correct_badge": "(Правильно)",
|
"correct_badge": "Правильно",
|
||||||
"not_set": "Не установлено"
|
"not_set": "Не установлено",
|
||||||
|
"incorrect_badge": "Неправильный"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1473,7 +1535,10 @@
|
|||||||
"cancel": "Отмена"
|
"cancel": "Отмена"
|
||||||
},
|
},
|
||||||
"content_locked_title": "Этот контент заблокирован",
|
"content_locked_title": "Этот контент заблокирован",
|
||||||
"content_locked_description": "Ваш преподаватель еще не разблокировал этот контент. Завершите предыдущий контент или зайдите позже."
|
"content_locked_description": "Ваш преподаватель еще не разблокировал этот контент. Завершите предыдущий контент или зайдите позже.",
|
||||||
|
"saving": "Сохранение…",
|
||||||
|
"saved": "Сохранено",
|
||||||
|
"draft_recovery": "Для этого урока найден несохраненный черновик. Хотели бы вы восстановить его?"
|
||||||
},
|
},
|
||||||
"news_feed": {
|
"news_feed": {
|
||||||
"heading": "Лента новостей",
|
"heading": "Лента новостей",
|
||||||
@@ -1582,6 +1647,7 @@
|
|||||||
"unpublished": "Неопубликовано",
|
"unpublished": "Неопубликовано",
|
||||||
"continue_course": "Продолжить",
|
"continue_course": "Продолжить",
|
||||||
"learn_more": "Узнать больше",
|
"learn_more": "Узнать больше",
|
||||||
|
"tags_overflow_aria": "Ещё {count} тегов",
|
||||||
"list_view": {
|
"list_view": {
|
||||||
"title": "Название",
|
"title": "Название",
|
||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
@@ -2245,7 +2311,8 @@
|
|||||||
"email_password_disabled_message": "Для этой организации отключен вход по электронной почте и паролю.",
|
"email_password_disabled_message": "Для этой организации отключен вход по электронной почте и паролю.",
|
||||||
"google_auth_disabled": "Аутентификация Google отключена.",
|
"google_auth_disabled": "Аутентификация Google отключена.",
|
||||||
"signup_disabled_error": "Регистрация для этой организации отключена. Пожалуйста, свяжитесь с вашим администратором.",
|
"signup_disabled_error": "Регистрация для этой организации отключена. Пожалуйста, свяжитесь с вашим администратором.",
|
||||||
"or_continue_with": "Или продолжите, используя адрес электронной почты и пароль."
|
"or_continue_with": "Или продолжите, используя адрес электронной почты и пароль.",
|
||||||
|
"sso_enabled": "{provider} включен для этой организации"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"general": "Общий",
|
"general": "Общий",
|
||||||
@@ -2471,7 +2538,8 @@
|
|||||||
"edit_group": "Редактировать группу",
|
"edit_group": "Редактировать группу",
|
||||||
"edit_tag": "Изменить тег",
|
"edit_tag": "Изменить тег",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"cancel": "Отмена"
|
"cancel": "Отмена",
|
||||||
|
"actions_menu": "Действия с тегами"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"not_available": "Н/Д"
|
"not_available": "Н/Д"
|
||||||
@@ -2480,9 +2548,13 @@
|
|||||||
"invite": {
|
"invite": {
|
||||||
"organization": {
|
"organization": {
|
||||||
"page_title": "Присоединяйтесь к {orgName} в ClassroomIO.",
|
"page_title": "Присоединяйтесь к {orgName} в ClassroomIO.",
|
||||||
"role_label": "Роль: {роль}",
|
"invitation_student": "Вас пригласили в {orgName} в качестве ученика.",
|
||||||
"email_label": "Приглашенный адрес электронной почты: {email}",
|
"invitation_teacher": "Вас пригласили в {orgName} в качестве преподавателя.",
|
||||||
"join_button": "Присоединиться к организации",
|
"invitation_admin": "Вас пригласили в {orgName} в качестве администратора.",
|
||||||
|
"invitation_fallback": "Вас пригласили в {orgName} в качестве {role}.",
|
||||||
|
"email_line": "Электронная почта: {email}",
|
||||||
|
"accept_button": "Принять приглашение",
|
||||||
|
"go_to_dashboard": "Перейти в панель управления",
|
||||||
"messages": {
|
"messages": {
|
||||||
"email_mismatch": "Вы вошли в систему с другим адресом электронной почты, указанным в этом приглашении.",
|
"email_mismatch": "Вы вошли в систему с другим адресом электронной почты, указанным в этом приглашении.",
|
||||||
"expired": "Срок действия этой ссылки для приглашения истек.",
|
"expired": "Срок действия этой ссылки для приглашения истек.",
|
||||||
@@ -2520,6 +2592,17 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Create key"
|
"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": {
|
"keys": {
|
||||||
@@ -2534,7 +2617,11 @@
|
|||||||
"never_used": "Никогда",
|
"never_used": "Никогда",
|
||||||
"scopes_label": "Области применения:",
|
"scopes_label": "Области применения:",
|
||||||
"revoke_confirm": "Отозвать этот ключ автоматизации?",
|
"revoke_confirm": "Отозвать этот ключ автоматизации?",
|
||||||
"rotate_confirm": "Повернуть этот ключ автоматизации?"
|
"rotate_confirm": "Повернуть этот ключ автоматизации?",
|
||||||
|
"table_key": "Ключ",
|
||||||
|
"table_status": "Статус",
|
||||||
|
"table_created": "Создано",
|
||||||
|
"table_last_used": "Последнее использование"
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
"claude_code": "Клод Код",
|
"claude_code": "Клод Код",
|
||||||
|
|||||||
@@ -475,7 +475,54 @@
|
|||||||
"name": "Tên",
|
"name": "Tên",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"date_joined": "Ngày tham gia",
|
"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": {
|
"community": {
|
||||||
"title": "cộng đồng",
|
"title": "cộng đồng",
|
||||||
@@ -940,6 +987,8 @@
|
|||||||
"complete": "Hoàn thành",
|
"complete": "Hoàn thành",
|
||||||
"next": "Tiếp theo",
|
"next": "Tiếp theo",
|
||||||
"prev": "Trước đó",
|
"prev": "Trước đó",
|
||||||
|
"prev_shortcut": "Trước (←)",
|
||||||
|
"next_shortcut": "Tiếp (→)",
|
||||||
"disabled": "Tính năng bị vô hiệu hóa",
|
"disabled": "Tính năng bị vô hiệu hóa",
|
||||||
"version_history": {
|
"version_history": {
|
||||||
"title": "Lịch sử phiên bản",
|
"title": "Lịch sử phiên bản",
|
||||||
@@ -1200,6 +1249,9 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"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)"
|
"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": {
|
"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).",
|
"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",
|
"correct_value_placeholder": "Giá trị đúng",
|
||||||
"tolerance_placeholder": "Dung sai"
|
"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": {
|
"short_answer": {
|
||||||
@@ -1228,6 +1285,10 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"instructions_placeholder": "Hướng dẫn tùy chọn"
|
"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": {
|
"true_false": {
|
||||||
@@ -1291,8 +1352,9 @@
|
|||||||
"option_prefix": "Tùy chọn",
|
"option_prefix": "Tùy chọn",
|
||||||
"remove": "Xóa",
|
"remove": "Xóa",
|
||||||
"add_option": "Thêm tùy chọn",
|
"add_option": "Thêm tùy chọn",
|
||||||
"correct_badge": "(Đúng)",
|
"correct_badge": "Đúng",
|
||||||
"not_set": "Chưa đặt"
|
"not_set": "Chưa đặt",
|
||||||
|
"incorrect_badge": "sai"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1473,7 +1535,10 @@
|
|||||||
"cancel": "Hủy bỏ"
|
"cancel": "Hủy bỏ"
|
||||||
},
|
},
|
||||||
"content_locked_title": "Nội dung này đã bị khóa",
|
"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": {
|
"news_feed": {
|
||||||
"heading": "Nguồn cấp tin tức",
|
"heading": "Nguồn cấp tin tức",
|
||||||
@@ -1582,6 +1647,7 @@
|
|||||||
"unpublished": "Chưa được xuất bản",
|
"unpublished": "Chưa được xuất bản",
|
||||||
"continue_course": "Tiếp tục",
|
"continue_course": "Tiếp tục",
|
||||||
"learn_more": "Tìm hiểu thêm",
|
"learn_more": "Tìm hiểu thêm",
|
||||||
|
"tags_overflow_aria": "Thêm {count} thẻ",
|
||||||
"list_view": {
|
"list_view": {
|
||||||
"title": "Tiêu đề",
|
"title": "Tiêu đề",
|
||||||
"description": "Mô tả",
|
"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.",
|
"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.",
|
"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.",
|
"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": {
|
"tabs": {
|
||||||
"general": "chung",
|
"general": "chung",
|
||||||
@@ -2471,7 +2538,8 @@
|
|||||||
"edit_group": "Chỉnh sửa nhóm",
|
"edit_group": "Chỉnh sửa nhóm",
|
||||||
"edit_tag": "Chỉnh sửa thẻ",
|
"edit_tag": "Chỉnh sửa thẻ",
|
||||||
"save": "Lưu",
|
"save": "Lưu",
|
||||||
"cancel": "Hủy bỏ"
|
"cancel": "Hủy bỏ",
|
||||||
|
"actions_menu": "Hành động gắn thẻ"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"not_available": "không áp dụng"
|
"not_available": "không áp dụng"
|
||||||
@@ -2480,9 +2548,13 @@
|
|||||||
"invite": {
|
"invite": {
|
||||||
"organization": {
|
"organization": {
|
||||||
"page_title": "Tham gia cùng {orgName} trên ClassroomIO",
|
"page_title": "Tham gia cùng {orgName} trên ClassroomIO",
|
||||||
"role_label": "Vai trò: {vai trò}",
|
"invitation_student": "Bạn được mời vào {orgName} với vai trò học viên.",
|
||||||
"email_label": "Email được mời: {email}",
|
"invitation_teacher": "Bạn được mời vào {orgName} với vai trò giáo viên.",
|
||||||
"join_button": "Tham gia tổ chức",
|
"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": {
|
"messages": {
|
||||||
"email_mismatch": "Bạn đã đăng nhập bằng một email khác với lời mời này.",
|
"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.",
|
"expired": "Liên kết mời này đã hết hạn.",
|
||||||
@@ -2520,6 +2592,17 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Create key"
|
"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": {
|
"keys": {
|
||||||
@@ -2534,7 +2617,11 @@
|
|||||||
"never_used": "không bao giờ",
|
"never_used": "không bao giờ",
|
||||||
"scopes_label": "Phạm vi:",
|
"scopes_label": "Phạm vi:",
|
||||||
"revoke_confirm": "Thu hồi khóa tự động hóa này?",
|
"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": {
|
"clients": {
|
||||||
"claude_code": "Mã Claude",
|
"claude_code": "Mã Claude",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
// Invite context: allow signup when invite_token present or redirect contains invite info
|
// Invite context: allow signup when invite_token present or redirect contains invite info
|
||||||
const hasInviteContext = $derived(
|
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);
|
const inviteOnly = $derived(!!org?.settings?.signup?.inviteOnly);
|
||||||
|
|||||||
@@ -86,7 +86,14 @@
|
|||||||
return;
|
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)) {
|
if (isPublicRoute(path) && (path !== '/' || data.isOrgSite)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-7
@@ -11,6 +11,7 @@
|
|||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import { ROLE } from '@cio/utils/constants';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -25,6 +26,9 @@
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
const canJoinOrganization = $derived(inviteStatus === 'ACTIVE' && !isInviteEmailMismatch);
|
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 {
|
function buildInviteAuthParams(pathname: string, email: string): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
@@ -35,6 +39,9 @@
|
|||||||
|
|
||||||
const loginParams = $derived(buildInviteAuthParams(page.url?.pathname || '', data.invite?.invite?.email || ''));
|
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 {
|
function getBlockedInviteMessage(): string {
|
||||||
if (isInviteEmailMismatch) {
|
if (isInviteEmailMismatch) {
|
||||||
return t.get('invite.organization.messages.email_mismatch');
|
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 () => {
|
onMount(async () => {
|
||||||
if (!data.currentOrg) return;
|
if (!data.currentOrg) return;
|
||||||
|
|
||||||
@@ -110,23 +124,43 @@
|
|||||||
|
|
||||||
<AuthUI isLogin={false} {handleSubmit} isLoading={loading} showOnlyContent={true} showLogo={true}>
|
<AuthUI isLogin={false} {handleSubmit} isLoading={loading} showOnlyContent={true} showLogo={true}>
|
||||||
<div class="mt-0 w-full">
|
<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">
|
<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>
|
||||||
<p class="mt-1 text-center text-sm font-light dark:text-white">
|
<p class="mt-3 text-center text-sm font-light dark:text-white">
|
||||||
{$t('invite.organization.email_label', { email: data.invite.invite.email })}
|
{$t('invite.organization.email_line', { email: data.invite.invite.email })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if !canJoinOrganization}
|
{#if !canJoinOrganization && !isAlreadyJoined}
|
||||||
<p class="mt-3 text-center text-sm text-red-500">{getBlockedInviteMessage()}</p>
|
<p class="mt-3 text-center text-sm text-red-500">{getBlockedInviteMessage()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4 flex w-full flex-col items-center justify-center gap-3">
|
<div class="my-4 flex w-full flex-col items-center justify-center gap-3">
|
||||||
<Button type="submit" disabled={!canJoinOrganization || loading} {loading}>
|
{#if isAlreadyJoined}
|
||||||
{$t('invite.organization.join_button')}
|
<Button type="button" onclick={handleNavigateAfterJoined}>
|
||||||
|
{#if inviteRoleId === ROLE.STUDENT}
|
||||||
|
{$t('navigation.goto_lms')}
|
||||||
|
{:else}
|
||||||
|
{$t('invite.organization.go_to_dashboard')}
|
||||||
|
{/if}
|
||||||
</Button>
|
</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">
|
<p class="ui:text-muted-foreground text-center text-sm">
|
||||||
{$t('login.already_have_account')}
|
{$t('login.already_have_account')}
|
||||||
<a class="ui:text-primary hover:underline" href={resolve(`/login?${loginParams}`, {})}> {$t('login.login')}</a>
|
<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 { CommunityQuestionPage } from '$features/community/pages';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
const communityPath = $derived(page.url.pathname.replace(/\/[^/]+$/, ''));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Question - ClassroomIO</title>
|
<title>Question - ClassroomIO</title>
|
||||||
</svelte:head>
|
</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 { 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 * 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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -11,6 +16,7 @@
|
|||||||
<Page.Root class="w-full">
|
<Page.Root class="w-full">
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
|
<BackButton href={resolve(communityPath, {})} label={$t('community.ask.go_back')} />
|
||||||
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
|
|
||||||
import { CreateCourseButton } from '$features/course/components';
|
import { CreateCourseButton } from '$features/course/components';
|
||||||
import * as Avatar from '@cio/ui/base/avatar';
|
import * as Avatar from '@cio/ui/base/avatar';
|
||||||
import { shortenName } from '$lib/utils/functions/string';
|
|
||||||
import { Progress } from '@cio/ui/base/progress';
|
import { Progress } from '@cio/ui/base/progress';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { WelcomeModal } from '$features/onboarding/components';
|
import { WelcomeModal } from '$features/onboarding/components';
|
||||||
@@ -148,11 +147,12 @@
|
|||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Avatar.Root class="h-6 w-6">
|
<Avatar.Root class="h-6 w-6">
|
||||||
<Avatar.Image
|
{#if enrollment.avatarUrl}
|
||||||
src={enrollment.avatarUrl ? enrollment.avatarUrl : '/logo-192.png'}
|
<Avatar.Image src={enrollment.avatarUrl} alt={enrollment.name ? enrollment.name : 'User'} />
|
||||||
alt={enrollment.name ? enrollment.name : 'User'}
|
{/if}
|
||||||
/>
|
<Avatar.Fallback>
|
||||||
<Avatar.Fallback>{shortenName(enrollment.name) || 'U'}</Avatar.Fallback>
|
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||||
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
|
|
||||||
<div class="min-h-[45px] space-y-1">
|
<div class="min-h-[45px] space-y-1">
|
||||||
|
|||||||
@@ -5,17 +5,26 @@ export const load = async ({ parent, cookies }) => {
|
|||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return {
|
return {
|
||||||
audience: []
|
audience: [],
|
||||||
|
courses: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch organization audience
|
const headers = getApiHeaders(cookies, orgId);
|
||||||
const audienceResponse = await classroomio.organization.audience.$get({}, 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 audienceData = await audienceResponse.json();
|
||||||
const audience = audienceData.success ? audienceData.data : [];
|
const audience = audienceData.success ? audienceData.data : [];
|
||||||
|
|
||||||
|
const coursesData = await coursesResponse.json();
|
||||||
|
const courses = coursesData.success ? coursesData.data : [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
audience
|
audience,
|
||||||
|
courses
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import { currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
|
import { currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
|
||||||
import { PLAN } from '@cio/utils/plans';
|
import { PLAN } from '@cio/utils/plans';
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
@@ -35,14 +37,17 @@
|
|||||||
</Page.Title>
|
</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
<Page.Action>
|
||||||
<Button onclick={exportAudience} disabled={isLoading} loading={isLoading}>
|
<Button variant="outline" onclick={exportAudience} disabled={isLoading} loading={isLoading}>
|
||||||
{$t('audience.export')}
|
{$t('audience.export')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="secondary" href={resolve(`${page.url.pathname}/import`, {})}>
|
||||||
|
{$t('audience.import_users')}
|
||||||
|
</Button>
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
<AudiencePage audience={data.audience} />
|
<AudiencePage audience={data.audience} courses={data.courses} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
</Page.Root>
|
</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 { CommunityQuestionPage } from '$features/community/pages';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
const communityPath = $derived(page.url.pathname.replace(/\/[^/]+$/, ''));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Question - ClassroomIO</title>
|
<title>Question - ClassroomIO</title>
|
||||||
</svelte:head>
|
</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 { 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 * 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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -11,6 +16,7 @@
|
|||||||
<Page.Root class="w-full">
|
<Page.Root class="w-full">
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
|
<BackButton href={resolve(communityPath, {})} label={$t('community.ask.go_back')} />
|
||||||
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|||||||
@@ -2,24 +2,33 @@
|
|||||||
import { CoursesPage } from '$features/course/pages';
|
import { CoursesPage } from '$features/course/pages';
|
||||||
import { CreateCourseButton, CourseFilterPopover } from '$features/course/components';
|
import { CreateCourseButton, CourseFilterPopover } from '$features/course/components';
|
||||||
import { courseMetaDeta } from '$features/course/utils/store';
|
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 { browser } from '$app/environment';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import { coursesApi } from '$features/course/api';
|
import { coursesApi } from '$features/course/api';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
const getInitialSelectedTags = () => data.activeTags ?? [];
|
||||||
|
|
||||||
let searchValue = $state('');
|
let searchValue = $state('');
|
||||||
let selectedId: string = $state('0');
|
let sortKey: CourseSortBy = $state(DEFAULT_COURSE_SORT);
|
||||||
let selectedOrder = $state<'asc' | 'desc'>('desc');
|
let selectedOrder = $state<CourseSortOrder>(DEFAULT_SORT_ORDER);
|
||||||
let selectedTags = $state<string[]>([]);
|
|
||||||
let hasInitializedFilters = $state(false);
|
|
||||||
let isFiltering = $state(false);
|
|
||||||
|
|
||||||
const validSortValues = new Set(COURSE_SORT_OPTIONS.map((option) => option.value));
|
let selectedTags = $state<string[]>(getInitialSelectedTags());
|
||||||
const validSortOrders = new Set(['asc', 'desc']);
|
|
||||||
|
let hasInitializedFilters = $state(false);
|
||||||
|
let hasCompletedInitialUrlSync = false;
|
||||||
|
let isFiltering = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (data.courses) {
|
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() {
|
function updateFiltersUrl() {
|
||||||
if (!browser || !hasInitializedFilters) {
|
if (!browser || !hasInitializedFilters) {
|
||||||
return;
|
return;
|
||||||
@@ -73,13 +49,13 @@
|
|||||||
nextUrl.searchParams.delete('tags');
|
nextUrl.searchParams.delete('tags');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedId !== '0') {
|
if (sortKey !== DEFAULT_COURSE_SORT) {
|
||||||
nextUrl.searchParams.set('sort', selectedId);
|
nextUrl.searchParams.set('sort', sortKey);
|
||||||
} else {
|
} else {
|
||||||
nextUrl.searchParams.delete('sort');
|
nextUrl.searchParams.delete('sort');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedOrder !== 'desc') {
|
if (selectedOrder !== DEFAULT_SORT_ORDER) {
|
||||||
nextUrl.searchParams.set('order', selectedOrder);
|
nextUrl.searchParams.set('order', selectedOrder);
|
||||||
} else {
|
} else {
|
||||||
nextUrl.searchParams.delete('order');
|
nextUrl.searchParams.delete('order');
|
||||||
@@ -94,8 +70,14 @@
|
|||||||
return;
|
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);
|
localStorage.setItem('classroomio_filter_course_order_key', selectedOrder);
|
||||||
|
|
||||||
|
if (!hasCompletedInitialUrlSync) {
|
||||||
|
hasCompletedInitialUrlSync = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateFiltersUrl();
|
updateFiltersUrl();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,7 +93,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleTag(tagSlug: string, checked: boolean) {
|
function toggleTag(tagSlug: string, checked: boolean) {
|
||||||
const next = new Set(selectedTags);
|
const next = new SvelteSet(selectedTags);
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
next.add(tagSlug);
|
next.add(tagSlug);
|
||||||
@@ -123,8 +105,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearFilters() {
|
async function clearFilters() {
|
||||||
selectedId = '0';
|
sortKey = DEFAULT_COURSE_SORT;
|
||||||
selectedOrder = 'desc';
|
selectedOrder = DEFAULT_SORT_ORDER;
|
||||||
|
|
||||||
if (selectedTags.length === 0) {
|
if (selectedTags.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -144,21 +126,21 @@
|
|||||||
|
|
||||||
const sortedCourses = [...filteredCourses];
|
const sortedCourses = [...filteredCourses];
|
||||||
|
|
||||||
if (selectedId === '0') {
|
if (sortKey === CourseSortBy.DateCreated) {
|
||||||
return sortedCourses.sort((a, b) =>
|
return sortedCourses.sort((a, b) =>
|
||||||
selectedOrder === 'asc'
|
selectedOrder === CourseSortOrder.Asc
|
||||||
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
? new Date(a.createdAt ?? '').getTime() - new Date(b.createdAt ?? '').getTime()
|
||||||
: new Date(b.createdAt).getTime() - new Date(a.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) =>
|
return sortedCourses.sort((a, b) =>
|
||||||
selectedOrder === 'asc'
|
selectedOrder === CourseSortOrder.Asc
|
||||||
? Number(a.isPublished) - Number(b.isPublished)
|
? Number(a.isPublished) - Number(b.isPublished)
|
||||||
: Number(b.isPublished) - Number(a.isPublished)
|
: Number(b.isPublished) - Number(a.isPublished)
|
||||||
);
|
);
|
||||||
} else if (selectedId === '2') {
|
} else if (sortKey === CourseSortBy.Lessons) {
|
||||||
return sortedCourses.sort((a, b) =>
|
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;
|
$courseMetaDeta.view = courseView;
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const sortFromStorage = parseCourseSortValue(localStorage.getItem('classroomio_filter_course_sort_key'));
|
||||||
const legacySortFromUrl = getLegacySortState(searchParams.get('sort'));
|
const orderFromStorage = parseCourseSortOrder(localStorage.getItem('classroomio_filter_course_order_key'));
|
||||||
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'));
|
|
||||||
|
|
||||||
if (legacySortFromUrl) {
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
selectedId = legacySortFromUrl.selectedId;
|
const sortFromUrl = parseCourseSortValue(searchParams.get('sort'));
|
||||||
selectedOrder = legacySortFromUrl.selectedOrder;
|
const orderFromUrl = parseCourseSortOrder(searchParams.get('order'));
|
||||||
} else if (sortFromUrl !== '0' || orderFromUrl !== 'desc') {
|
|
||||||
selectedId = sortFromUrl;
|
if (sortFromUrl !== DEFAULT_COURSE_SORT || orderFromUrl !== DEFAULT_SORT_ORDER) {
|
||||||
|
sortKey = sortFromUrl;
|
||||||
selectedOrder = orderFromUrl;
|
selectedOrder = orderFromUrl;
|
||||||
} else if (legacySortFromStorage) {
|
} else if (sortFromStorage !== DEFAULT_COURSE_SORT || orderFromStorage !== DEFAULT_SORT_ORDER) {
|
||||||
selectedId = legacySortFromStorage.selectedId;
|
sortKey = sortFromStorage;
|
||||||
selectedOrder = legacySortFromStorage.selectedOrder;
|
|
||||||
} else if (sortFromStorage !== '0' || orderFromStorage !== 'desc') {
|
|
||||||
selectedId = sortFromStorage;
|
|
||||||
selectedOrder = orderFromStorage;
|
selectedOrder = orderFromStorage;
|
||||||
} else {
|
|
||||||
selectedId = oldSortFromStorage;
|
|
||||||
selectedOrder = 'desc';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasInitializedFilters = true;
|
hasInitializedFilters = true;
|
||||||
updateFiltersUrl();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -218,10 +188,10 @@
|
|||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
<CoursesPage courses={filteredCourses} bind:searchValue bind:selectedId showSortSelect={false}>
|
<CoursesPage courses={filteredCourses} bind:searchValue bind:sortKey showSortSelect={false}>
|
||||||
{#snippet filterControls()}
|
{#snippet filterControls()}
|
||||||
<CourseFilterPopover
|
<CourseFilterPopover
|
||||||
bind:selectedId
|
bind:sortKey
|
||||||
bind:selectedOrder
|
bind:selectedOrder
|
||||||
{selectedTags}
|
{selectedTags}
|
||||||
tagGroups={data.tagGroups}
|
tagGroups={data.tagGroups}
|
||||||
|
|||||||
@@ -66,12 +66,13 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
|||||||
user: {
|
user: {
|
||||||
create: {
|
create: {
|
||||||
after: async (user) => {
|
after: async (user) => {
|
||||||
|
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id });
|
||||||
await createProfileHook(user);
|
await createProfileHook(user);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
after: async (user) => {
|
after: async (user) => {
|
||||||
console.log('update user', user);
|
console.log('[auth] databaseHooks.user.update.after: running', { userId: user.id });
|
||||||
await syncUserWithProfile(user);
|
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).
|
* Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org).
|
||||||
*/
|
*/
|
||||||
export const createProfileHook = async (user: User) => {
|
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);
|
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
|
||||||
console.debug('existingProfile', existingProfile);
|
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
|
* This function will be called by better-auth after each request
|
||||||
*/
|
*/
|
||||||
export const combineAfterHooks = async (context: AuthMiddlewareContext) => {
|
export const combineAfterHooks = async (context: AuthMiddlewareContext) => {
|
||||||
|
console.log('[auth] combineAfterHooks: running');
|
||||||
for (const hook of afterHooks) {
|
for (const hook of afterHooks) {
|
||||||
try {
|
try {
|
||||||
await hook(context);
|
await hook(context);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import * as schema from '@db/schema';
|
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 { User } from 'better-auth';
|
||||||
import { db } from '@db/drizzle';
|
import { db } from '@db/drizzle';
|
||||||
|
import { enrollUsersInCourseGroups } from '@db/queries/group';
|
||||||
|
import { getOrgCourseGroups } from '@db/queries/course';
|
||||||
import { markUserAndProfileEmailVerified } from '@db/queries/auth';
|
import { markUserAndProfileEmailVerified } from '@db/queries/auth';
|
||||||
|
|
||||||
const DEFAULT_STUDENT_ROLE_ID = 3;
|
const DEFAULT_STUDENT_ROLE_ID = 3;
|
||||||
@@ -21,13 +23,25 @@ export async function ensureOrgMembership(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const emailLower = email.toLowerCase();
|
const emailLower = email.toLowerCase();
|
||||||
|
|
||||||
const [existing] = await db
|
const [existingByProfile] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.organizationmember)
|
.from(schema.organizationmember)
|
||||||
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.profileId, userId)))
|
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.profileId, userId)))
|
||||||
.limit(1);
|
.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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,13 +52,25 @@ export async function ensureOrgMembership(
|
|||||||
and(
|
and(
|
||||||
eq(schema.organizationInvite.organizationId, orgId),
|
eq(schema.organizationInvite.organizationId, orgId),
|
||||||
eq(schema.organizationInvite.email, emailLower),
|
eq(schema.organizationInvite.email, emailLower),
|
||||||
eq(schema.organizationInvite.isRevoked, false)
|
eq(schema.organizationInvite.isRevoked, false),
|
||||||
|
isNull(schema.organizationInvite.acceptedAt)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (invite) {
|
if (invite) {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
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({
|
await tx.insert(schema.organizationmember).values({
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
profileId: userId,
|
profileId: userId,
|
||||||
@@ -52,6 +78,7 @@ export async function ensureOrgMembership(
|
|||||||
roleId: invite.roleId,
|
roleId: invite.roleId,
|
||||||
verified: true
|
verified: true
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await tx
|
await tx
|
||||||
.update(schema.organizationInvite)
|
.update(schema.organizationInvite)
|
||||||
@@ -65,10 +92,46 @@ export async function ensureOrgMembership(
|
|||||||
await markUserAndProfileEmailVerified(userId, tx);
|
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);
|
console.debug('User joined org via invite:', orgId);
|
||||||
return;
|
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
|
const [policy] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.organizationAuthPolicy)
|
.from(schema.organizationAuthPolicy)
|
||||||
@@ -94,6 +157,7 @@ export async function ensureOrgMembership(
|
|||||||
* This runs after profile creation and creates organization membership
|
* This runs after profile creation and creates organization membership
|
||||||
*/
|
*/
|
||||||
export const ssoProvisioningHook = async (user: User) => {
|
export const ssoProvisioningHook = async (user: User) => {
|
||||||
|
console.log('[auth] ssoProvisioningHook: running', { userId: user.id });
|
||||||
if (!user.email) {
|
if (!user.email) {
|
||||||
console.debug('No email for user, skipping SSO provisioning');
|
console.debug('No email for user, skipping SSO provisioning');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { hasVerifiedEmailProvider } from './create-profile';
|
import { hasVerifiedEmailProvider } from './create-profile';
|
||||||
|
|
||||||
export const syncUserWithProfile = async (user: User) => {
|
export const syncUserWithProfile = async (user: User) => {
|
||||||
console.debug('syncUserWithProfile', user);
|
console.log('[auth] syncUserWithProfile: running', { userId: user?.id });
|
||||||
|
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
return;
|
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,
|
"when": 1771176910627,
|
||||||
"tag": "0006_modern_the_initiative",
|
"tag": "0006_modern_the_initiative",
|
||||||
"breakpoints": true
|
"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 type { TProfile } from '@db/types';
|
||||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
export const getProfileById = async (id: string) => {
|
export const getProfileById = async (id: string) => {
|
||||||
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1);
|
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;
|
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'>>) => {
|
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();
|
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