├── .brandrc.js.example ├── .circleci ├── config.yml └── setup-env.sh ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .nvmrc ├── .snyk ├── LICENSE ├── README.md ├── build ├── package.json └── plugins │ └── vite │ └── html-replace.js ├── bunyan-pretty.config.js ├── client ├── App.vue ├── OidcClient.js ├── api │ ├── activity.js │ ├── asset.js │ ├── auth.js │ ├── contentElement.js │ ├── feed.js │ ├── helpers.js │ ├── index.js │ ├── repository.js │ ├── request.js │ ├── revision.js │ ├── tag.js │ └── user.js ├── assets │ ├── img │ │ ├── default-favicon.ico │ │ ├── default-logo-compact.png │ │ ├── default-logo-compact.svg │ │ └── default-logo-full.svg │ ├── locales │ │ └── timeago-en-US-short.json │ └── stylesheets │ │ ├── common │ │ ├── _all.scss │ │ ├── _buttons.scss │ │ ├── _chips.scss │ │ ├── _errors.scss │ │ ├── _inputs.scss │ │ ├── _layouts.scss │ │ ├── _modals.scss │ │ ├── _transitions.scss │ │ ├── _variables.scss │ │ ├── _vuetify.scss │ │ └── assessments │ │ │ ├── _all.scss │ │ │ └── _buttons.scss │ │ └── main.scss ├── components │ ├── auth │ │ ├── Container.vue │ │ ├── ForgotPassword.vue │ │ ├── Login.vue │ │ └── ResetPassword.vue │ ├── catalog │ │ ├── Add.vue │ │ ├── Card │ │ │ ├── Tags │ │ │ │ ├── AddTag.vue │ │ │ │ └── index.vue │ │ │ └── index.vue │ │ ├── Container.vue │ │ ├── RepositoryFilter.vue │ │ ├── RepositoryFilterSelection │ │ │ ├── SelectedFilter.vue │ │ │ └── index.vue │ │ ├── Search.vue │ │ ├── SelectOrder.vue │ │ └── repositoryFilterConfigs.js │ ├── common │ │ ├── CircularProgress.vue │ │ ├── ConfirmationModal.vue │ │ ├── EditorField.vue │ │ ├── Footer.vue │ │ ├── MetaInput.vue │ │ ├── Navbar.vue │ │ ├── ProgressDialog.vue │ │ ├── TailorDialog.vue │ │ ├── Waves.vue │ │ └── mixins │ │ │ ├── commentEventListeners.js │ │ │ ├── deprecation.js │ │ │ ├── publish.vue │ │ │ └── userTracking.js │ ├── content-containers │ │ ├── tcc-assessment-pool │ │ │ ├── edit │ │ │ │ └── index.vue │ │ │ ├── index.js │ │ │ ├── info.js │ │ │ └── util.js │ │ ├── tcc-default │ │ │ ├── edit │ │ │ │ └── index.vue │ │ │ └── index.js │ │ └── tcc-exam │ │ │ ├── edit │ │ │ ├── Assessment.vue │ │ │ ├── AssessmentGroup.vue │ │ │ ├── GroupIntroduction.vue │ │ │ └── index.vue │ │ │ ├── index.js │ │ │ ├── info.js │ │ │ └── util.js │ ├── content-elements │ │ ├── tce-accordion │ │ │ ├── edit │ │ │ │ ├── AccordionItem.vue │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-audio │ │ │ ├── edit │ │ │ │ ├── Toolbar.vue │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-brightcove-video │ │ │ ├── edit │ │ │ │ ├── Player.vue │ │ │ │ ├── Toolbar.vue │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-carousel │ │ │ ├── edit │ │ │ │ ├── CarouselItem.vue │ │ │ │ ├── Toolbar.vue │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-drag-drop │ │ │ ├── edit │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-embed │ │ │ ├── edit │ │ │ │ ├── Toolbar.vue │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-fill-blank │ │ │ ├── edit │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-html │ │ │ ├── edit │ │ │ │ ├── Toolbar.vue │ │ │ │ ├── index.vue │ │ │ │ └── theme │ │ │ │ │ ├── index.js │ │ │ │ │ ├── modules │ │ │ │ │ └── image-embed.js │ │ │ │ │ ├── toolbar-icons.js │ │ │ │ │ └── ui │ │ │ │ │ ├── color-picker.js │ │ │ │ │ ├── color-picker.scss │ │ │ │ │ ├── image-embed-tooltip.js │ │ │ │ │ ├── image-embed-tooltip.scss │ │ │ │ │ └── tooltip.js │ │ │ └── index.js │ │ ├── tce-image │ │ │ ├── edit │ │ │ │ ├── Cropper.vue │ │ │ │ ├── Toolbar.vue │ │ │ │ ├── UploadBtn.vue │ │ │ │ └── index.vue │ │ │ ├── index.js │ │ │ ├── info.js │ │ │ └── server │ │ │ │ └── index.js │ │ ├── tce-jodit │ │ │ └── index.js │ │ ├── tce-matching-question │ │ │ ├── edit │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-modal │ │ │ ├── edit │ │ │ │ ├── Preview.vue │ │ │ │ ├── Toolbar.vue │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-multiple-choice │ │ │ ├── edit │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-numerical-response │ │ │ ├── edit │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-page-break │ │ │ ├── edit │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-pdf │ │ │ ├── edit │ │ │ │ ├── CircularProgress.vue │ │ │ │ ├── Toolbar.vue │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-scorm │ │ │ ├── index.js │ │ │ └── server │ │ │ │ └── index.js │ │ ├── tce-single-choice │ │ │ ├── edit │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-table │ │ │ ├── edit │ │ │ │ ├── TableCell.vue │ │ │ │ ├── Toolbar.vue │ │ │ │ ├── index.vue │ │ │ │ └── utils.js │ │ │ └── index.js │ │ ├── tce-text-response │ │ │ ├── edit │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── tce-true-false │ │ │ ├── edit │ │ │ │ └── index.vue │ │ │ └── index.js │ │ └── tce-video │ │ │ ├── edit │ │ │ ├── Toolbar.vue │ │ │ └── index.vue │ │ │ └── index.js │ ├── editor │ │ ├── ActivityContent │ │ │ ├── ContainerList.vue │ │ │ ├── Loader.vue │ │ │ ├── PublishDiffProvider.vue │ │ │ └── index.vue │ │ ├── Sidebar │ │ │ ├── ElementSidebar │ │ │ │ ├── ElementMeta │ │ │ │ │ ├── Inputs.vue │ │ │ │ │ ├── Relationships │ │ │ │ │ │ ├── Item.vue │ │ │ │ │ │ └── index.vue │ │ │ │ │ └── index.vue │ │ │ │ └── index.vue │ │ │ ├── Navigation.vue │ │ │ └── index.vue │ │ ├── Toolbar │ │ │ ├── ActivityActions.vue │ │ │ ├── DefaultToolbar.vue │ │ │ ├── ElementToolbar.vue │ │ │ └── index.vue │ │ └── index.vue │ ├── meta-inputs │ │ ├── meta-checkbox │ │ │ ├── Edit.vue │ │ │ └── index.js │ │ ├── meta-color │ │ │ ├── Edit │ │ │ │ ├── ColorInput.vue │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── meta-combobox │ │ │ ├── Edit.vue │ │ │ └── index.js │ │ ├── meta-datetime │ │ │ ├── Edit │ │ │ │ ├── TimePicker.vue │ │ │ │ └── index.vue │ │ │ └── index.js │ │ ├── meta-file │ │ │ ├── Edit.vue │ │ │ └── index.js │ │ ├── meta-html │ │ │ ├── Edit.vue │ │ │ └── index.js │ │ ├── meta-input │ │ │ ├── Edit.vue │ │ │ └── index.js │ │ ├── meta-select │ │ │ ├── Edit.vue │ │ │ └── index.js │ │ ├── meta-switch │ │ │ ├── Edit.vue │ │ │ └── index.js │ │ └── meta-textarea │ │ │ ├── Edit.vue │ │ │ └── index.js │ ├── repository │ │ ├── Outline │ │ │ ├── Activity.vue │ │ │ ├── OutlineFooter.vue │ │ │ ├── SearchResult.vue │ │ │ ├── Toolbar.vue │ │ │ ├── TreeView │ │ │ │ ├── TreeGraph.vue │ │ │ │ └── index.vue │ │ │ ├── icons │ │ │ │ ├── AddAbove.vue │ │ │ │ ├── AddBelow.vue │ │ │ │ ├── AddInto.vue │ │ │ │ └── index.js │ │ │ ├── index.vue │ │ │ └── reorderMixin.js │ │ ├── Revisions │ │ │ ├── EntityRevisions.vue │ │ │ ├── EntitySidebar.vue │ │ │ ├── RevisionItem.vue │ │ │ └── index.vue │ │ ├── Settings │ │ │ ├── CloneModal.vue │ │ │ ├── ExportModal.vue │ │ │ ├── General.vue │ │ │ ├── Sidebar.vue │ │ │ ├── UserManagement │ │ │ │ ├── AddUserDialog.vue │ │ │ │ ├── UserList.vue │ │ │ │ └── index.vue │ │ │ └── index.vue │ │ ├── Workflow │ │ │ ├── Filters │ │ │ │ ├── Assignee.vue │ │ │ │ └── index.vue │ │ │ ├── Overview │ │ │ │ ├── Assignee.vue │ │ │ │ ├── DueDate.vue │ │ │ │ ├── Name.vue │ │ │ │ ├── Priority.vue │ │ │ │ ├── Status.vue │ │ │ │ └── index.vue │ │ │ ├── SelectStatus.vue │ │ │ ├── Sidebar │ │ │ │ ├── ActivityCard.vue │ │ │ │ ├── FieldGroup.vue │ │ │ │ ├── Header.vue │ │ │ │ └── index.vue │ │ │ ├── icons │ │ │ │ ├── PriorityCritical.vue │ │ │ │ ├── PriorityHigh.vue │ │ │ │ ├── PriorityLow.vue │ │ │ │ ├── PriorityMedium.vue │ │ │ │ ├── PriorityTrivial.vue │ │ │ │ └── index.js │ │ │ └── index.vue │ │ ├── common │ │ │ ├── ActivityDiscussion.vue │ │ │ ├── ActivityOptions │ │ │ │ ├── Menu.vue │ │ │ │ ├── Toolbar.vue │ │ │ │ └── common.js │ │ │ ├── AssigneeAvatar.vue │ │ │ ├── CopyActivity │ │ │ │ ├── RepositoryTree.vue │ │ │ │ └── index.vue │ │ │ ├── CreateDialog │ │ │ │ ├── TypeSelect.vue │ │ │ │ └── index.vue │ │ │ ├── LabelChip.vue │ │ │ ├── RepositoryNameField.vue │ │ │ ├── SelectPriority.vue │ │ │ ├── Sidebar │ │ │ │ ├── Badge.vue │ │ │ │ ├── Body.vue │ │ │ │ ├── Header.vue │ │ │ │ ├── Publishing.vue │ │ │ │ ├── Relationship.vue │ │ │ │ ├── Status │ │ │ │ │ └── index.vue │ │ │ │ └── index.vue │ │ │ ├── WorkflowDueDate.vue │ │ │ └── selectActivity.js │ │ └── index.vue │ ├── system-settings │ │ ├── ContentElements.vue │ │ ├── Sidebar.vue │ │ ├── StructureTypes.vue │ │ ├── UserManagement │ │ │ ├── UserDialog.vue │ │ │ └── index.vue │ │ └── index.vue │ └── user-settings │ │ ├── Avatar │ │ ├── AvatarDialog.vue │ │ └── index.vue │ │ ├── ChangePasswordDialog.vue │ │ ├── Info.vue │ │ └── index.vue ├── content-plugins │ ├── ComponentRegistry.js │ ├── ContainerRegistry.js │ ├── ElementRegistry.js │ ├── MetaRegistry.js │ ├── index.js │ └── validation │ │ ├── ValidationService.js │ │ ├── index.js │ │ └── types.js ├── directives │ └── file-filter.js ├── filters.js ├── index.html ├── main.js ├── package.json ├── plugins │ ├── vuetify-snackbar │ │ ├── Snackbar.vue │ │ └── index.js │ └── vuetify.js ├── polyfills.js ├── router.js ├── settings.js ├── sse.js ├── store │ ├── helpers │ │ ├── actions.js │ │ ├── mutations.js │ │ └── resource.js │ ├── index.js │ ├── modules │ │ ├── auth │ │ │ ├── actions.js │ │ │ ├── getters.js │ │ │ ├── index.js │ │ │ └── mutations.js │ │ ├── editor │ │ │ ├── getters.js │ │ │ ├── index.js │ │ │ └── mutations.js │ │ ├── repositories │ │ │ ├── actions.js │ │ │ ├── getters.js │ │ │ ├── index.js │ │ │ └── mutations.js │ │ └── repository │ │ │ ├── actions.js │ │ │ ├── activities │ │ │ ├── actions.js │ │ │ ├── getters.js │ │ │ ├── index.js │ │ │ └── mutations.js │ │ │ ├── comments │ │ │ ├── actions.js │ │ │ ├── getters.js │ │ │ ├── index.js │ │ │ └── mutations.js │ │ │ ├── content-elements │ │ │ ├── actions.js │ │ │ ├── getters.js │ │ │ ├── index.js │ │ │ └── mutations.js │ │ │ ├── feed.js │ │ │ ├── getters.js │ │ │ ├── index.js │ │ │ ├── mutations.js │ │ │ ├── revisions │ │ │ ├── actions.js │ │ │ ├── getters.js │ │ │ ├── index.js │ │ │ └── mutations.js │ │ │ └── user-tracking │ │ │ ├── actions.js │ │ │ ├── getters.js │ │ │ ├── index.js │ │ │ └── mutations.js │ └── plugins │ │ ├── index.js │ │ └── vuex-persist.js └── utils │ ├── InsertLocation.js │ ├── date.js │ ├── paramsParser.js │ ├── repository.js │ ├── revision.js │ └── validation.js ├── common ├── package.json └── sse.js ├── config ├── client │ └── brand.loader.js ├── package.json ├── server │ ├── auth.js │ ├── consumer.js │ ├── database.js │ ├── index.js │ ├── mail.js │ ├── storage.js │ ├── store.js │ └── tce.js └── shared │ ├── core-containers.js │ ├── core-elements.js │ ├── core-meta.js │ ├── index.js │ ├── role.js │ └── tailor.loader.js ├── cypress.config.mjs ├── cypress ├── .eslintrc.js ├── e2e │ ├── auth │ │ └── sign-in.cy.js │ ├── catalog │ │ ├── access-repository.cy.js │ │ ├── create-repository.cy.js │ │ ├── repository-tags.cy.js │ │ ├── search-and-filter.cy.js │ │ └── utils.js │ └── repository │ │ └── structure │ │ ├── child-activity-creation.cy.js │ │ ├── root-activity-creation.cy.js │ │ └── utils.js ├── support │ └── e2e │ │ ├── activity.js │ │ ├── api.js │ │ ├── auth.js │ │ ├── common.js │ │ ├── index.js │ │ ├── repository.js │ │ ├── schema.js │ │ └── vuetify │ │ ├── index.js │ │ └── vSelect.js └── utils │ └── index.js ├── extensions ├── content-containers │ └── .gitkeep ├── content-elements │ └── .gitkeep └── meta-inputs │ └── .gitkeep ├── jsconfig.json ├── lerna.json ├── nodemon.json ├── package-lock.json ├── package.json ├── packages ├── config │ ├── .eslintignore │ ├── .eslintrc.json │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── index.js │ │ ├── schema-processor.js │ │ ├── schema-validation.js │ │ ├── schema.js │ │ ├── workflow-validation.js │ │ └── workflow.js │ └── vite.config.js ├── core-components │ ├── .eslintignore │ ├── .eslintrc.json │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── ActiveUsers.vue │ │ │ ├── AddElement │ │ │ │ ├── AddNewElement.vue │ │ │ │ ├── InlineActivator.vue │ │ │ │ └── index.vue │ │ │ ├── AssessmentItem.vue │ │ │ ├── AssetInput.vue │ │ │ ├── ContainedContent.vue │ │ │ ├── ContentElement.vue │ │ │ ├── ContentPreview │ │ │ │ ├── Element.vue │ │ │ │ └── index.vue │ │ │ ├── DatePicker.vue │ │ │ ├── Discussion │ │ │ │ ├── ResolveButton.vue │ │ │ │ ├── Thread │ │ │ │ │ ├── Comment │ │ │ │ │ │ ├── Header.vue │ │ │ │ │ │ ├── Preview.vue │ │ │ │ │ │ └── index.vue │ │ │ │ │ ├── List.vue │ │ │ │ │ ├── UnseenDivider.vue │ │ │ │ │ └── index.vue │ │ │ │ └── index.vue │ │ │ ├── EditorLink.vue │ │ │ ├── ElementDiscussion.vue │ │ │ ├── ElementList.vue │ │ │ ├── ElementPlaceholder.vue │ │ │ ├── EmbeddedContainer.vue │ │ │ ├── FileInput.vue │ │ │ ├── InputError.vue │ │ │ ├── PreviewOverlay.vue │ │ │ ├── PublishDiffChip.vue │ │ │ ├── QuestionContainer │ │ │ │ ├── Controls.vue │ │ │ │ ├── Feedback.vue │ │ │ │ ├── Question.vue │ │ │ │ └── index.vue │ │ │ ├── SelectElement │ │ │ │ ├── SelectActivity.vue │ │ │ │ ├── SelectRepository.vue │ │ │ │ └── index.vue │ │ │ ├── TailorDialog.vue │ │ │ └── UploadBtn.vue │ │ ├── download.js │ │ ├── index.js │ │ ├── loader.js │ │ ├── mixins.scss │ │ └── upload.js │ ├── stylelint.config.cjs │ └── vite.config.js ├── utils │ ├── .eslintignore │ ├── .eslintrc.json │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── InsertLocation.js │ │ ├── activity.js │ │ ├── assessment.js │ │ ├── calculatePosition.js │ │ ├── events │ │ │ ├── discussion.js │ │ │ └── index.js │ │ ├── index.js │ │ ├── numberToLetter.js │ │ ├── publishDiffChangeTypes.js │ │ └── uuid.js │ └── vite.config.js └── vue-radio │ ├── .eslintignore │ ├── .eslintrc.json │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── helpers.js │ ├── index.js │ └── radio.js │ └── vite.config.js ├── sequelize.config.js ├── server ├── activity │ ├── activity.controller.js │ ├── activity.model.js │ ├── hooks.js │ ├── index.js │ ├── status.hooks.js │ └── status.model.js ├── app.js ├── comment │ ├── comment.controller.js │ ├── comment.model.js │ ├── hooks.js │ └── index.js ├── content-element │ ├── content-element.controller.js │ ├── content-element.model.js │ ├── hooks.js │ └── index.js ├── index.js ├── oidc │ ├── authenticated.mustache │ ├── error.mustache │ └── index.js ├── package.json ├── repository │ ├── feed │ │ ├── controller.js │ │ ├── index.js │ │ └── store.js │ ├── index.js │ ├── proxy.js │ ├── repository.controller.js │ ├── repository.model.js │ ├── repositoryUser.model.js │ └── storage.js ├── revision │ ├── hooks.js │ ├── index.js │ ├── revision.controller.js │ ├── revision.model.js │ └── revision.service.js ├── router.js ├── script │ ├── addAdmin.js │ ├── addIntegration.js │ ├── detachDeletedRepos.js │ ├── fixRevisionUrls.js │ ├── generateIntegrationToken.js │ ├── inviteAdmin.js │ ├── migrateAssessmentPools.js │ ├── migrateAssetsLocation.js │ ├── preflight.js │ └── sequelize.js ├── shared │ ├── auth │ │ ├── audience.js │ │ ├── authenticator.js │ │ ├── index.js │ │ ├── mw.js │ │ └── oidc.js │ ├── content-plugins │ │ ├── BaseRegistry.js │ │ ├── containerRegistry.js │ │ ├── elementHooks.js │ │ ├── elementRegistry.js │ │ └── index.js │ ├── database │ │ ├── config.js │ │ ├── helpers.js │ │ ├── hooks.js │ │ ├── index.js │ │ ├── migrations │ │ │ ├── 20181115140901-create-user.js │ │ │ ├── 20181115140902-create-course.js │ │ │ ├── 20181115140903-course-user-relationship.js │ │ │ ├── 20181115140904-create-activity.js │ │ │ ├── 20181115140905-create-teaching-element.js │ │ │ ├── 20181115140906-create-revision.js │ │ │ ├── 20181115140907-create-comment.js │ │ │ ├── 20181115140943-add-meta-to-teaching-element.js │ │ │ ├── 20190416152610-change-table-cell-type.js │ │ │ ├── 20190712153652-pin-repo.js │ │ │ ├── 20190717151915-remove-course-stats.js │ │ │ ├── 20190717151916-add-user-info.js │ │ │ ├── 20190717151916-remove-user-token.js │ │ │ ├── 20191023082600-rename-course-to-repository.js │ │ │ ├── 20191023083030-rename-course_user-to-repository_user.js │ │ │ ├── 20191023083031-update-repository-roles.js │ │ │ ├── 20191023083231-rename-course_id-to-repository_id.js │ │ │ ├── 20191023083231-update-revision-type-enum.js │ │ │ ├── 20191023083232-update-constraint-names.js │ │ │ ├── 20191023083234-rename-teaching-to-content-element.js │ │ │ ├── 20191023083236-change-revision-entity-enum.js │ │ │ ├── 20200130124211-create-tag.js │ │ │ ├── 20200130124252-create-repository-tag.js │ │ │ ├── 20200215090218-add-modified-at-to-activity.js │ │ │ ├── 20200217104115-add-has-unpublished-changes-to-repository.js │ │ │ ├── 20201005125421-add-missing-uids.js │ │ │ ├── 20201017161408-create-task.js │ │ │ ├── 20201113162457-add-element-id-to-comment.js │ │ │ ├── 20210125094759-add-edited_at_column_to_comment.js │ │ │ ├── 20210125094819-add-resolved_at-column-to-comment.js │ │ │ ├── 20210128094442-change-task-to-activity-status.js │ │ │ ├── 20210204115515-hydrate-edited_at-column.js │ │ │ └── package.json │ │ ├── pagination.js │ │ └── seeds │ │ │ ├── 20181115140901-insert-users.js │ │ │ └── package.json │ ├── error │ │ └── helpers.js │ ├── logger.js │ ├── mail │ │ ├── formatters.js │ │ ├── index.js │ │ ├── render.js │ │ └── templates │ │ │ ├── assignee.mjml │ │ │ ├── assignee.txt │ │ │ ├── comment.mjml │ │ │ ├── comment.txt │ │ │ ├── components │ │ │ ├── footer.mjml │ │ │ ├── head.mjml │ │ │ └── header.mjml │ │ │ ├── reset.mjml │ │ │ ├── reset.txt │ │ │ ├── welcome.mjml │ │ │ └── welcome.txt │ ├── origin.js │ ├── publishing │ │ ├── helpers.js │ │ └── publishing.service.js │ ├── request │ │ └── mw.js │ ├── sse │ │ ├── channels.js │ │ └── index.js │ ├── storage │ │ ├── helpers.js │ │ ├── index.js │ │ ├── providers │ │ │ ├── amazon.js │ │ │ └── filesystem.js │ │ ├── proxy │ │ │ ├── index.js │ │ │ ├── mw.js │ │ │ └── providers │ │ │ │ ├── cloudfront.js │ │ │ │ └── local.js │ │ ├── storage.controller.js │ │ ├── storage.router.js │ │ ├── util.js │ │ └── validation.js │ ├── transfer │ │ ├── default │ │ │ ├── index.js │ │ │ ├── processors.js │ │ │ └── resolvers.js │ │ ├── formats.js │ │ ├── job.js │ │ └── transfer.service.js │ ├── util │ │ ├── Deferred.js │ │ ├── calculatePosition.js │ │ └── processListQuery.js │ └── webhookProvider.js ├── tag │ ├── index.js │ ├── repositoryTag.model.js │ ├── tag.controller.js │ └── tag.model.js └── user │ ├── index.js │ ├── mw.js │ ├── user.controller.js │ └── user.model.js ├── stylelint.config.js ├── tailor.config.js.example └── vite.config.mjs /.brandrc.js.example: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | title: 'Tailor', 5 | // Logo files in /client/assets/img 6 | // Filenames must contain 'logo' verbiage 7 | logo: { 8 | // Compact, navbar logo 9 | compact: 'default-logo-compact.svg', 10 | // Full, login screen logo 11 | full: 'default-logo-full.svg' 12 | }, 13 | // Favicon file in /client/assets/img 14 | // Filename must contain 'favicon' verbiage 15 | favicon: 'default-favicon.ico', 16 | // Style constants 17 | style: { 18 | // Primary brand color 19 | brandColor: '#0D47A0', 20 | // Secondary brand color 21 | altBrandColor: '#5C6BC0' 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /.circleci/setup-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | { 3 | echo HOSTNAME="$APP_HOSTNAME"; 4 | echo PORT="$APP_PORT"; 5 | echo PROTOCOL="$APP_PROTOCOL"; 6 | echo REVERSE_PROXY_PORT="$APP_REVERSE_PROXY_PORT"; 7 | 8 | echo CORS_ALLOWED_ORIGINS="$CORS_ALLOWED_ORIGINS" 9 | 10 | echo DATABASE_NAME="$DATABASE_NAME" 11 | echo DATABASE_USER="$DATABASE_USER" 12 | echo DATABASE_PASSWORD="$DATABASE_PASSWORD" 13 | echo DATABASE_HOST="$DATABASE_HOST" 14 | echo DATABASE_PORT="$DATABASE_PORT" 15 | echo DATABASE_ADAPTER="$DATABASE_ADAPTER" 16 | 17 | echo AUTH_SALT_ROUNDS="$AUTH_SALT_ROUNDS" 18 | echo AUTH_JWT_SECRET="$AUTH_JWT_SECRET" 19 | echo AUTH_JWT_ISSUER="$AUTH_JWT_ISSUER" 20 | echo AUTH_JWT_COOKIE_NAME="$AUTH_JWT_COOKIE_NAME" 21 | echo AUTH_JWT_COOKIE_SECRET="$AUTH_JWT_COOKIE_SECRET" 22 | 23 | echo STORAGE_PROVIDER="$STORAGE_PROVIDER" 24 | echo STORAGE_PATH="$STORAGE_PATH" 25 | echo STORAGE_PROXY="$STORAGE_PROXY" 26 | echo STORAGE_PROXY_PRIVATE_KEY="$STORAGE_PROXY_PRIVATE_KEY" 27 | 28 | echo ENABLE_DEFAULT_SCHEMA="$ENABLE_DEFAULT_SCHEMA" 29 | 30 | echo STORE_PROVIDER="$STORE_PROVIDER" 31 | echo STORE_TTL="$STORE_TTL" 32 | 33 | echo CYPRESS_USERNAME="$CYPRESS_USERNAME" 34 | echo CYPRESS_PASSWORD="$CYPRESS_PASSWORD" 35 | } >> .env 36 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | extensions 3 | packages 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** @type {import('eslint').Linter.Config} */ 4 | module.exports = { 5 | root: true, 6 | extends: '@extensionengine', 7 | plugins: [ 8 | 'vuetify' 9 | ], 10 | rules: { 11 | 'vuetify/no-deprecated-classes': 'error', 12 | 'vuetify/grid-unknown-attributes': 'error', 13 | 'vuetify/no-legacy-grid': 'error', 14 | 'vue/valid-v-slot': ['error', { 15 | allowModifiers: true 16 | }] 17 | }, 18 | overrides: [ 19 | { 20 | files: [ 21 | 'build/plugins/vite/**', 22 | 'client/**', 23 | 'common/**', 24 | 'config/**', 25 | 'server/**' 26 | ], 27 | parserOptions: { 28 | ecmaVersion: 'latest', 29 | sourceType: 'module', 30 | requireConfigFile: false 31 | } 32 | }, 33 | { 34 | files: [ 35 | 'server/shared/database/migrations/**', 36 | 'server/shared/database/seeds/**' 37 | ], 38 | parserOptions: { 39 | sourceType: 'script' 40 | } 41 | } 42 | ], 43 | globals: { 44 | BRAND_CONFIG: true 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Editors 5 | .vscode 6 | *.sublime-* 7 | .idea 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | 14 | # Cypress 15 | cypress/videos 16 | cypress/screenshots 17 | 18 | # Configuration 19 | .env 20 | ecosystem.* 21 | tailor.config.js 22 | tailor.config.mjs 23 | .tailorrc* 24 | # TODO: Deprecate legacy configuration files 25 | activities.config.js 26 | .activities-rc* 27 | brand.config.js 28 | .brandrc* 29 | !*.example 30 | 31 | # Extensions 32 | extensions/content-elements/* 33 | !extensions/content-elements/.gitkeep 34 | extensions/content-containers/* 35 | !extensions/content-containers/.gitkeep 36 | extensions/meta-inputs/* 37 | !extensions/meta-inputs/.gitkeep 38 | 39 | # Branding 40 | client/assets/img/* 41 | !client/assets/img/default-* 42 | 43 | # DB migrations 44 | umzug.json 45 | 46 | # Filesystem storage provider 47 | data/* 48 | !data/.gitkeep 49 | 50 | # Build artifacts 51 | dist/* 52 | !dist/.gitkeep 53 | stats.json 54 | packages/*/dist 55 | packages/*/stats.html 56 | # General 57 | .DS_Store 58 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.13.0 2 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.12.0 3 | ignore: {} 4 | # patches apply the minimum changes required to fix a vulnerability 5 | patch: 6 | 'npm:hoek:20180212': 7 | - passport-jwt > jsonwebtoken > joi > hoek: 8 | patched: '2018-10-03T09:31:40.291Z' 9 | - passport-jwt > jsonwebtoken > joi > topo > hoek: 10 | patched: '2018-10-03T09:31:40.291Z' 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 ExtensionEngine, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /build/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /build/plugins/vite/html-replace.js: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'node:module'; 2 | import { execSync } from 'node:child_process'; 3 | import isEmpty from 'lodash/isEmpty.js'; 4 | import template from 'lodash/template.js'; 5 | 6 | const require = createRequire(import.meta.url); 7 | 8 | export default function htmlReplace({ defaults = false, replacements = {} }) { 9 | return { 10 | name: 'html-replace', 11 | transformIndexHtml: { 12 | enforce: 'pre', 13 | transform: html => { 14 | if (!defaults && isEmpty(replacements)) { 15 | return html; 16 | } 17 | 18 | const mergedReplacements = {}; 19 | 20 | if (defaults) { 21 | const pkg = require('../../../package.json'); 22 | const rev = execSync('git rev-parse --short HEAD'); 23 | 24 | Object.assign(mergedReplacements, { 25 | description: pkg.description, 26 | version: `${pkg.version}-rev-${rev} (${pkg.codename})` 27 | }); 28 | } 29 | 30 | Object.assign(mergedReplacements, replacements); 31 | 32 | return template(html)(mergedReplacements); 33 | } 34 | } 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /bunyan-pretty.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { format } = require('util'); 4 | const { prettify } = require('sql-log-prettifier'); 5 | 6 | module.exports = { 7 | level: 'debug', 8 | customPrettifiers: { 9 | query(input) { 10 | const index = input.indexOf(': ') + 1; 11 | const sql = input.substring(index); 12 | return format('\n```sql\n%s\n```', prettify(sql)); 13 | } 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /client/api/activity.js: -------------------------------------------------------------------------------- 1 | import request from './request'; 2 | 3 | const urls = { 4 | root: repositoryId => `/repositories/${repositoryId}/activities` 5 | }; 6 | 7 | function getActivities(repositoryId, params) { 8 | return request.get(urls.root(repositoryId), { params }) 9 | .then(res => res.data.data); 10 | } 11 | 12 | function createPreview(repositoryId, activityId) { 13 | return request.get(`${urls.root(repositoryId)}/${activityId}/preview`) 14 | .then(res => res.data.location); 15 | } 16 | 17 | export default { 18 | createPreview, 19 | getActivities 20 | }; 21 | -------------------------------------------------------------------------------- /client/api/asset.js: -------------------------------------------------------------------------------- 1 | import request from './request'; 2 | 3 | const urls = { 4 | base: repositoryId => `/repositories/${repositoryId}/assets` 5 | }; 6 | 7 | function getUrl(repositoryId, key) { 8 | const params = { key }; 9 | return request.get(urls.base(repositoryId), { params }).then(res => res.data.url); 10 | } 11 | 12 | function upload(repositoryId, data) { 13 | return request.post(urls.base(repositoryId), data, { 14 | headers: { 15 | /* 16 | The default value of the Content-Type header is set to `application/json` inside 17 | the `./request.js` file, which implies the provided data will be serialized as JSON. 18 | Unsetting the header instructs Axios to determine the header and serialization based 19 | on the type of the provided data. 20 | https://github.com/axios/axios/issues/5556#issuecomment-1434668134 21 | */ 22 | 'Content-Type': undefined 23 | } 24 | }).then(res => res.data); 25 | } 26 | 27 | export default { 28 | getUrl, 29 | upload 30 | }; 31 | -------------------------------------------------------------------------------- /client/api/contentElement.js: -------------------------------------------------------------------------------- 1 | import { extractData } from './helpers'; 2 | import request from './request'; 3 | 4 | const urls = { 5 | repository: id => `/repositories/${id}`, 6 | root: repositoryId => `${urls.repository(repositoryId)}/content-elements`, 7 | resource: (repositoryId, id) => `${urls.root(repositoryId)}/${id}` 8 | }; 9 | 10 | function fetch({ repositoryId, ...params }) { 11 | return request.get(urls.root(repositoryId), { params }).then(extractData); 12 | } 13 | 14 | function patch({ repositoryId, id }, data) { 15 | return request.patch(urls.resource(repositoryId, id), data); 16 | } 17 | 18 | export default { 19 | fetch, 20 | patch 21 | }; 22 | -------------------------------------------------------------------------------- /client/api/feed.js: -------------------------------------------------------------------------------- 1 | import { extractData } from './helpers'; 2 | import request from './request'; 3 | 4 | const urls = { 5 | root: repositoryId => `/repositories/${repositoryId}/feed`, 6 | subscribe: repositoryId => `${urls.root(repositoryId)}/subscribe` 7 | }; 8 | 9 | function fetch(repositoryId) { 10 | return request.get(urls.root(repositoryId)).then(extractData); 11 | } 12 | 13 | function start(context) { 14 | return request.post(urls.root(context.repositoryId), { context }); 15 | } 16 | 17 | function end(context) { 18 | return request.delete(urls.root(context.repositoryId), { data: { context } }); 19 | } 20 | 21 | export default { 22 | urls, 23 | fetch, 24 | start, 25 | end 26 | }; 27 | -------------------------------------------------------------------------------- /client/api/helpers.js: -------------------------------------------------------------------------------- 1 | export function extractData(res) { 2 | return res.data.data; 3 | } 4 | -------------------------------------------------------------------------------- /client/api/index.js: -------------------------------------------------------------------------------- 1 | import activity from './activity'; 2 | import contentElement from './contentElement'; 3 | import repository from './repository'; 4 | 5 | export const exposedApi = { 6 | fetchRepositories: repository.getRepositories, 7 | fetchActivities: activity.getActivities, 8 | fetchContentElements: contentElement.fetch 9 | }; 10 | 11 | export { default as activity } from './activity'; 12 | export { default as asset } from './asset'; 13 | export { default as auth } from './auth'; 14 | export { default as contentElement } from './contentElement'; 15 | export { default as feed } from './feed'; 16 | export { default as repository } from './repository'; 17 | export { default as revision } from './revision'; 18 | export { default as tag } from './tag'; 19 | export { default as user } from './user'; 20 | export { default as client } from './request'; 21 | export { extractData } from './helpers'; 22 | -------------------------------------------------------------------------------- /client/api/revision.js: -------------------------------------------------------------------------------- 1 | import { extractData } from './helpers'; 2 | import request from './request'; 3 | 4 | const urls = { 5 | root: repositoryId => `/repositories/${repositoryId}/revisions`, 6 | timeTravel: repositoryId => `/repositories/${repositoryId}/revisions/time-travel`, 7 | resource: (repositoryId, id) => `${urls.root(repositoryId)}/${id}` 8 | }; 9 | 10 | function fetch(repositoryId, params) { 11 | return request.get(urls.root(repositoryId), { params }).then(extractData); 12 | } 13 | 14 | function getStateAtMoment(repositoryId, params) { 15 | return request.get(urls.timeTravel(repositoryId), { params }) 16 | .then(extractData); 17 | } 18 | 19 | function get(repositoryId, id, params) { 20 | return request.get(urls.resource(repositoryId, id), { params }) 21 | .then(res => res.data); 22 | } 23 | 24 | export default { 25 | fetch, 26 | getStateAtMoment, 27 | get 28 | }; 29 | -------------------------------------------------------------------------------- /client/api/tag.js: -------------------------------------------------------------------------------- 1 | import { extractData } from './helpers'; 2 | import request from './request'; 3 | 4 | const urls = { 5 | root: '/tags' 6 | }; 7 | 8 | function fetch(params) { 9 | return request.get(urls.root, { params }).then(extractData); 10 | } 11 | 12 | export default { 13 | fetch 14 | }; 15 | -------------------------------------------------------------------------------- /client/api/user.js: -------------------------------------------------------------------------------- 1 | import { extractData } from './helpers'; 2 | import request from './request'; 3 | 4 | function fetch(params) { 5 | return request.get('/users', { params }).then(extractData); 6 | } 7 | 8 | function upsert(data) { 9 | return request.post('/users', data).then(extractData); 10 | } 11 | 12 | function remove({ id }) { 13 | return request.delete(`/users/${id}`); 14 | } 15 | 16 | function reinvite({ id }) { 17 | return request.post(`/users/${id}/reinvite`); 18 | } 19 | 20 | export default { 21 | fetch, 22 | upsert, 23 | remove, 24 | reinvite 25 | }; 26 | -------------------------------------------------------------------------------- /client/assets/img/default-favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExtensionEngine/tailor/84c54980c967fd4d0d1f700e556c3992c4587c11/client/assets/img/default-favicon.ico -------------------------------------------------------------------------------- /client/assets/img/default-logo-compact.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExtensionEngine/tailor/84c54980c967fd4d0d1f700e556c3992c4587c11/client/assets/img/default-logo-compact.png -------------------------------------------------------------------------------- /client/assets/locales/timeago-en-US-short.json: -------------------------------------------------------------------------------- 1 | [ 2 | "now", 3 | "%s s", 4 | "%s m", 5 | "%s h", 6 | "%s d", 7 | "%s w", 8 | "%s m", 9 | "%s y" 10 | ] 11 | -------------------------------------------------------------------------------- /client/assets/stylesheets/common/_all.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | @import 'transitions'; 3 | @import 'buttons'; 4 | @import 'chips'; 5 | @import 'errors'; 6 | @import 'inputs'; 7 | @import 'layouts'; 8 | @import 'modals'; 9 | @import 'vuetify'; 10 | -------------------------------------------------------------------------------- /client/assets/stylesheets/common/_chips.scss: -------------------------------------------------------------------------------- 1 | .v-chip.readonly::before { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /client/assets/stylesheets/common/_errors.scss: -------------------------------------------------------------------------------- 1 | .has-error { 2 | .help-block { 3 | text-align: left; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/assets/stylesheets/common/_inputs.scss: -------------------------------------------------------------------------------- 1 | // Remove X (clear button) on Edge 2 | input::-ms-clear { 3 | display: none; 4 | } 5 | -------------------------------------------------------------------------------- /client/assets/stylesheets/common/_layouts.scss: -------------------------------------------------------------------------------- 1 | .vertical-layout { 2 | display: flex; 3 | flex-direction: column; 4 | 5 | &[direction="reverse"] { 6 | flex-direction: column-reverse; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/assets/stylesheets/common/_modals.scss: -------------------------------------------------------------------------------- 1 | .modal-open.ie { 2 | object, embed { 3 | visibility: hidden; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/assets/stylesheets/common/_transitions.scss: -------------------------------------------------------------------------------- 1 | .fade-enter-active { 2 | transition: opacity 0.5s; 3 | } 4 | 5 | .fade-enter { 6 | opacity: 0; 7 | } 8 | 9 | .fade-leave, .fade-leave-active, .fade-leave-to { 10 | display: none; 11 | } 12 | -------------------------------------------------------------------------------- /client/assets/stylesheets/common/_variables.scss: -------------------------------------------------------------------------------- 1 | // Font weight 2 | $font-weight-light: 300; 3 | $font-weight-regular: 400; 4 | $font-weight-medium: 500; 5 | $font-weight-semibold: 600; 6 | $font-weight-bold: 700; 7 | 8 | // Font family 9 | $font-family-icons: "Material Design Icons"; 10 | $font-family-primary: poppins, helvetica, arial, sans-serif; 11 | $font-family-secondary: roboto, helvetica, arial, sans-serif; 12 | $bg-color-default: #f5f5f5; 13 | 14 | @mixin highlight($color) { 15 | box-shadow: 0 0 0 2px $color !important; 16 | border: none; 17 | } 18 | -------------------------------------------------------------------------------- /client/assets/stylesheets/common/assessments/_all.scss: -------------------------------------------------------------------------------- 1 | @import 'buttons'; 2 | -------------------------------------------------------------------------------- /client/assets/stylesheets/common/assessments/_buttons.scss: -------------------------------------------------------------------------------- 1 | .assessment { 2 | .btn[disabled] { 3 | box-shadow: 1px 1px 4px rgb(0 0 0 / 40%); 4 | } 5 | 6 | .btn:focus { 7 | outline: 0; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/assets/stylesheets/main.scss: -------------------------------------------------------------------------------- 1 | // Google Web Fonts 2 | /* stylelint-disable function-comma-newline-after, import-notation */ 3 | @import '~sass-web-fonts/web-fonts'; 4 | 5 | $web-font-path: web-fonts-url( 6 | ('Roboto': (300, 400, 500, 700)), 7 | ('Poppins': (300, 400, 500, 700)) 8 | ); 9 | 10 | @import url($web-font-path); 11 | /* stylelint-enable */ 12 | 13 | // MDI 14 | $mdi-font-path: '~@mdi/font/fonts'; 15 | 16 | @import '~@mdi/font/scss/materialdesignicons'; 17 | 18 | // Common stylesheets 19 | @import 'common/all'; 20 | @import 'common/assessments/all'; 21 | 22 | // Tailor core components 23 | @import "~@tailor-cms/core-components/style.css"; 24 | 25 | // Vuetify 26 | @import "~vuetify/dist/vuetify.css"; 27 | -------------------------------------------------------------------------------- /client/components/catalog/RepositoryFilterSelection/SelectedFilter.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 21 | 22 | 33 | -------------------------------------------------------------------------------- /client/components/catalog/RepositoryFilterSelection/index.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 40 | 41 | 46 | -------------------------------------------------------------------------------- /client/components/catalog/Search.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 41 | 42 | 50 | -------------------------------------------------------------------------------- /client/components/catalog/repositoryFilterConfigs.js: -------------------------------------------------------------------------------- 1 | export default { 2 | TAG: { 3 | type: 'TAG', 4 | label: 'tags', 5 | queryParam: 'tagIds', 6 | icon: 'mdi-tag-outline' 7 | }, 8 | SCHEMA: { 9 | type: 'SCHEMA', 10 | label: 'schemas', 11 | queryParam: 'schemas', 12 | icon: 'mdi-file-tree' 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /client/components/common/Footer.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 31 | 32 | 37 | -------------------------------------------------------------------------------- /client/components/common/ProgressDialog.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /client/components/common/mixins/deprecation.js: -------------------------------------------------------------------------------- 1 | import { noCase } from 'change-case'; 2 | 3 | const printDeprecationWarning = (oldEvent, newEvent) => { 4 | console.warn(`Deprecation notice: 5 | '${oldEvent}' listener is deprecated and will no longer be used! 6 | Please emit '${newEvent}' instead on your content containers`); 7 | }; 8 | 9 | export default { 10 | methods: { 11 | deprecateEvent(handlerName, { oldEvent, newEvent, adaptArgs }) { 12 | newEvent = newEvent || noCase(oldEvent, { delimiter: ':' }); 13 | return (...args) => { 14 | printDeprecationWarning(oldEvent, newEvent); 15 | this[handlerName](...(adaptArgs ? adaptArgs(...args) : args)); 16 | }; 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /client/components/content-containers/tcc-assessment-pool/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | import info from './info'; 3 | 4 | export default { 5 | ...info, 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/content-containers/tcc-assessment-pool/info.js: -------------------------------------------------------------------------------- 1 | export default { 2 | templateId: 'ASSESSMENT_POOL', 3 | version: '1.0' 4 | }; 5 | -------------------------------------------------------------------------------- /client/components/content-containers/tcc-assessment-pool/util.js: -------------------------------------------------------------------------------- 1 | import info from './info'; 2 | import pick from 'lodash/pick'; 3 | import Promise from 'bluebird'; 4 | 5 | const ATTRS = [ 6 | 'id', 'uid', 'type', 'position', 'parentId', 'createdAt', 'updatedAt' 7 | ]; 8 | 9 | async function fetchContainer(container) { 10 | const elements = await container.getContentElements({ raw: true }); 11 | return { ...pick(container, ATTRS), elements }; 12 | } 13 | 14 | function fetch(parent, type) { 15 | const opts = { where: { type } }; 16 | return parent.getChildren(opts).map(fetchContainer); 17 | } 18 | 19 | async function resolve(container, resolveStatics) { 20 | container.elements = await Promise.map(container.elements, resolveStatics); 21 | return container; 22 | } 23 | 24 | module.exports = { 25 | ...info, 26 | fetch, 27 | resolve 28 | }; 29 | -------------------------------------------------------------------------------- /client/components/content-containers/tcc-default/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | 3 | export default { 4 | templateId: 'DEFAULT', 5 | version: '1.0', 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/content-containers/tcc-exam/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | import info from './info'; 3 | 4 | export default { 5 | ...info, 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/content-containers/tcc-exam/info.js: -------------------------------------------------------------------------------- 1 | export default { 2 | templateId: 'EXAM', 3 | version: '1.0' 4 | }; 5 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-accordion/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | 3 | const initState = () => { 4 | return { 5 | embeds: {}, 6 | items: {} 7 | }; 8 | }; 9 | 10 | export default { 11 | name: 'Accordion', 12 | type: 'ACCORDION', 13 | version: '1.0', 14 | initState, 15 | Edit, 16 | ui: { 17 | icon: 'mdi-view-sequential', 18 | forceFullWidth: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-audio/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | import Toolbar from './edit/Toolbar.vue'; 3 | 4 | const initState = () => ({ url: null }); 5 | 6 | export default { 7 | name: 'Audio', 8 | type: 'AUDIO', 9 | version: '1.0', 10 | initState, 11 | Edit, 12 | Toolbar, 13 | ui: { 14 | icon: 'mdi-volume-high', 15 | forceFullWidth: false 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-brightcove-video/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | import Toolbar from './edit/Toolbar.vue'; 3 | 4 | const initState = () => ({ accountId: null, playerId: null, videoId: null }); 5 | 6 | export default { 7 | name: 'Brightcove Video', 8 | type: 'BRIGHTCOVE_VIDEO', 9 | version: '1.0', 10 | initState, 11 | Edit, 12 | Toolbar, 13 | ui: { 14 | icon: 'mdi-video', 15 | forceFullWidth: false 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-carousel/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | import Toolbar from './edit/Toolbar.vue'; 3 | 4 | const initState = () => { 5 | return { 6 | embeds: {}, 7 | items: {}, 8 | height: 500 9 | }; 10 | }; 11 | 12 | export default { 13 | name: 'Carousel', 14 | type: 'CAROUSEL', 15 | version: '1.0', 16 | initState, 17 | Edit, 18 | Toolbar, 19 | ui: { 20 | icon: 'mdi-view-carousel', 21 | forceFullWidth: true 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-embed/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | import Toolbar from './edit/Toolbar.vue'; 3 | 4 | const initState = () => ({ url: null, height: 260 }); 5 | 6 | export default { 7 | name: 'Embed', 8 | type: 'EMBED', 9 | version: '1.0', 10 | initState, 11 | Edit, 12 | Toolbar, 13 | ui: { 14 | icon: 'mdi-arrange-bring-forward', 15 | forceFullWidth: false 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-fill-blank/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import Edit from './edit/index.vue'; 3 | import find from 'lodash/find'; 4 | 5 | const TEXT_CONTAINERS = ['JODIT_HTML', 'HTML']; 6 | const BLANK_PLACEHOLDER = /(@blank)/g; 7 | 8 | const answer = () => yup.string().trim().max(200).required().label('Answer'); 9 | 10 | const schema = { 11 | question: yup.array().test( 12 | 'has-blanks', 'At least one @blank required', question => { 13 | return !!find(question, it => containsText(it) && containsBlanks(it)); 14 | } 15 | ), 16 | correct: yup.array().of(yup.array().min(1).of(answer())) 17 | }; 18 | 19 | const initState = () => ({ 20 | correct: [] 21 | }); 22 | 23 | export default { 24 | name: 'Fill in the blank', 25 | type: 'QUESTION', 26 | subtype: 'FB', 27 | version: '1.0', 28 | schema, 29 | initState, 30 | Edit, 31 | ui: { 32 | forceFullWidth: true 33 | } 34 | }; 35 | 36 | function containsText(asset) { 37 | return TEXT_CONTAINERS.includes(asset.type) && 38 | asset.data.content && 39 | asset.data.content.trim().length > 0; 40 | } 41 | 42 | function containsBlanks(asset) { 43 | return asset.data.content.match(BLANK_PLACEHOLDER); 44 | } 45 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-html/edit/theme/modules/image-embed.js: -------------------------------------------------------------------------------- 1 | import createImageEmbedTooltip from '../ui/image-embed-tooltip'; 2 | 3 | export default Quill => class ImageEmbed extends Quill.import('core/module') { 4 | static NAME = 'imageEmbed'; 5 | 6 | constructor(quill, options = {}) { 7 | super(quill, options); 8 | const { bounds } = quill.options; 9 | const ImageEmbedTooltip = createImageEmbedTooltip(Quill); 10 | quill.tooltips = quill.tooltips || {}; 11 | quill.tooltips.imageEmbed = new ImageEmbedTooltip(quill, bounds, options); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-html/edit/theme/ui/color-picker.scss: -------------------------------------------------------------------------------- 1 | .ql-color-picker { 2 | .ql-picker-options .picker-item__none { 3 | $height: 30px; 4 | $icon-size: 18px; 5 | 6 | float: none; 7 | display: inline-flex; 8 | width: 100%; 9 | height: $height; 10 | padding: 0 1px; 11 | line-height: $height; 12 | text-align: left; 13 | align-items: center; 14 | vertical-align: middle; 15 | 16 | .icon { 17 | vertical-align: top; 18 | margin-right: 4px; 19 | font-size: $icon-size; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-html/edit/theme/ui/image-embed-tooltip.scss: -------------------------------------------------------------------------------- 1 | .ql-tooltip.ql-image-embed { 2 | &::before { 3 | content: none; 4 | } 5 | 6 | label { 7 | margin: 0 8px 0 0; 8 | line-height: 26px; 9 | } 10 | 11 | input[type="text"] { 12 | display: initial; 13 | 14 | &:focus { 15 | outline: none; 16 | } 17 | } 18 | 19 | .action { 20 | transition: none; 21 | margin-left: 16px; 22 | padding-right: 8px; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-html/edit/theme/ui/tooltip.js: -------------------------------------------------------------------------------- 1 | export default Quill => class Tooltip extends Quill.import('ui/tooltip') { 2 | constructor(quill, bounds) { 3 | super(quill, bounds); 4 | this._onClick = this._onClick.bind(this); 5 | this.isOpen = false; 6 | } 7 | 8 | show() { 9 | super.show(); 10 | this.isOpen = true; 11 | setTimeout(() => document.body.addEventListener('click', this._onClick), 0); 12 | const bounds = this.quill.getBounds(this.quill.selection.savedRange); 13 | this.position(bounds); 14 | } 15 | 16 | hide() { 17 | super.hide(); 18 | this.isOpen = false; 19 | document.body.removeEventListener('click', this._onClick); 20 | } 21 | 22 | _onClick(e) { 23 | if (this.isOpen && !this.root.contains(e.target)) this.hide(); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-html/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | import Toolbar from './edit/Toolbar.vue'; 3 | 4 | const initState = () => ({ 5 | content: '' 6 | }); 7 | 8 | export default { 9 | name: 'Text (deprecated)', 10 | type: 'HTML', 11 | version: '1.0', 12 | initState, 13 | Edit, 14 | Toolbar, 15 | ui: { 16 | icon: 'mdi-format-text', 17 | forceFullWidth: false 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-image/edit/UploadBtn.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 31 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-image/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | import info from './info'; 3 | import Toolbar from './edit/Toolbar.vue'; 4 | 5 | const initState = () => ({ url: null }); 6 | 7 | export default { 8 | ...info, 9 | initState, 10 | Edit, 11 | Toolbar, 12 | ui: { 13 | icon: 'mdi-image', 14 | forceFullWidth: false 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-image/info.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'Image', 3 | type: 'IMAGE', 4 | version: '1.0' 5 | }; 6 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-jodit/index.js: -------------------------------------------------------------------------------- 1 | import '@extensionengine/tce-jodit/tce-jodit.css'; 2 | import { Edit, options, Toolbar } from '@extensionengine/tce-jodit'; 3 | 4 | export default { 5 | ...options, 6 | name: options.label, 7 | Edit, 8 | Toolbar 9 | }; 10 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-modal/edit/Preview.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 47 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-modal/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | import Toolbar from './edit/Toolbar.vue'; 3 | 4 | const initState = () => ({ title: null, embeds: {} }); 5 | 6 | export default { 7 | name: 'Modal', 8 | type: 'MODAL', 9 | version: '1.0', 10 | initState, 11 | Edit, 12 | Toolbar, 13 | ui: { 14 | icon: 'mdi-window-maximize', 15 | forceFullWidth: false 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-multiple-choice/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import Edit from './edit/index.vue'; 3 | 4 | const MESSAGE = 'Please choose at least one correct answer'; 5 | 6 | const answer = () => yup.string().trim().max(500).required().label('Answer'); 7 | 8 | const schema = { 9 | answers: yup.array().min(2).of(answer()).required(), 10 | correct: yup.array().min(1, MESSAGE).of(yup.number()).required() 11 | }; 12 | 13 | const initState = () => ({ 14 | answers: ['', '', ''], 15 | correct: [] 16 | }); 17 | 18 | export default { 19 | name: 'Multiple Choice', 20 | type: 'QUESTION', 21 | subtype: 'MC', 22 | version: '1.0', 23 | schema, 24 | initState, 25 | Edit, 26 | ui: { 27 | forceFullWidth: true 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-numerical-response/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import Edit from './edit/index.vue'; 3 | 4 | const MESSAGE = 'Numeric answer is required (use . as a decimal separator)'; 5 | 6 | const answer = () => yup.number().required().typeError(MESSAGE).label('Answer'); 7 | const prefix = () => yup.string().trim().max(64).label('Prefix'); 8 | const suffix = () => yup.string().trim().max(64).label('Suffix'); 9 | 10 | const schema = { 11 | correct: yup.array().min(1).of(answer()).required(), 12 | prefixes: yup.array().min(1).of(prefix()), 13 | suffixes: yup.array().min(1).of(suffix()) 14 | }; 15 | 16 | const initState = () => ({ 17 | prefixes: [''], 18 | suffixes: [''], 19 | correct: [''] 20 | }); 21 | 22 | export default { 23 | name: 'Numerical Response', 24 | type: 'ASSESSMENT', 25 | subtype: 'NR', 26 | version: '1.0', 27 | schema, 28 | initState, 29 | Edit, 30 | ui: { 31 | forceFullWidth: true 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-page-break/edit/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | 17 | 23 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-page-break/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | 3 | const initState = () => ({}); 4 | 5 | export default { 6 | name: 'Section Break', 7 | type: 'BREAK', 8 | version: '1.0', 9 | initState, 10 | Edit, 11 | ui: { 12 | icon: 'mdi-format-page-break', 13 | forceFullWidth: true 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-pdf/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | import Toolbar from './edit/Toolbar.vue'; 3 | 4 | const initState = () => ({ url: null }); 5 | 6 | export default { 7 | name: 'PDF', 8 | type: 'PDF', 9 | version: '1.0', 10 | initState, 11 | Edit, 12 | Toolbar, 13 | ui: { 14 | icon: 'mdi-file-pdf-box', 15 | forceFullWidth: true 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-scorm/index.js: -------------------------------------------------------------------------------- 1 | import '@extensionengine/tce-scorm/tce-scorm.css'; 2 | import { Edit, options, Toolbar } from '@extensionengine/tce-scorm'; 3 | 4 | export default { 5 | ...options, 6 | name: options.label, 7 | Edit, 8 | Toolbar 9 | }; 10 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-scorm/server/index.js: -------------------------------------------------------------------------------- 1 | import { beforeSave } from '@extensionengine/tce-scorm/server'; 2 | import { options } from '@extensionengine/tce-scorm'; 3 | 4 | export default { 5 | type: options.type, 6 | beforeSave 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-single-choice/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import Edit from './edit/index.vue'; 3 | 4 | const MESSAGE = 'Please choose the correct answer'; 5 | 6 | const answer = () => yup.string().trim().max(500).required().label('Answer'); 7 | 8 | const schema = { 9 | answers: yup.array().min(2).of(answer()).required(), 10 | correct: yup.number().required(MESSAGE).typeError(MESSAGE) 11 | }; 12 | 13 | const initState = () => ({ 14 | answers: ['', ''], 15 | correct: '' 16 | }); 17 | 18 | export default { 19 | name: 'Single Choice', 20 | type: 'QUESTION', 21 | subtype: 'SC', 22 | version: '1.0', 23 | schema, 24 | initState, 25 | Edit, 26 | ui: { 27 | forceFullWidth: true 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-table/edit/TableCell.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 29 | 30 | 54 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-table/edit/utils.js: -------------------------------------------------------------------------------- 1 | import find from 'lodash/find'; 2 | 3 | export function addCell(row, cell) { 4 | if (!row.cells) row.cells = {}; 5 | row.cells[cell.id] = cell; 6 | return cell; 7 | } 8 | 9 | export function removeCell(row, predicate = {}) { 10 | const cell = find(row.cells, predicate); 11 | if (!cell) return; 12 | delete row.cells[cell.id]; 13 | return cell; 14 | } 15 | 16 | export function addEmbed(embeds, cellId, tableId) { 17 | const embed = { 18 | id: cellId, 19 | type: 'JODIT_HTML', 20 | embedded: true, 21 | data: { tableId, cellId } 22 | }; 23 | embeds[cellId] = embed; 24 | return embed; 25 | } 26 | 27 | export function removeEmbed(embeds, predicate = {}) { 28 | const embed = find(embeds, predicate); 29 | if (!embed) return; 30 | delete embeds[embed.id]; 31 | return embed; 32 | } 33 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-table/index.js: -------------------------------------------------------------------------------- 1 | import { addCell, addEmbed } from './edit/utils'; 2 | import { createId as cuid } from '@paralleldrive/cuid2'; 3 | import Edit from './edit/index.vue'; 4 | import times from 'lodash/times'; 5 | import Toolbar from './edit/Toolbar.vue'; 6 | 7 | const initState = () => { 8 | const tableId = cuid(); 9 | const embeds = {}; 10 | const rows = {}; 11 | times(2, position => { 12 | const rowId = cuid(); 13 | const row = { id: rowId, position, cells: {} }; 14 | rows[rowId] = row; 15 | times(3, position => { 16 | const cellId = cuid(); 17 | addCell(row, { id: cellId, position }); 18 | addEmbed(embeds, cellId, tableId); 19 | }); 20 | }); 21 | return { tableId, embeds, rows }; 22 | }; 23 | 24 | export default { 25 | name: 'Table', 26 | type: 'TABLE', 27 | version: '1.0', 28 | initState, 29 | Edit, 30 | Toolbar, 31 | ui: { 32 | icon: 'mdi-table', 33 | forceFullWidth: true 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-text-response/edit/index.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 38 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-text-response/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import Edit from './edit/index.vue'; 3 | 4 | const schema = { 5 | correct: yup.string().trim().max(7000).required().label('Answer') 6 | }; 7 | 8 | const initState = () => ({ 9 | correct: '' 10 | }); 11 | 12 | export default { 13 | name: 'Text Response', 14 | type: 'QUESTION', 15 | subtype: 'TR', 16 | version: '1.0', 17 | schema, 18 | initState, 19 | Edit, 20 | ui: { 21 | forceFullWidth: true 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-true-false/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import Edit from './edit/index.vue'; 3 | 4 | const MESSAGE = 'Please choose the correct answer'; 5 | 6 | const schema = { 7 | correct: yup.boolean().required(MESSAGE).typeError(MESSAGE) 8 | }; 9 | 10 | const initState = () => ({ 11 | correct: null 12 | }); 13 | 14 | export default { 15 | name: 'True - False', 16 | type: 'QUESTION', 17 | subtype: 'TF', 18 | version: '1.0', 19 | schema, 20 | initState, 21 | Edit, 22 | ui: { 23 | forceFullWidth: true 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /client/components/content-elements/tce-video/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './edit/index.vue'; 2 | import Toolbar from './edit/Toolbar.vue'; 3 | 4 | const initState = () => ({ url: null }); 5 | 6 | export default { 7 | name: 'Video', 8 | type: 'VIDEO', 9 | version: '1.0', 10 | initState, 11 | Edit, 12 | Toolbar, 13 | ui: { 14 | icon: 'mdi-video', 15 | forceFullWidth: false 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /client/components/editor/ActivityContent/Loader.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 23 | 24 | 29 | -------------------------------------------------------------------------------- /client/components/editor/Sidebar/ElementSidebar/ElementMeta/Inputs.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 35 | -------------------------------------------------------------------------------- /client/components/editor/Sidebar/ElementSidebar/ElementMeta/Relationships/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 37 | -------------------------------------------------------------------------------- /client/components/editor/Sidebar/ElementSidebar/ElementMeta/index.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 28 | -------------------------------------------------------------------------------- /client/components/editor/Sidebar/ElementSidebar/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 30 | 31 | 40 | -------------------------------------------------------------------------------- /client/components/editor/Toolbar/DefaultToolbar.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-checkbox/Edit.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | 23 | 39 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-checkbox/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './Edit.vue'; 2 | 3 | export default { 4 | type: 'CHECKBOX', 5 | version: '1.0', 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-color/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './Edit/index.vue'; 2 | 3 | export default { 4 | type: 'COLOR', 5 | version: '1.0', 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-combobox/Edit.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 35 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-combobox/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './Edit.vue'; 2 | 3 | export default { 4 | type: 'COMBOBOX', 5 | version: '1.0', 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-datetime/Edit/TimePicker.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 36 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-datetime/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './Edit/index.vue'; 2 | 3 | export default { 4 | type: 'DATETIME', 5 | version: '1.0', 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-file/Edit.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 33 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-file/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './Edit.vue'; 2 | 3 | export default { 4 | type: 'FILE', 5 | version: '1.0', 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-html/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './Edit.vue'; 2 | 3 | export default { 4 | type: 'HTML', 5 | version: '1.0', 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-input/Edit.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 48 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-input/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './Edit.vue'; 2 | 3 | export default { 4 | type: 'INPUT', 5 | version: '1.0', 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-select/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './Edit.vue'; 2 | 3 | export default { 4 | type: 'SELECT', 5 | version: '1.0', 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-switch/Edit.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 21 | 22 | 29 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-switch/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './Edit.vue'; 2 | 3 | export default { 4 | type: 'SWITCH', 5 | version: '1.0', 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-textarea/Edit.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 46 | -------------------------------------------------------------------------------- /client/components/meta-inputs/meta-textarea/index.js: -------------------------------------------------------------------------------- 1 | import Edit from './Edit.vue'; 2 | 3 | export default { 4 | type: 'TEXTAREA', 5 | version: '1.0', 6 | Edit 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/repository/Outline/OutlineFooter.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 39 | -------------------------------------------------------------------------------- /client/components/repository/Outline/icons/AddAbove.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /client/components/repository/Outline/icons/AddBelow.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /client/components/repository/Outline/icons/AddInto.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /client/components/repository/Outline/icons/index.js: -------------------------------------------------------------------------------- 1 | import AddAbove from './AddAbove.vue'; 2 | import AddBelow from './AddBelow.vue'; 3 | import AddInto from './AddInto.vue'; 4 | 5 | export default { 6 | AddAbove, 7 | AddBelow, 8 | AddInto 9 | }; 10 | -------------------------------------------------------------------------------- /client/components/repository/Outline/reorderMixin.js: -------------------------------------------------------------------------------- 1 | import { mapActions } from 'vuex'; 2 | 3 | export default { 4 | methods: { 5 | ...mapActions('repository/activities', { updatePosition: 'reorder' }), 6 | reorder({ newIndex: newPosition }, items) { 7 | const activity = items[newPosition]; 8 | const context = { items, newPosition }; 9 | this.updatePosition({ activity, context }); 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /client/components/repository/Settings/UserManagement/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 29 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/Overview/Assignee.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/Overview/DueDate.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/Overview/Name.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/Overview/Priority.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 20 | 21 | 26 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/Overview/Status.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/SelectStatus.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | 26 | 31 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/Sidebar/ActivityCard.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 37 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/icons/PriorityCritical.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 24 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/icons/PriorityHigh.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 24 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/icons/PriorityLow.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 24 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/icons/PriorityMedium.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/icons/PriorityTrivial.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /client/components/repository/Workflow/icons/index.js: -------------------------------------------------------------------------------- 1 | import PriorityCritical from './PriorityCritical.vue'; 2 | import PriorityHigh from './PriorityHigh.vue'; 3 | import PriorityLow from './PriorityLow.vue'; 4 | import PriorityMedium from './PriorityMedium.vue'; 5 | import PriorityTrivial from './PriorityTrivial.vue'; 6 | 7 | export default { 8 | PriorityCritical, 9 | PriorityHigh, 10 | PriorityLow, 11 | PriorityMedium, 12 | PriorityTrivial 13 | }; 14 | -------------------------------------------------------------------------------- /client/components/repository/common/AssigneeAvatar.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 33 | -------------------------------------------------------------------------------- /client/components/repository/common/LabelChip.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /client/components/repository/common/SelectPriority.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 39 | 40 | 45 | -------------------------------------------------------------------------------- /client/components/repository/common/selectActivity.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import { mapGetters } from 'vuex'; 3 | 4 | export default { 5 | computed: mapGetters('repository', ['selectedActivity']), 6 | methods: { 7 | selectActivity(activityId) { 8 | if (get(this.selectedActivity, 'id') === activityId) return; 9 | this.$router.replace({ query: { ...this.$route.query, activityId } }); 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /client/components/system-settings/index.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 39 | 40 | 53 | -------------------------------------------------------------------------------- /client/content-plugins/ContainerRegistry.js: -------------------------------------------------------------------------------- 1 | import ComponentRegistry from './ComponentRegistry'; 2 | import containerList from 'shared/core-containers'; 3 | import get from 'lodash/get'; 4 | import { getContainerName as getName } from '@tailor-cms/utils'; 5 | import { schema } from 'tailor-config'; 6 | import { service as ValidationService } from './validation'; 7 | 8 | const { getContainerTemplateId: getId } = schema; 9 | 10 | const getTemplateMessage = name => ` 11 | For container ${name} using deprecated 'type' identification! 12 | Use 'templateId' instead! 13 | `; 14 | 15 | const getElementsMessage = name => ` 16 | For container ${name} using deprecated 'tes' prop! 17 | Use 'elements' instead! 18 | `; 19 | 20 | const validator = ({ Edit: template, templateId, type }) => { 21 | const name = templateId || type; 22 | 23 | ValidationService 24 | .validate(!templateId, getTemplateMessage(name)) 25 | .validate(get(template, 'props.tes'), getElementsMessage(name)); 26 | }; 27 | 28 | export default Vue => new ComponentRegistry(Vue, { 29 | name: 'content container', 30 | extensions: containerList, 31 | attrs: ['type', 'templateId', 'version'], 32 | getCondition: id => it => getId(it) === id, 33 | validator, 34 | getName 35 | }); 36 | -------------------------------------------------------------------------------- /client/content-plugins/ElementRegistry.js: -------------------------------------------------------------------------------- 1 | import ComponentRegistry from './ComponentRegistry'; 2 | import elementList from 'shared/core-elements'; 3 | import { getComponentName as getName } from '@tailor-cms/utils'; 4 | 5 | const getCondition = type => it => it.subtype === type || it.type === type; 6 | 7 | export default Vue => new ComponentRegistry(Vue, { 8 | name: 'content element', 9 | extensions: elementList, 10 | attrs: ['name', 'type', 'subtype', 'version', 'schema', 'initState', 'ui'], 11 | getCondition, 12 | getName 13 | }); 14 | -------------------------------------------------------------------------------- /client/content-plugins/MetaRegistry.js: -------------------------------------------------------------------------------- 1 | import ComponentRegistry from './ComponentRegistry'; 2 | import { getMetaName as getName } from '@tailor-cms/utils'; 3 | import inputsList from 'shared/core-meta'; 4 | 5 | export default Vue => new ComponentRegistry(Vue, { 6 | name: 'meta input', 7 | extensions: inputsList, 8 | attrs: ['type', 'version'], 9 | getCondition: type => it => it.type === type, 10 | getName 11 | }); 12 | -------------------------------------------------------------------------------- /client/content-plugins/index.js: -------------------------------------------------------------------------------- 1 | import ContainerRegistry from './ContainerRegistry'; 2 | import ElementRegistry from './ElementRegistry'; 3 | import MetaRegistry from './MetaRegistry'; 4 | 5 | export default class ContentRepository { 6 | constructor(Vue) { 7 | this.containerRegistry = ContainerRegistry(Vue); 8 | this.elementRegistry = ElementRegistry(Vue); 9 | this.metaRegistry = MetaRegistry(Vue); 10 | } 11 | 12 | initialize() { 13 | return Promise.all([ 14 | this.containerRegistry.initialize(), 15 | this.elementRegistry.initialize(), 16 | this.metaRegistry.initialize() 17 | ]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/content-plugins/validation/ValidationService.js: -------------------------------------------------------------------------------- 1 | import types from './types'; 2 | 3 | class ValidationService { 4 | validate(condition, message, type = types.WARNING) { 5 | if (condition) console[type](message); 6 | return this; 7 | } 8 | } 9 | 10 | export default new ValidationService(); 11 | -------------------------------------------------------------------------------- /client/content-plugins/validation/index.js: -------------------------------------------------------------------------------- 1 | import service from './ValidationService'; 2 | import types from './types'; 3 | 4 | export { 5 | service, 6 | types 7 | }; 8 | -------------------------------------------------------------------------------- /client/content-plugins/validation/types.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ERROR: 'error', 3 | WARNING: 'warn' 4 | }; 5 | -------------------------------------------------------------------------------- /client/filters.js: -------------------------------------------------------------------------------- 1 | import _truncate from 'lodash/truncate'; 2 | import fecha from 'fecha'; 3 | 4 | const DEFAULT_DATE_FORMAT = 'MM/DD/YY HH:mm'; 5 | 6 | const isObject = arg => arg !== null && typeof arg === 'object'; 7 | 8 | export function formatDate(value, dateFormat = DEFAULT_DATE_FORMAT) { 9 | return value && fecha.format(new Date(value), dateFormat); 10 | } 11 | 12 | export function truncate(value, config) { 13 | config = isObject(config) ? config : { length: config }; 14 | return value && _truncate(value, config); 15 | } 16 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= title %> 9 | 10 | 11 | 12 | 13 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /client/plugins/vuetify-snackbar/Snackbar.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 54 | -------------------------------------------------------------------------------- /client/plugins/vuetify-snackbar/index.js: -------------------------------------------------------------------------------- 1 | import debounce from 'lodash/debounce'; 2 | import Queue from 'promise-queue'; 3 | import Snackbar from './Snackbar.vue'; 4 | 5 | const queue = new Queue(1, Infinity); 6 | 7 | export const install = Vue => { 8 | const SnackbarCtor = Vue.extend(Snackbar); 9 | const vm = new SnackbarCtor(); 10 | 11 | Vue.mixin({ 12 | mounted() { 13 | if (this.$options.name !== 'v-app') return; 14 | vm.$vuetify = this.$root.$vuetify; 15 | this.$el.appendChild(vm.$mount().$el); 16 | } 17 | }); 18 | 19 | const toQueue = (msg, opts) => queue.add(() => vm.show(msg, opts)); 20 | const debouncedQueue = debounce(toQueue, 2500); 21 | const show = (msg, opts) => { 22 | return (opts && opts.immediate ? toQueue : debouncedQueue)(msg, opts); 23 | }; 24 | 25 | const $snackbar = { 26 | show: (msg, opts) => show(msg, opts), 27 | close: () => vm.close, 28 | success: (msg, opts) => show(msg, { ...opts, color: 'success' }), 29 | info: (msg, opts) => show(msg, { ...opts, color: 'info' }), 30 | warning: (msg, opts) => show(msg, { ...opts, color: 'warning' }), 31 | error: (msg, opts) => show(msg, { ...opts, color: 'error' }) 32 | }; 33 | Object.assign(Vue.prototype, { $snackbar }); 34 | }; 35 | 36 | export default install; 37 | -------------------------------------------------------------------------------- /client/polyfills.js: -------------------------------------------------------------------------------- 1 | import '@ungap/global-this'; 2 | import 'core-js/stable'; 3 | import 'regenerator-runtime/runtime'; 4 | import 'dom-shims/shim/Element.classList'; 5 | import 'dom-shims/shim/Element.mutation'; 6 | import { EventSource } from 'event-source-polyfill'; 7 | globalThis.EventSource = EventSource; 8 | -------------------------------------------------------------------------------- /client/settings.js: -------------------------------------------------------------------------------- 1 | export default { 2 | debug: { 3 | state: false 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /client/store/helpers/mutations.js: -------------------------------------------------------------------------------- 1 | import castArray from 'lodash/castArray'; 2 | import each from 'lodash/each'; 3 | import omit from 'lodash/omit'; 4 | import { uuid } from '@tailor-cms/utils'; 5 | import Vue from 'vue'; 6 | 7 | export const fetch = (state, items) => { 8 | each(items, it => Vue.set(state.items, it.uid, it)); 9 | }; 10 | 11 | export const reset = (state, items = {}) => { 12 | state.items = items; 13 | }; 14 | 15 | export const add = (state, model) => { 16 | const existing = state.items[model.uid]; 17 | if (existing) return update(state, model); 18 | const uid = uuid(); 19 | Vue.set(state.items, uid, { ...model, uid }); 20 | }; 21 | 22 | export const update = (state, model) => { 23 | const existing = state.items[model.uid]; 24 | if (!existing) return; 25 | Vue.set(state.items, existing.uid, { ...existing, ...omit(model, 'uid') }); 26 | }; 27 | 28 | export const save = (state, models) => { 29 | castArray(models).forEach(model => { 30 | const existing = state.items[model.uid]; 31 | if (existing) return update(state, model); 32 | Vue.set(state.items, model.uid, model); 33 | }); 34 | }; 35 | 36 | export const remove = (state, models) => { 37 | models.forEach(it => Vue.delete(state.items, it.uid)); 38 | }; 39 | 40 | export const setEndpoint = (state, url) => { 41 | state.$apiUrl = url; 42 | }; 43 | -------------------------------------------------------------------------------- /client/store/index.js: -------------------------------------------------------------------------------- 1 | import auth from './modules/auth/index.js'; 2 | import createLogger from 'vuex/dist/logger.js'; 3 | import editor from './modules/editor/index.js'; 4 | import plugins from './plugins/index.js'; 5 | import repositories from './modules/repositories/index.js'; 6 | import repository from './modules/repository/index.js'; 7 | import settings from '../settings.js'; 8 | import Vue from 'vue'; 9 | import Vuex from 'vuex'; 10 | 11 | Vue.use(Vuex); 12 | 13 | const isDevEnv = process.env.NODE_ENV !== 'production'; 14 | const middlewares = settings.debug.state && isDevEnv ? [createLogger()] : []; 15 | 16 | const modules = { 17 | auth, 18 | repository, 19 | repositories, 20 | editor 21 | }; 22 | 23 | const store = new Vuex.Store({ 24 | middlewares, 25 | modules, 26 | plugins, 27 | strict: false 28 | }); 29 | 30 | export default function getStore() { 31 | return hydrateUserStore().then(() => store); 32 | } 33 | 34 | function hydrateUserStore() { 35 | const authRefresh = Vue.oidc.enabled && Vue.oidc.logoutEnabled 36 | ? Vue.oidc.slientlyRefresh().catch(() => {}) 37 | : Promise.resolve(); 38 | 39 | return authRefresh.then(() => store.dispatch('fetchUserInfo')); 40 | } 41 | -------------------------------------------------------------------------------- /client/store/modules/auth/actions.js: -------------------------------------------------------------------------------- 1 | import { auth as api } from '@/api'; 2 | 3 | export const login = ({ commit }, credentials) => { 4 | return api.login(credentials) 5 | .then(({ data: { user, authData } }) => commit('setAuth', { user, authData })); 6 | }; 7 | 8 | export const logout = ({ commit }) => { 9 | return api.logout() 10 | .then(() => commit('resetAuth')); 11 | }; 12 | 13 | export const changePassword = (_, { currentPassword, newPassword }) => { 14 | return api.changePassword(currentPassword, newPassword); 15 | }; 16 | 17 | export const fetchUserInfo = ({ commit }) => { 18 | return api.getUserInfo() 19 | .then(({ data: { user, authData } }) => commit('setAuth', { user, authData })) 20 | .catch(() => commit('resetAuth')); 21 | }; 22 | 23 | export const updateInfo = ({ commit }, userData) => { 24 | return api.updateUserInfo(userData) 25 | .then(({ data: { user } }) => commit('setUser', user)); 26 | }; 27 | -------------------------------------------------------------------------------- /client/store/modules/auth/getters.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import { role } from 'shared'; 3 | 4 | export const isAdmin = ({ user }) => get(user, 'role') === role.user.ADMIN; 5 | 6 | export const isOidcActive = ({ authData }) => authData?.strategy === 'oidc'; 7 | -------------------------------------------------------------------------------- /client/store/modules/auth/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import * as getters from './getters'; 3 | import * as mutations from './mutations'; 4 | 5 | const state = { 6 | user: null, 7 | authData: null 8 | }; 9 | 10 | export default { 11 | namespaced: false, 12 | state, 13 | getters, 14 | actions, 15 | mutations 16 | }; 17 | -------------------------------------------------------------------------------- /client/store/modules/auth/mutations.js: -------------------------------------------------------------------------------- 1 | export const setUser = (state, user) => { 2 | state.user = user; 3 | }; 4 | 5 | export const setAuth = (state, { user, authData }) => { 6 | state.user = user; 7 | state.authData = authData; 8 | }; 9 | 10 | export const resetAuth = state => { 11 | state.user = null; 12 | state.authData = null; 13 | }; 14 | -------------------------------------------------------------------------------- /client/store/modules/editor/index.js: -------------------------------------------------------------------------------- 1 | import * as getters from './getters'; 2 | import * as mutations from './mutations'; 3 | 4 | const state = { 5 | showPublishDiff: false 6 | }; 7 | 8 | export default { 9 | namespaced: true, 10 | state, 11 | getters, 12 | mutations 13 | }; 14 | -------------------------------------------------------------------------------- /client/store/modules/editor/mutations.js: -------------------------------------------------------------------------------- 1 | const togglePublishDiff = (state, showPublishDiff) => { 2 | state.showPublishDiff = showPublishDiff ?? !state.showPublishDiff; 3 | }; 4 | 5 | export { togglePublishDiff }; 6 | -------------------------------------------------------------------------------- /client/store/modules/repositories/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import * as getters from './getters'; 3 | import * as mutations from './mutations'; 4 | 5 | const PAGINATION_DEFAULTS = { offset: 0, limit: 21 }; 6 | 7 | const state = { 8 | items: {}, 9 | search: '', 10 | showPinned: false, 11 | tags: [], 12 | repositoryFilter: [], 13 | $internals: { 14 | pagination: PAGINATION_DEFAULTS, 15 | sort: { 16 | order: 'DESC', 17 | field: 'createdAt' 18 | }, 19 | allRepositoriesFetched: false 20 | } 21 | }; 22 | 23 | export default { 24 | namespaced: true, 25 | state, 26 | getters, 27 | actions, 28 | mutations 29 | }; 30 | -------------------------------------------------------------------------------- /client/store/modules/repository/activities/getters.js: -------------------------------------------------------------------------------- 1 | import { activity as activityUtils } from '@tailor-cms/utils'; 2 | import find from 'lodash/find'; 3 | 4 | const { 5 | getDescendants: getDeepChildren, 6 | getAncestors: getParents 7 | } = activityUtils; 8 | 9 | export const activities = state => state.items; 10 | 11 | export const getParent = state => { 12 | return id => { 13 | const activity = find(state.items, { id }); 14 | return activity ? find(state.items, { id: activity.parentId }) : null; 15 | }; 16 | }; 17 | 18 | export const getDescendants = state => { 19 | return activity => getDeepChildren(state.items, activity); 20 | }; 21 | 22 | export const getAncestors = state => { 23 | return activity => getParents(state.items, activity); 24 | }; 25 | 26 | export const getLineage = state => { 27 | return activity => { 28 | const ancestors = getParents(state.items, activity); 29 | const descendants = getDeepChildren(state.items, activity); 30 | return [...ancestors, ...descendants]; 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /client/store/modules/repository/activities/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import * as getters from './getters'; 3 | import * as mutations from './mutations'; 4 | 5 | const state = { 6 | items: {}, 7 | $internals: {}, 8 | $apiUrl: null 9 | }; 10 | 11 | export default { 12 | namespaced: true, 13 | state, 14 | getters, 15 | actions, 16 | mutations 17 | }; 18 | -------------------------------------------------------------------------------- /client/store/modules/repository/activities/mutations.js: -------------------------------------------------------------------------------- 1 | import { 2 | add, fetch, remove, reset, save, setEndpoint 3 | } from '@/store/helpers/mutations'; 4 | 5 | const reorder = (state, { activity, position }) => { 6 | state.items[activity.uid].position = position; 7 | }; 8 | 9 | export { add, fetch, remove, reorder, reset, save, setEndpoint }; 10 | -------------------------------------------------------------------------------- /client/store/modules/repository/comments/actions.js: -------------------------------------------------------------------------------- 1 | import { Comment as Events } from '@/../common/sse'; 2 | import feed from '../feed'; 3 | import generateActions from '@/store/helpers/actions'; 4 | 5 | const { api, get, save, setEndpoint, update } = generateActions(); 6 | 7 | const plugSSE = ({ commit }) => { 8 | feed 9 | .subscribe(Events.Create, item => commit('save', item)) 10 | .subscribe(Events.Update, item => commit('update', item)) 11 | .subscribe(Events.Delete, item => commit('update', item)); 12 | }; 13 | 14 | const fetch = ({ commit }, payload) => { 15 | return api.fetch(payload) 16 | .then(items => commit('fetch', items)); 17 | }; 18 | 19 | const updateResolvement = ({ dispatch }, params) => { 20 | return api.post('/resolve', params) 21 | .then(() => dispatch('fetch', params)); 22 | }; 23 | 24 | const remove = ({ commit }, comment) => { 25 | comment.deletedAt = new Date(); 26 | commit('save', comment); 27 | return api.remove(comment); 28 | }; 29 | 30 | export { 31 | fetch, 32 | get, 33 | plugSSE, 34 | remove, 35 | save, 36 | setEndpoint, 37 | update, 38 | updateResolvement 39 | }; 40 | -------------------------------------------------------------------------------- /client/store/modules/repository/comments/getters.js: -------------------------------------------------------------------------------- 1 | import filter from 'lodash/filter'; 2 | import get from 'lodash/get'; 3 | import orderBy from 'lodash/orderBy'; 4 | 5 | export const getComments = state => params => { 6 | if (params.contentElementId) params.resolvedAt = null; 7 | const comments = filter(state.items, params); 8 | return orderBy(comments, 'createdAt', 'desc'); 9 | }; 10 | 11 | export const getUnseenActivityComments = ({ seen }, getters, { auth }) => activity => { 12 | const activityComments = getters.getComments({ activityId: activity.id }); 13 | const activitySeenAt = get(seen.activity, activity.uid, 0); 14 | return filter(activityComments, it => { 15 | const isAuthor = it.author.id === auth.user.id; 16 | const createdAt = new Date(it.createdAt).getTime(); 17 | if (isAuthor || activitySeenAt >= createdAt) return; 18 | if (!it.contentElement) return true; 19 | // Return unseen activity comment if contentElement is not set 20 | const elementSeenAt = get(seen.contentElement, it.contentElement.uid, 0); 21 | return elementSeenAt < createdAt; 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /client/store/modules/repository/comments/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import * as getters from './getters'; 3 | import * as mutations from './mutations'; 4 | 5 | const state = { 6 | items: {}, 7 | seen: { 8 | activity: {}, 9 | contentElement: {} 10 | }, 11 | $apiUrl: null 12 | }; 13 | 14 | export default { 15 | namespaced: true, 16 | state, 17 | getters, 18 | actions, 19 | mutations 20 | }; 21 | -------------------------------------------------------------------------------- /client/store/modules/repository/comments/mutations.js: -------------------------------------------------------------------------------- 1 | import { 2 | add, 3 | fetch, 4 | remove, 5 | reset, 6 | save, 7 | setEndpoint, 8 | update 9 | } from '@/store/helpers/mutations'; 10 | 11 | const markSeenComments = ({ seen }, payload) => { 12 | const { activityUid, elementUid, lastCommentAt } = payload; 13 | const key = elementUid ? 'contentElement' : 'activity'; 14 | seen[key] = { 15 | ...seen[key], 16 | [elementUid || activityUid]: lastCommentAt 17 | }; 18 | }; 19 | 20 | export { 21 | add, 22 | fetch, 23 | markSeenComments, 24 | remove, 25 | reset, 26 | save, 27 | setEndpoint, 28 | update 29 | }; 30 | -------------------------------------------------------------------------------- /client/store/modules/repository/content-elements/getters.js: -------------------------------------------------------------------------------- 1 | export const elements = state => state.items; 2 | -------------------------------------------------------------------------------- /client/store/modules/repository/content-elements/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import * as getters from './getters'; 3 | import * as mutations from './mutations'; 4 | 5 | const state = { 6 | items: {}, 7 | $internals: {}, 8 | $apiUrl: null 9 | }; 10 | 11 | export default { 12 | namespaced: true, 13 | state, 14 | getters, 15 | actions, 16 | mutations 17 | }; 18 | -------------------------------------------------------------------------------- /client/store/modules/repository/content-elements/mutations.js: -------------------------------------------------------------------------------- 1 | import { 2 | add, 3 | fetch, 4 | remove, 5 | reset, 6 | save, 7 | setEndpoint, 8 | update 9 | } from '@/store/helpers/mutations'; 10 | 11 | const reorder = (state, { element, position }) => { 12 | state.items[element.uid].position = position; 13 | }; 14 | 15 | export { add, fetch, remove, reorder, reset, save, setEndpoint, update }; 16 | -------------------------------------------------------------------------------- /client/store/modules/repository/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import * as getters from './getters'; 3 | import * as mutations from './mutations'; 4 | import activities from './activities'; 5 | import comments from './comments'; 6 | import contentElements from './content-elements'; 7 | import revisions from './revisions'; 8 | import userTracking from './user-tracking'; 9 | 10 | const state = { 11 | repositoryId: null, 12 | sseId: null, 13 | users: {}, 14 | userCount: 0, 15 | outline: { expanded: {} }, 16 | $apiUrl: null 17 | }; 18 | 19 | export default { 20 | namespaced: true, 21 | state, 22 | getters, 23 | actions, 24 | mutations, 25 | modules: { 26 | activities, 27 | comments, 28 | contentElements, 29 | revisions, 30 | userTracking 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /client/store/modules/repository/revisions/actions.js: -------------------------------------------------------------------------------- 1 | import generateActions from '@/store/helpers/actions'; 2 | 3 | const { api, get, remove, reset, save, setEndpoint } = generateActions(); 4 | const PAGINATION_DEFAULTS = { offset: 0, limit: 25 }; 5 | 6 | const fetch = ({ getters: { revisionQueryParams: params }, commit }) => { 7 | return api.fetch(params).then(revisions => { 8 | commit('setPagination', { offset: params.offset + params.limit }); 9 | commit('allRevisionsFetched', Object.keys(revisions).length < params.limit); 10 | commit('fetch', revisions); 11 | }); 12 | }; 13 | 14 | const resetPagination = ({ commit }) => { 15 | commit('setPagination', PAGINATION_DEFAULTS); 16 | commit('reset'); 17 | }; 18 | 19 | export { get, fetch, remove, reset, resetPagination, save, setEndpoint }; 20 | -------------------------------------------------------------------------------- /client/store/modules/repository/revisions/getters.js: -------------------------------------------------------------------------------- 1 | export const revisionQueryParams = state => state.$internals.pagination; 2 | 3 | export const hasMoreResults = state => !state.$internals.allRevisionsFetched; 4 | 5 | export const items = state => { 6 | return Object.values(state.items) 7 | .map(rev => ({ ...rev, createdAt: new Date(rev.createdAt) })) 8 | .sort((rev1, rev2) => rev2.createdAt - rev1.createdAt); 9 | }; 10 | -------------------------------------------------------------------------------- /client/store/modules/repository/revisions/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import * as getters from './getters'; 3 | import * as mutations from './mutations'; 4 | 5 | const PAGINATION_DEFAULTS = { offset: 0, limit: 25 }; 6 | 7 | const state = { 8 | items: {}, 9 | $internals: { 10 | pagination: PAGINATION_DEFAULTS, 11 | allRevisionsFetched: false 12 | } 13 | }; 14 | 15 | export default { 16 | namespaced: true, 17 | state, 18 | getters, 19 | actions, 20 | mutations 21 | }; 22 | -------------------------------------------------------------------------------- /client/store/modules/repository/revisions/mutations.js: -------------------------------------------------------------------------------- 1 | import { add, fetch, reset, save, setEndpoint } from '@/store/helpers/mutations'; 2 | 3 | const setPagination = (state, changes) => { 4 | const $internals = state.$internals; 5 | $internals.pagination = { ...$internals.pagination, ...changes }; 6 | }; 7 | 8 | const allRevisionsFetched = (state, allFetched) => { 9 | state.$internals.allRevisionsFetched = allFetched; 10 | }; 11 | 12 | export { 13 | add, 14 | allRevisionsFetched, 15 | fetch, 16 | reset, 17 | save, 18 | setEndpoint, 19 | setPagination 20 | }; 21 | -------------------------------------------------------------------------------- /client/store/modules/repository/user-tracking/actions.js: -------------------------------------------------------------------------------- 1 | import { feed as api } from '@/api'; 2 | import feed from '../feed'; 3 | import { UserActivity } from '@/../common/sse'; 4 | 5 | const plugSSE = ({ commit }) => { 6 | feed 7 | .subscribe(UserActivity.Start, 8 | ({ user, context }) => commit('start', { user, context })) 9 | .subscribe(UserActivity.End, 10 | ({ user, context }) => commit('end', { user, context })) 11 | .subscribe(UserActivity.EndSession, 12 | ({ sseId, userId }) => commit('endSession', { sseId, userId })); 13 | }; 14 | 15 | const start = ({ rootState }, context) => { 16 | const { sseId, repositoryId } = rootState.repository; 17 | return api.start({ sseId, repositoryId, ...context }); 18 | }; 19 | 20 | const end = ({ rootState }, context) => { 21 | const { sseId, repositoryId } = rootState.repository; 22 | return api.end({ sseId, repositoryId, ...context }); 23 | }; 24 | 25 | const fetch = ({ commit }, repositoryId) => { 26 | return api.fetch(repositoryId).then(({ items }) => commit('fetch', items)); 27 | }; 28 | 29 | export { 30 | plugSSE, 31 | fetch, 32 | start, 33 | end 34 | }; 35 | -------------------------------------------------------------------------------- /client/store/modules/repository/user-tracking/getters.js: -------------------------------------------------------------------------------- 1 | import each from 'lodash/each'; 2 | import find from 'lodash/find'; 3 | import orderBy from 'lodash/orderBy'; 4 | 5 | export const getActiveUsers = (_state, getters, rootState) => { 6 | const { auth: { user: currentUser } } = rootState; 7 | return (entity, entityId) => { 8 | const users = getters.activityByEntity[entity][entityId] || []; 9 | return orderBy(users, 'connectedAt', 'desc') 10 | .filter(it => it.email !== currentUser.email); 11 | }; 12 | }; 13 | 14 | export const activityByEntity = state => { 15 | return Object.values(state.users).reduce((acc, { contexts, ...user }) => { 16 | contexts.forEach(ctx => setUserContext(acc, user, ctx)); 17 | return acc; 18 | }, { repository: {}, activity: {}, element: {} }); 19 | }; 20 | 21 | function setUserContext(state, user, context) { 22 | const mappings = { 23 | repository: context.repositoryId, 24 | activity: context.activityId, 25 | element: context.elementId 26 | }; 27 | each(mappings, (id, type) => { 28 | if (!id) return; 29 | const entity = state[type][id]; 30 | if (!entity) { 31 | state[type][id] = [user]; 32 | return; 33 | } 34 | if (find(entity, { id: user.id })) return; 35 | entity.push(user); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /client/store/modules/repository/user-tracking/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import * as getters from './getters'; 3 | import * as mutations from './mutations'; 4 | 5 | const state = { 6 | users: {} 7 | }; 8 | 9 | export default { 10 | namespaced: true, 11 | state, 12 | getters, 13 | actions, 14 | mutations 15 | }; 16 | -------------------------------------------------------------------------------- /client/store/plugins/index.js: -------------------------------------------------------------------------------- 1 | import vuexPersist from './vuex-persist'; 2 | export default [vuexPersist]; 3 | -------------------------------------------------------------------------------- /client/store/plugins/vuex-persist.js: -------------------------------------------------------------------------------- 1 | import isEmpty from 'lodash/isEmpty'; 2 | import VuexPersistence from 'vuex-persist'; 3 | 4 | const OBSERVED_MUTATIONS = [ 5 | 'repository/comments/markSeenComments' 6 | ]; 7 | 8 | const STORAGE_KEY = 'TAILOR_APP_STATE'; 9 | migrateSeenState(); 10 | 11 | export default new VuexPersistence({ 12 | key: STORAGE_KEY, 13 | reducer: ({ repository }) => ({ 14 | repository: { 15 | comments: { seen: repository.comments.seen } 16 | } 17 | }), 18 | storage: window.localStorage, 19 | filter: mutation => OBSERVED_MUTATIONS.includes(mutation.type) 20 | }).plugin; 21 | 22 | function migrateSeenState() { 23 | const storage = window.localStorage; 24 | const state = JSON.parse(storage.getItem(STORAGE_KEY)); 25 | if (!state) return; 26 | const { seenByActivity } = state?.repository?.comments || {}; 27 | if (!isEmpty(seenByActivity)) { 28 | state.repository.comments.seen = { activity: seenByActivity }; 29 | } 30 | if (seenByActivity) delete state.repository.comments.seenByActivity; 31 | storage.setItem(STORAGE_KEY, JSON.stringify(state)); 32 | } 33 | -------------------------------------------------------------------------------- /client/utils/InsertLocation.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ADD_BEFORE: 'ADD_BEFORE', 3 | ADD_AFTER: 'ADD_AFTER', 4 | ADD_INTO: 'ADD_INTO', 5 | REORDER: 'REORDER' 6 | }; 7 | -------------------------------------------------------------------------------- /client/utils/date.js: -------------------------------------------------------------------------------- 1 | import compareAsc from 'date-fns/compareAsc'; 2 | import fecha from 'fecha'; 3 | 4 | export function isAfterOrEqual(firstDate, secondDate) { 5 | return compareAsc(firstDate, secondDate) !== -1; 6 | } 7 | 8 | export function truncateTime(dateTime) { 9 | const format = 'YYYY-MM-DD'; 10 | const date = fecha.format(dateTime, format); 11 | return fecha.parse(date, format); 12 | } 13 | -------------------------------------------------------------------------------- /client/utils/paramsParser.js: -------------------------------------------------------------------------------- 1 | import mapValues from 'lodash/mapValues'; 2 | 3 | export const numeric = route => mapValues(route.params, Number); 4 | -------------------------------------------------------------------------------- /client/utils/repository.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | 3 | const REPOSITORY_COLORS = ['#689F38', '#FF5722', '#2196F3']; 4 | 5 | export function getColor(repository) { 6 | const meta = get(repository, 'data.color'); 7 | return meta || REPOSITORY_COLORS[(repository.id || 0) % 3]; 8 | } 9 | 10 | export function getAcronym(name) { 11 | const reducer = (acc, it) => it ? `${acc}${it[0].toUpperCase()}` : acc; 12 | return name.split(/\s/).reduce(reducer, '').substr(0, 2); 13 | } 14 | -------------------------------------------------------------------------------- /common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /common/sse.js: -------------------------------------------------------------------------------- 1 | export const Activity = { 2 | Create: 'activity:create', 3 | Update: 'activity:update', 4 | BulkUpdate: 'activity:bulk_update', 5 | Delete: 'activity:delete' 6 | }; 7 | 8 | export const Comment = { 9 | Create: 'comment:create', 10 | Update: 'comment:update', 11 | Delete: 'comment:delete' 12 | }; 13 | 14 | export const ContentElement = { 15 | Create: 'content_element:create', 16 | Update: 'content_element:update', 17 | Delete: 'content_element:delete' 18 | }; 19 | 20 | export const UserActivity = { 21 | Start: 'user_activity:start', 22 | End: 'user_activity:end', 23 | EndSession: 'user_activity:end_session' 24 | }; 25 | -------------------------------------------------------------------------------- /config/client/brand.loader.js: -------------------------------------------------------------------------------- 1 | import map from 'lodash/map.js'; 2 | import merge from 'lodash/merge.js'; 3 | 4 | const toScssVariable = (value, name) => `$${name}: ${value};`; 5 | const defaultConfig = { 6 | title: 'Tailor', 7 | logo: { 8 | compact: 'default-logo-compact.svg', 9 | full: 'default-logo-full.svg' 10 | }, 11 | favicon: 'default-favicon.ico', 12 | style: { 13 | brandColor: '#0D47A0', 14 | altBrandColor: '#5C6BC0' 15 | } 16 | }; 17 | 18 | async function loadConfig() { 19 | let userConfig; 20 | 21 | try { 22 | ({ default: userConfig } = await import('../../.brandrc.js')); 23 | } catch (err) { 24 | // Nothing to do, user config is not defined, so using default. 25 | } 26 | 27 | return merge({}, defaultConfig, userConfig); 28 | } 29 | 30 | const config = await loadConfig(); 31 | 32 | export const brandConfig = { 33 | title: config.title, 34 | favicon: `/${config.favicon}`, 35 | logo: { 36 | compact: `/${config.logo.compact}`, 37 | full: `/${config.logo.full}` 38 | } 39 | }; 40 | 41 | export const brandStyles = map({ ...config.style }, toScssVariable).join('\n'); 42 | -------------------------------------------------------------------------------- /config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /config/server/consumer.js: -------------------------------------------------------------------------------- 1 | const { env } = process; 2 | 3 | export const webhookUrl = env.CONSUMER_WEBHOOK_URL; 4 | 5 | export const clientId = env.CONSUMER_CLIENT_ID; 6 | 7 | export const clientSecret = env.CONSUMER_CLIENT_SECRET; 8 | 9 | export const tokenHost = env.CONSUMER_CLIENT_TOKEN_HOST; 10 | 11 | export const tokenPath = env.CONSUMER_CLIENT_TOKEN_PATH; 12 | 13 | export default { 14 | webhookUrl, 15 | clientId, 16 | clientSecret, 17 | tokenHost, 18 | tokenPath 19 | }; 20 | -------------------------------------------------------------------------------- /config/server/database.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | const config = { 4 | url: process.env.POSTGRES_URI, 5 | dialect: 'postgres' 6 | }; 7 | 8 | export const development = config; 9 | 10 | export const test = config; 11 | 12 | export const production = config; 13 | -------------------------------------------------------------------------------- /config/server/mail.js: -------------------------------------------------------------------------------- 1 | import yn from 'yn'; 2 | 3 | export const sender = { 4 | name: process.env.EMAIL_SENDER_NAME, 5 | address: process.env.EMAIL_SENDER_ADDRESS 6 | }; 7 | 8 | export const user = process.env.EMAIL_USER; 9 | 10 | export const password = process.env.EMAIL_PASSWORD; 11 | 12 | export const host = process.env.EMAIL_HOST; 13 | 14 | export const port = process.env.EMAIL_PORT || null; 15 | 16 | export const ssl = yn(process.env.EMAIL_SSL); 17 | 18 | export const tls = yn(process.env.EMAIL_TLS); 19 | -------------------------------------------------------------------------------- /config/server/store.js: -------------------------------------------------------------------------------- 1 | export const provider = process.env.STORE_PROVIDER || 'memory'; 2 | 3 | export const ttl = parseInt(process.env.STORE_TTL, 10) || 0; 4 | 5 | export const memory = {}; 6 | 7 | export const redis = { 8 | port: parseInt(process.env.REDIS_PORT, 10) || 6379, 9 | host: process.env.REDIS_HOST || 'localhost', 10 | password: process.env.REDIS_PASSWORD 11 | }; 12 | -------------------------------------------------------------------------------- /config/server/tce.js: -------------------------------------------------------------------------------- 1 | import camelCase from 'lodash/camelCase.js'; 2 | 3 | export const tceConfig = Object.keys(process.env) 4 | .map(it => it.match(/^TCE_(.*)/)) 5 | .filter(Boolean) 6 | .reduce((config, [prefixedKey, key]) => ({ 7 | ...config, 8 | [camelCase(key)]: process.env[prefixedKey] 9 | }), {}); 10 | 11 | export default tceConfig; 12 | -------------------------------------------------------------------------------- /config/shared/core-containers.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'tcc-default', 3 | 'tcc-exam', 4 | 'tcc-assessment-pool' 5 | ]; 6 | -------------------------------------------------------------------------------- /config/shared/core-elements.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'tce-jodit', 3 | 'tce-image', 4 | 'tce-video', 5 | 'tce-embed', 6 | 'tce-audio', 7 | 'tce-page-break', 8 | 'tce-scorm', 9 | 'tce-pdf', 10 | 'tce-accordion', 11 | 'tce-table', 12 | 'tce-modal', 13 | 'tce-carousel', 14 | 'tce-brightcove-video', 15 | 'tce-multiple-choice', 16 | 'tce-single-choice', 17 | 'tce-true-false', 18 | 'tce-text-response', 19 | 'tce-numerical-response', 20 | 'tce-fill-blank', 21 | 'tce-matching-question', 22 | 'tce-drag-drop', 23 | 'tce-html' 24 | ]; 25 | -------------------------------------------------------------------------------- /config/shared/core-meta.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'meta-checkbox', 3 | 'meta-color', 4 | 'meta-combobox', 5 | 'meta-datetime', 6 | 'meta-file', 7 | 'meta-html', 8 | 'meta-input', 9 | 'meta-select', 10 | 'meta-switch', 11 | 'meta-textarea' 12 | ]; 13 | -------------------------------------------------------------------------------- /config/shared/index.js: -------------------------------------------------------------------------------- 1 | export * as role from './role.js'; 2 | -------------------------------------------------------------------------------- /config/shared/role.js: -------------------------------------------------------------------------------- 1 | import values from 'lodash/values.js'; 2 | 3 | const role = { 4 | user: { USER: 'USER', ADMIN: 'ADMIN' }, 5 | repository: { ADMIN: 'ADMIN', AUTHOR: 'AUTHOR' } 6 | }; 7 | 8 | export const user = role.user; 9 | 10 | export const repository = role.repository; 11 | 12 | export const getRoleValues = type => values(role[type] || {}); 13 | 14 | export default role; 15 | -------------------------------------------------------------------------------- /config/shared/tailor.loader.js: -------------------------------------------------------------------------------- 1 | import { getSchemaApi, getWorkflowApi, processSchemas } from '@tailor-cms/config'; 2 | import config from '../../tailor.config.js'; 3 | 4 | const { SCHEMAS, WORKFLOWS } = config; 5 | 6 | processSchemas(SCHEMAS); 7 | 8 | const schema = getSchemaApi(SCHEMAS); 9 | const workflow = getWorkflowApi(WORKFLOWS, schema); 10 | 11 | export { 12 | SCHEMAS, 13 | WORKFLOWS, 14 | schema, 15 | workflow 16 | }; 17 | -------------------------------------------------------------------------------- /cypress.config.mjs: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { defineConfig } from 'cypress'; 3 | import serverConfig from './config/server/index.js'; 4 | 5 | export default defineConfig({ 6 | env: { 7 | USERNAME: process.env.CYPRESS_USERNAME, 8 | PASSWORD: process.env.CYPRESS_PASSWORD 9 | }, 10 | e2e: { 11 | baseUrl: serverConfig.origin, 12 | specPattern: 'cypress/e2e/**/*.cy.js', 13 | supportFile: 'cypress/support/e2e/index.js' 14 | }, 15 | viewportWidth: 1400, 16 | viewportHeight: 800 17 | }); 18 | -------------------------------------------------------------------------------- /cypress/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['@extensionengine', 'plugin:cypress/recommended'], 4 | plugins: ['eslint-plugin-cypress'], 5 | env: { 'cypress/globals': true }, 6 | parserOptions: { 7 | sourceType: 'module' 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /cypress/e2e/auth/sign-in.cy.js: -------------------------------------------------------------------------------- 1 | describe('Sign in view', () => { 2 | beforeEach(() => cy.visit('/')); 3 | 4 | it('should sign in an existing user', () => { 5 | cy.findByLabelText(/email/i) 6 | .type(Cypress.env('USERNAME')); 7 | cy.findByLabelText(/password/i) 8 | .type(Cypress.env('PASSWORD')); 9 | cy.findByRole('button', { name: /Log In$/i }) 10 | .click(); 11 | cy.location() 12 | .should(location => expect(location.hash).to.eq('#/')); 13 | }); 14 | 15 | it('should sign in an existing user by dispatching an action', () => { 16 | cy.login(); 17 | cy.visit('/') 18 | .then(() => cy.assertRoute('catalog')); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /cypress/e2e/catalog/access-repository.cy.js: -------------------------------------------------------------------------------- 1 | import { findRepositoryCard } from './utils'; 2 | 3 | describe('ability to access repository', () => { 4 | beforeEach(() => { 5 | cy.visit('/'); 6 | cy.login(); 7 | cy.createRepository().then(repo => { 8 | cy.wrap(repo).its('name').as('name'); 9 | }); 10 | cy.visit('/').then(() => cy.assertRoute('catalog')); 11 | }); 12 | 13 | it('should access repository', () => { 14 | cy.get('@name').then(name => { 15 | findRepositoryCard(name).click(); 16 | cy.assertRoute('repository'); 17 | }); 18 | }); 19 | 20 | it('should access repository settings', () => { 21 | cy.get('@name').then(name => { 22 | findRepositoryCard(name) 23 | .findByRole('button', { name: 'Repository settings' }) 24 | .click(); 25 | cy.assertRoute('repository-info'); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /cypress/e2e/catalog/create-repository.cy.js: -------------------------------------------------------------------------------- 1 | import { findRepositoryCard } from './utils'; 2 | 3 | const getDialog = () => cy.findByRole('dialog'); 4 | 5 | describe('create repository', () => { 6 | beforeEach(() => { 7 | cy.visit('/'); 8 | cy.login(); 9 | cy.visit('/').then(() => cy.assertRoute('catalog')); 10 | }); 11 | 12 | it('should create a new repository using the create dialog', () => { 13 | const repositoryName = `Test_repository_${(new Date()).getTime()}`; 14 | cy.findByRole('button', { name: 'Add repository' }).click(); 15 | getDialog().within(() => { 16 | cy.findByLabelText(/name/i) 17 | .type(repositoryName); 18 | cy.findByLabelText(/description/i) 19 | .type('Test description'); 20 | cy.findByRole('button', { name: /create/i }) 21 | .click(); 22 | }); 23 | findRepositoryCard(repositoryName); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /cypress/e2e/catalog/search-and-filter.cy.js: -------------------------------------------------------------------------------- 1 | import { findRepositoryCard, searchRepository } from './utils.js'; 2 | 3 | describe('ability to search and filter repository catalog', () => { 4 | beforeEach(() => { 5 | cy.visit('/'); 6 | cy.login(); 7 | cy.createRepository().then(repo => { 8 | cy.wrap(repo).its('name').as('name'); 9 | }); 10 | cy.visit('/').then(() => cy.assertRoute('catalog')); 11 | }); 12 | 13 | it('should be able to search for the repository', () => { 14 | cy.get('@name').then(name => { 15 | searchRepository(name); 16 | findRepositoryCard(name).should('have.length', 1); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /cypress/e2e/catalog/utils.js: -------------------------------------------------------------------------------- 1 | import { toTestIdAttr } from '../../utils'; 2 | 3 | export const sel = { 4 | card: 'catalog__repositoryCard' 5 | }; 6 | 7 | export const findRepositoryCard = val => cy.contains(toTestIdAttr(sel.card), val); 8 | export const searchRepository = val => cy.findByPlaceholderText(/search/i).type(val); 9 | -------------------------------------------------------------------------------- /cypress/e2e/repository/structure/utils.js: -------------------------------------------------------------------------------- 1 | import { toTestIdAttr } from '../../../utils'; 2 | 3 | const sel = { 4 | activityItem: 'repository__structureActivity', 5 | addDialog: 'repository__createActivityDialog', 6 | addRootDialog: 'repository__createRootActivityDialog', 7 | addRootBtn: 'repository__createRootActivityBtn' 8 | }; 9 | 10 | export const generateActivityName = type => `${type} - ${(new Date()).getTime()}`; 11 | 12 | export const getActivityDialog = () => cy.findByTestId(sel.addDialog); 13 | export const getRootActivityDialog = () => cy.findByTestId(sel.addRootDialog); 14 | 15 | export function createRootActivity(name, type) { 16 | cy.findByTestId(sel.addRootBtn).click(); 17 | return getRootActivityDialog().within(() => { 18 | cy.vSelect('Type', type); 19 | cy.findByLabelText('Name').type(`${name}{enter}`); 20 | cy.findByRole('button', { name: /create/i }).click(); 21 | }); 22 | } 23 | 24 | export function findActivityItem(name) { 25 | return cy.contains(toTestIdAttr(sel.activityItem), name, { timeout: 6000 }); 26 | } 27 | -------------------------------------------------------------------------------- /cypress/support/e2e/activity.js: -------------------------------------------------------------------------------- 1 | const generateName = (prefix = 'Activity') => `${prefix} - ${(new Date()).getTime()}`; 2 | 3 | const ACTIVITY_TYPES = { 4 | MODULE: 'TEST_SCHEMA/MODULE', 5 | LESSON: 'TEST_SCHEMA/LESSON', 6 | PAGE: 'TEST_SCHEMA/PAGE' 7 | }; 8 | 9 | const SAVE_ACTIVITY_ACTION = 'repository/activities/save'; 10 | const SET_ENDPOINT_ACTION = 'repository/activities/setEndpoint'; 11 | 12 | Cypress.Commands.add('createActivity', 13 | (repositoryId, type, name = generateName(), parentId) => { 14 | const endpointUrl = `repositories/${repositoryId}/activities`; 15 | cy.getStore().invoke('dispatch', SET_ENDPOINT_ACTION, endpointUrl); 16 | return cy.getStore().invoke('dispatch', SAVE_ACTIVITY_ACTION, { 17 | repositoryId, 18 | parentId, 19 | type, 20 | position: 1, 21 | data: { name } 22 | }); 23 | }); 24 | 25 | Cypress.Commands.add('seedActivities', repositoryId => { 26 | return cy.createActivity(repositoryId, ACTIVITY_TYPES.MODULE, generateName('Module')) 27 | .then(module => { 28 | return cy.createActivity( 29 | repositoryId, 30 | ACTIVITY_TYPES.LESSON, 31 | generateName('Lesson'), 32 | module.id 33 | ).then(lesson => ({ module, lesson })); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /cypress/support/e2e/api.js: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('interceptFetch', (route, alias) => { 2 | cy.intercept(route, req => { 3 | delete req.headers['if-none-match']; 4 | }).as(alias); 5 | cy.wait(`@${alias}`); 6 | return cy.get(`@${alias}`).then(({ response }) => response); 7 | }); 8 | -------------------------------------------------------------------------------- /cypress/support/e2e/auth.js: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('login', () => { 2 | return cy.getStore().invoke('dispatch', 'login', { 3 | email: Cypress.env('USERNAME'), 4 | password: Cypress.env('PASSWORD') 5 | }); 6 | }); 7 | 8 | Cypress.Commands.add('logout', () => { 9 | return cy.getStore().invoke('dispatch', 'logout'); 10 | }); 11 | -------------------------------------------------------------------------------- /cypress/support/e2e/common.js: -------------------------------------------------------------------------------- 1 | // App instance and routing 2 | Cypress.Commands.add('getApp', () => cy.window().its('__app__')); 3 | Cypress.Commands.add('getStore', () => cy.getApp().its('$store')); 4 | Cypress.Commands.add('getRouter', () => cy.getApp().its('$router')); 5 | Cypress.Commands.add('getRoute', () => cy.getApp().its('$route')); 6 | Cypress.Commands.add('getRouteName', () => cy.getRoute().its('name')); 7 | Cypress.Commands.add('assertRoute', name => cy.getRouteName().should('eq', name)); 8 | 9 | // Confirmation dialog actions 10 | Cypress.Commands.add('confirmAction', dialogTitle => { 11 | return getDialogByTitle(dialogTitle) 12 | .findByText(/confirm/i) 13 | .click(); 14 | }); 15 | Cypress.Commands.add('cancelAction', dialogTitle => { 16 | return getDialogByTitle(dialogTitle) 17 | .findByText(/cancel/i) 18 | .click(); 19 | }); 20 | 21 | const getDialogByTitle = dialogTitle => { 22 | return cy.root() 23 | .findAllByRole('document') 24 | .filter(`:contains("${dialogTitle}")`) 25 | .eq(0); 26 | }; 27 | -------------------------------------------------------------------------------- /cypress/support/e2e/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | 27 | import '@testing-library/cypress/add-commands'; 28 | import './common'; 29 | import './schema'; 30 | import './api'; 31 | import './auth'; 32 | import './repository'; 33 | import './activity'; 34 | import './vuetify'; 35 | -------------------------------------------------------------------------------- /cypress/support/e2e/repository.js: -------------------------------------------------------------------------------- 1 | const generateName = () => `Test repository - ${(new Date()).getTime()}`; 2 | 3 | Cypress.Commands.add('createRepository', (name = generateName()) => { 4 | return cy.getTestSchema().then(schema => { 5 | return cy.getStore().invoke('dispatch', 'repositories/create', { 6 | schema: schema.id, 7 | name, 8 | description: 'Test repository' 9 | }); 10 | }); 11 | }); 12 | 13 | Cypress.Commands.add('openRepository', repositoryId => { 14 | return cy.getRouter() 15 | .then(router => router.push({ name: 'repository', params: { repositoryId } })); 16 | }); 17 | -------------------------------------------------------------------------------- /cypress/support/e2e/schema.js: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('getTestSchemaId', () => { 2 | return cy.window().its('__test_schema_id__'); 3 | }); 4 | 5 | Cypress.Commands.add('getSchemaService', () => { 6 | return cy.getApp().its('_provided.$schemaService'); 7 | }); 8 | 9 | Cypress.Commands.add('getTestSchema', () => { 10 | return cy.getSchemaService() 11 | .then(service => cy.getTestSchemaId().then(service.getSchema)); 12 | }); 13 | -------------------------------------------------------------------------------- /cypress/support/e2e/vuetify/index.js: -------------------------------------------------------------------------------- 1 | import './vSelect'; 2 | -------------------------------------------------------------------------------- /cypress/support/e2e/vuetify/vSelect.js: -------------------------------------------------------------------------------- 1 | import isArray from 'lodash/isArray'; 2 | 3 | Cypress.Commands.add('vSelect', (inputLabel, optionLabels) => { 4 | optionLabels = isArray(optionLabels) ? optionLabels : [optionLabels]; 5 | // Show the dropdown menu 6 | cy.findByLabelText(inputLabel) 7 | .closest("div[role='button']") 8 | .as('vSelectBtn') 9 | .click({ force: true }); 10 | // TODO: Consider implementation with attach attr to improve scoping 11 | optionLabels.forEach(optionLabel => 12 | cy.findByRole('option', { name: optionLabel }).click({ force: true })); 13 | // Close the dropdown (menu) 14 | cy.get('@vSelectBtn') 15 | .closest('.v-input') 16 | .parent() 17 | .click({ force: true }); 18 | }); 19 | -------------------------------------------------------------------------------- /cypress/utils/index.js: -------------------------------------------------------------------------------- 1 | export const toTestIdAttr = val => `[data-testid="${val}"]`; 2 | -------------------------------------------------------------------------------- /extensions/content-containers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExtensionEngine/tailor/84c54980c967fd4d0d1f700e556c3992c4587c11/extensions/content-containers/.gitkeep -------------------------------------------------------------------------------- /extensions/content-elements/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExtensionEngine/tailor/84c54980c967fd4d0d1f700e556c3992c4587c11/extensions/content-elements/.gitkeep -------------------------------------------------------------------------------- /extensions/meta-inputs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExtensionEngine/tailor/84c54980c967fd4d0d1f700e556c3992c4587c11/extensions/meta-inputs/.gitkeep -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./node_modules/cypress", "cypress/**/*.js"] 3 | } 4 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.5", 3 | "packages": [ 4 | "packages/*" 5 | ], 6 | "publishConfig": { 7 | "directory": "dist" 8 | }, 9 | "$schema": "node_modules/lerna/schemas/lerna-schema.json" 10 | } 11 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "*.*", 4 | ".env" 5 | ], 6 | "ignore": [ 7 | "build", 8 | "client", 9 | "data", 10 | "dist", 11 | "*.config.js" 12 | ], 13 | "env": { 14 | "NODE_ENV": "development" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/config/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /packages/config/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "@extensionengine/eslint-config/base", 4 | "parserOptions": { 5 | "ecmaVersion": "latest", 6 | "sourceType": "module" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/config/src/index.js: -------------------------------------------------------------------------------- 1 | import getSchemaApi from './schema'; 2 | import getWorkflowApi from './workflow'; 3 | import processSchemas from './schema-processor'; 4 | 5 | export { 6 | getSchemaApi, 7 | getWorkflowApi, 8 | processSchemas 9 | }; 10 | -------------------------------------------------------------------------------- /packages/config/src/workflow-validation.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | const workflowStatus = yup.object().shape({ 4 | id: yup.string().required(), 5 | label: yup.string().required(), 6 | color: yup.string().required(), 7 | default: yup.boolean() 8 | }); 9 | 10 | const duration = yup.object().shape({ 11 | months: yup.number(), 12 | weeks: yup.number(), 13 | days: yup.number() 14 | }); 15 | 16 | const workflow = yup.object().shape({ 17 | id: yup.string().required(), 18 | statuses: yup.array().of(workflowStatus).min(1), 19 | dueDateWarningThreshold: duration 20 | }); 21 | 22 | const workflows = yup.array().of(workflow); 23 | 24 | export default config => { 25 | try { 26 | workflows.validateSync(config); 27 | } catch (err) { 28 | console.error('Invalid workflow config!', err.message); 29 | throw err; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /packages/config/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { fileURLToPath } from 'node:url'; 3 | import path from 'node:path'; 4 | 5 | const _dirname = fileURLToPath(new URL('.', import.meta.url)); 6 | 7 | /** 8 | * @type {import('vite').UserConfig} 9 | */ 10 | const config = { 11 | build: { 12 | lib: { 13 | entry: path.join(_dirname, './src/index.js'), 14 | name: 'TailorConfig', 15 | formats: ['es', 'cjs', 'umd'] 16 | } 17 | } 18 | }; 19 | 20 | export default () => defineConfig(config); 21 | -------------------------------------------------------------------------------- /packages/core-components/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /packages/core-components/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "@extensionengine/eslint-config", 4 | "parserOptions": { 5 | "ecmaVersion": "latest", 6 | "sourceType": "module" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/core-components/README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | # @tailor-cms/core-components 6 | 7 | [![Npm 8 | version](https://badgen.net/npm/v/@tailor-cms/core-components)](https://www.npmjs.com/package/@tailor-cms/core-components) 9 | [![GitHub 10 | license](https://badgen.net/github/license/ExtensionEngine/tailor)](https://github.com/ExtensionEngine/tailor/blob/develop/LICENSE) 11 | [![js @extensionengine 12 | style](https://badgen.net/badge/code%20style/@extensionengine/black)](https://github.com/ExtensionEngine/eslint-config) 13 | [![style @extensionengine 14 | style](https://badgen.net/badge/stylelint/@extensionengine/black)](https://github.com/ExtensionEngine/stylelint-config) 15 | [![Open Source 16 | Love](https://badgen.net/badge/Open%20Source/%E2%9D%A4/3eaf8e)](https://github.com/ellerbrock/open-source-badge/) 17 | 18 | > Tailor's core components. 19 | -------------------------------------------------------------------------------- /packages/core-components/src/components/Discussion/ResolveButton.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 26 | -------------------------------------------------------------------------------- /packages/core-components/src/components/Discussion/Thread/UnseenDivider.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 29 | 30 | 50 | -------------------------------------------------------------------------------- /packages/core-components/src/components/EditorLink.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 43 | -------------------------------------------------------------------------------- /packages/core-components/src/components/InputError.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 18 | 24 | -------------------------------------------------------------------------------- /packages/core-components/src/components/PreviewOverlay.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | 21 | 27 | -------------------------------------------------------------------------------- /packages/core-components/src/components/PublishDiffChip.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 28 | -------------------------------------------------------------------------------- /packages/core-components/src/components/QuestionContainer/Controls.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 38 | -------------------------------------------------------------------------------- /packages/core-components/src/download.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | download(url, fileName) { 4 | const a = document.createElement('a'); 5 | a.href = url; 6 | a.download = fileName; 7 | a.target = '_blank'; 8 | a.click(); 9 | } 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /packages/core-components/src/loader.js: -------------------------------------------------------------------------------- 1 | import pMinDelay from 'p-min-delay'; 2 | 3 | export default function loader(action, name, minDuration = 0) { 4 | return function () { 5 | this[name] = true; 6 | return pMinDelay(Promise.resolve(action.call(this, ...arguments)), minDuration) 7 | .finally(() => (this[name] = false)); 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /packages/core-components/src/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin highlight($color) { 2 | box-shadow: 0 0 0 2px $color !important; 3 | border: none; 4 | } 5 | -------------------------------------------------------------------------------- /packages/core-components/stylelint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '@extensionengine/stylelint-config' 3 | }; 4 | -------------------------------------------------------------------------------- /packages/core-components/vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url'; 2 | import path from 'node:path'; 3 | import vue from '@vitejs/plugin-vue2'; 4 | 5 | const _dirname = fileURLToPath(new URL('.', import.meta.url)); 6 | 7 | /** 8 | * @type {import('vite').UserConfig} 9 | */ 10 | export default { 11 | build: { 12 | lib: { 13 | entry: './src/index.js', 14 | name: 'TailorCoreComponents', 15 | formats: ['es', 'cjs', 'umd'] 16 | }, 17 | rollupOptions: { 18 | // Externalize deps that shouldn't be bundled 19 | external: ['vue', 'vuetify', 'vee-validate'] 20 | } 21 | }, 22 | resolve: { 23 | alias: [{ 24 | find: '@/', 25 | replacement: path.join(_dirname, './src/') 26 | }] 27 | }, 28 | plugins: [vue()] 29 | }; 30 | -------------------------------------------------------------------------------- /packages/utils/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /packages/utils/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "@extensionengine/eslint-config/base", 4 | "parserOptions": { 5 | "ecmaVersion": "latest", 6 | "sourceType": "module" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/utils/src/InsertLocation.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ADD_BEFORE: 'ADD_BEFORE', 3 | ADD_AFTER: 'ADD_AFTER', 4 | ADD_INTO: 'ADD_INTO', 5 | REORDER: 'REORDER' 6 | }; 7 | -------------------------------------------------------------------------------- /packages/utils/src/events/discussion.js: -------------------------------------------------------------------------------- 1 | export default { 2 | SAVE: 'comment:save', 3 | REMOVE: 'comment:remove', 4 | SET_LAST_SEEN: 'comment:setLastSeen', 5 | RESOLVE: 'element:resolveComments' 6 | }; 7 | -------------------------------------------------------------------------------- /packages/utils/src/events/index.js: -------------------------------------------------------------------------------- 1 | export { default as Discussion } from './discussion'; 2 | -------------------------------------------------------------------------------- /packages/utils/src/index.js: -------------------------------------------------------------------------------- 1 | import toCase from 'to-case'; 2 | 3 | export * from './calculatePosition'; 4 | export * as activity from './activity'; 5 | export { default as InsertLocation } from './InsertLocation'; 6 | export * as assessment from './assessment'; 7 | export * as Events from './events'; 8 | export { default as numberToLetter } from './numberToLetter'; 9 | export { default as publishDiffChangeTypes } from './publishDiffChangeTypes'; 10 | export { default as uuid } from './uuid'; 11 | 12 | export function getMetaName(type) { 13 | return `meta-${toCase.slug(type)}`; 14 | } 15 | 16 | export function getContainerName(type) { 17 | return `tcc-${toCase.slug(type)}`; 18 | } 19 | 20 | export function getComponentName(type) { 21 | return `tce-${toCase.slug(resolveElementType(type))}`; 22 | } 23 | 24 | export function processAnswerType(type) { 25 | return `answer-${toCase.slug(type)}`; 26 | } 27 | 28 | export function isQuestion(type) { 29 | return ['QUESTION', 'REFLECTION', 'ASSESSMENT'].includes(type); 30 | } 31 | 32 | export function resolveElementType(type) { 33 | return isQuestion(type) ? 'QUESTION-CONTAINER' : type; 34 | } 35 | 36 | export function getToolbarName(type) { 37 | return `${toCase.slug(type)}-toolbar`; 38 | } 39 | 40 | export function getElementId(element) { 41 | return element && (element.uid || element.id); 42 | } 43 | -------------------------------------------------------------------------------- /packages/utils/src/numberToLetter.js: -------------------------------------------------------------------------------- 1 | const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 2 | const base = alphabet.length; 3 | 4 | export default function numberToLetter(n) { 5 | const digits = []; 6 | 7 | do { 8 | const v = n % base; 9 | digits.push(v); 10 | n = Math.floor(n / base); 11 | } while (n-- > 0); 12 | 13 | const chars = []; 14 | while (digits.length) { 15 | chars.push(alphabet[digits.pop()]); 16 | } 17 | 18 | return chars.join(''); 19 | } 20 | -------------------------------------------------------------------------------- /packages/utils/src/publishDiffChangeTypes.js: -------------------------------------------------------------------------------- 1 | export default { 2 | NEW: 'new', 3 | CHANGED: 'changed', 4 | REMOVED: 'removed' 5 | }; 6 | -------------------------------------------------------------------------------- /packages/utils/src/uuid.js: -------------------------------------------------------------------------------- 1 | import { v1 } from 'uuid'; 2 | 3 | // TODO: Change to namespaced v5? 4 | export default function () { 5 | return v1(); 6 | } 7 | -------------------------------------------------------------------------------- /packages/utils/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { fileURLToPath } from 'node:url'; 3 | import path from 'node:path'; 4 | 5 | const _dirname = fileURLToPath(new URL('.', import.meta.url)); 6 | 7 | /** 8 | * @type {import('vite').UserConfig} 9 | */ 10 | const config = { 11 | build: { 12 | lib: { 13 | entry: path.join(_dirname, './src/index.js'), 14 | name: 'TailorUtils', 15 | formats: ['es', 'cjs', 'umd'] 16 | } 17 | } 18 | }; 19 | 20 | export default () => defineConfig(config); 21 | -------------------------------------------------------------------------------- /packages/vue-radio/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /packages/vue-radio/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "@extensionengine/eslint-config/base", 4 | "parserOptions": { 5 | "ecmaVersion": "latest", 6 | "sourceType": "module" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/vue-radio/src/helpers.js: -------------------------------------------------------------------------------- 1 | const isFunction = arg => typeof arg === 'function'; 2 | 3 | export function mapChannels(channels) { 4 | return mapKeys(castObject(channels), name => { 5 | return vm => vm.$radio.channel(name); 6 | }); 7 | } 8 | 9 | export function mapRequests(channel, requests) { 10 | const getChannel = !isFunction(channel) 11 | ? vm => vm.$radio.channel(channel) 12 | : channel; 13 | return mapKeys(castObject(requests), request => { 14 | return function (...args) { 15 | return getChannel(this).request(request, ...args); 16 | }; 17 | }); 18 | } 19 | 20 | function castObject(arg) { 21 | if (!Array.isArray(arg)) return arg; 22 | return arg.reduce((acc, name) => { 23 | return Object.assign(acc, { [name]: name }); 24 | }, {}); 25 | } 26 | 27 | function mapKeys(obj, cb) { 28 | return Object.entries(obj).reduce((acc, [key, value]) => { 29 | value = cb(value, key); 30 | return Object.assign(acc, { [key]: value }); 31 | }, {}); 32 | } 33 | -------------------------------------------------------------------------------- /packages/vue-radio/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { fileURLToPath } from 'node:url'; 3 | import path from 'node:path'; 4 | 5 | const _dirname = fileURLToPath(new URL('.', import.meta.url)); 6 | 7 | /** 8 | * @type {import('vite').UserConfig} 9 | */ 10 | const config = { 11 | build: { 12 | lib: { 13 | entry: path.join(_dirname, './src/index.js'), 14 | name: 'VueRadio', 15 | formats: ['es', 'cjs', 'umd'] 16 | }, 17 | rollupOptions: { 18 | output: { 19 | exports: 'named' 20 | } 21 | } 22 | } 23 | }; 24 | 25 | export default () => defineConfig(config); 26 | -------------------------------------------------------------------------------- /sequelize.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('dotenv').config(); 4 | const path = require('path'); 5 | 6 | module.exports = { 7 | config: path.join(__dirname, './server/shared/database/config.js'), 8 | seedersPath: path.join(__dirname, './server/shared/database/seeds'), 9 | migrationsPath: path.join(__dirname, './server/shared/database/migrations') 10 | }; 11 | -------------------------------------------------------------------------------- /server/content-element/index.js: -------------------------------------------------------------------------------- 1 | import ctrl from './content-element.controller.js'; 2 | import express from 'express'; 3 | import processListQuery from '../shared/util/processListQuery.js'; 4 | 5 | const processQuery = processListQuery(); 6 | const router = express.Router(); 7 | 8 | router.route('/') 9 | .get(processQuery, ctrl.list) 10 | .post(ctrl.create); 11 | 12 | router.route('/:elementId') 13 | .get(ctrl.show) 14 | .patch(ctrl.patch) 15 | .delete(ctrl.remove); 16 | 17 | router 18 | .post('/:elementId/reorder', ctrl.reorder); 19 | 20 | export default { 21 | path: '/content-elements', 22 | router 23 | }; 24 | -------------------------------------------------------------------------------- /server/oidc/authenticated.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ title }} 8 | 9 | 10 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /server/oidc/error.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ title }} 8 | 9 | 10 |

Authentication failed

11 |

Oops! Something went wrong

12 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /server/repository/feed/index.js: -------------------------------------------------------------------------------- 1 | import ctrl from './controller.js'; 2 | import express from 'express'; 3 | import { middleware as sse } from '../../shared/sse/index.js'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/subscribe', sse, ctrl.subscribe); 8 | 9 | router.route('/') 10 | .get(ctrl.fetchUserActivities) 11 | .post(ctrl.addUserActivity) 12 | .delete(ctrl.removeUserActivity); 13 | 14 | export default { 15 | path: '/feed', 16 | router 17 | }; 18 | -------------------------------------------------------------------------------- /server/repository/proxy.js: -------------------------------------------------------------------------------- 1 | import BaseProxy from '../shared/storage/proxy/index.js'; 2 | import { storage as config } from '../../config/server/index.js'; 3 | import path from 'node:path'; 4 | 5 | const storageCookies = { 6 | REPOSITORY: 'Storage-Repository' 7 | }; 8 | 9 | class Proxy extends BaseProxy { 10 | getSignedCookies(repositoryId, maxAge) { 11 | const resource = path.join('repository', `${repositoryId}`); 12 | return { 13 | ...super.getSignedCookies(resource, maxAge), 14 | [storageCookies.REPOSITORY]: repositoryId 15 | }; 16 | } 17 | 18 | hasCookies(cookies, repositoryId) { 19 | const { REPOSITORY } = storageCookies; 20 | const isRepositoryId = cookies[REPOSITORY] === repositoryId.toString(); 21 | return isRepositoryId && super.hasCookies(cookies); 22 | } 23 | 24 | getCookieNames() { 25 | return [ 26 | ...super.getCookieNames(), 27 | ...Object.values(storageCookies) 28 | ]; 29 | } 30 | } 31 | 32 | export default await Proxy.create(config.proxy); 33 | -------------------------------------------------------------------------------- /server/repository/storage.js: -------------------------------------------------------------------------------- 1 | import BaseStorage from '../shared/storage/index.js'; 2 | import { storage as config } from '../../config/server/index.js'; 3 | import path from 'node:path'; 4 | 5 | class Storage extends BaseStorage { 6 | getPath(repositoryId) { 7 | return path.join('repository', `${repositoryId}`, config.path); 8 | } 9 | } 10 | 11 | export default await Storage.create(config); 12 | -------------------------------------------------------------------------------- /server/router.js: -------------------------------------------------------------------------------- 1 | import { auth as authConfig } from '../config/server/index.js'; 2 | import authenticator from './shared/auth/index.js'; 3 | import express from 'express'; 4 | import { extractAuthData } from './shared/auth/mw.js'; 5 | import repository from './repository/index.js'; 6 | import tag from './tag/index.js'; 7 | import user from './user/index.js'; 8 | 9 | const { authenticate } = authenticator; 10 | const router = express.Router(); 11 | router.use(processBody); 12 | router.use(extractAuthData); 13 | 14 | // Public routes: 15 | router.use(user.path, user.router); 16 | 17 | // SSO routes: 18 | authConfig.oidc.enabled && await (async () => { 19 | const { default: oidc } = await import('./oidc/index.js'); 20 | router.use(oidc.path, oidc.router); 21 | })(); 22 | 23 | // Protected routes: 24 | router.use(authenticate('jwt')); 25 | router.use(repository.path, repository.router); 26 | router.use(tag.path, tag.router); 27 | 28 | export default router; 29 | 30 | function processBody(req, _res, next) { 31 | const { body } = req; 32 | if (body && body.email) body.email = body.email.toLowerCase(); 33 | next(); 34 | } 35 | -------------------------------------------------------------------------------- /server/script/addAdmin.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import createLogger from '../shared/logger.js'; 3 | import roleConfig from '../../config/shared/role.js'; 4 | 5 | createLogger.enabled = false; 6 | 7 | // Dynamic import is needed in order for the `enabled` flag to be respected 8 | const { default: db } = await import('../shared/database/index.js'); 9 | 10 | const { User } = db; 11 | const { user: role } = roleConfig; 12 | 13 | const args = process.argv.slice(2); 14 | if (args.length !== 2) { 15 | console.error('You must supply two arguments - email and password'); 16 | process.exit(1); 17 | } 18 | 19 | const email = args[0]; 20 | const password = args[1]; 21 | 22 | User.create({ email, password, role: role.ADMIN }) 23 | .then(user => { 24 | console.log(`Administrator created: ${user.email}`); 25 | process.exit(0); 26 | }) 27 | .catch(err => { 28 | console.error(err.message); 29 | process.exit(1); 30 | }); 31 | -------------------------------------------------------------------------------- /server/script/addIntegration.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import createLogger from '../shared/logger.js'; 3 | import roleConfig from '../../config/shared/role.js'; 4 | 5 | createLogger.enabled = false; 6 | 7 | // Dynamic import is needed in order for the `enabled` flag to be respected 8 | const { default: db } = await import('../shared/database/index.js'); 9 | 10 | const { User } = db; 11 | const { user: role } = roleConfig; 12 | 13 | User.findOne({ where: { role: role.INTEGRATION } }) 14 | .then(user => { 15 | if (!user) return true; 16 | console.log('Integration already exists'); 17 | process.exit(0); 18 | }) 19 | .then(() => User.create({ role: role.INTEGRATION })) 20 | .then(user => { 21 | console.log(`Integration user created: ${user.id}`); 22 | process.exit(0); 23 | }) 24 | .catch(err => { 25 | console.error(err.message); 26 | process.exit(1); 27 | }); 28 | -------------------------------------------------------------------------------- /server/script/generateIntegrationToken.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import createLogger from '../shared/logger.js'; 3 | 4 | createLogger.enabled = false; 5 | 6 | // Dynamic import is needed in order for the `enabled` flag to be respected 7 | const { default: db } = await import('../shared/database/index.js'); 8 | 9 | const { User } = db; 10 | 11 | User.findOne({ where: { role: 'INTEGRATION' } }) 12 | .then(user => user.createToken({})) 13 | .then(token => { 14 | console.log(`Integration token generated: ${token}`); 15 | process.exit(0); 16 | }) 17 | .catch(err => { 18 | console.error(err.message || err); 19 | process.exit(1); 20 | }); 21 | -------------------------------------------------------------------------------- /server/script/inviteAdmin.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import createLogger from '../shared/logger.js'; 3 | import Deferred from '../shared/util/Deferred.js'; 4 | import roleConfig from '../../config/shared/role.js'; 5 | 6 | createLogger.enabled = false; 7 | 8 | // Dynamic import is needed in order for the `enabled` flag to be respected 9 | const { default: db } = await import('../shared/database/index.js'); 10 | 11 | const { User } = db; 12 | const { user: role } = roleConfig; 13 | 14 | const args = process.argv.slice(2); 15 | if (args.length !== 1) { 16 | console.error('You must supply email'); 17 | process.exit(1); 18 | } 19 | 20 | const email = args[0]; 21 | const mailing = new Deferred(); 22 | 23 | User.invite({ email, role: role.ADMIN }, mailing.callback) 24 | .then(user => Promise.all([user, mailing.promise])) 25 | .then(([user]) => { 26 | console.log(`Invitation sent to ${user.email} for Admin role.`); 27 | process.exit(0); 28 | }) 29 | .catch(err => { 30 | console.error(err.message); 31 | process.exit(1); 32 | }); 33 | -------------------------------------------------------------------------------- /server/script/preflight.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import boxen from 'boxen'; 3 | import { readPackageUpSync } from 'read-pkg-up'; 4 | import semver from 'semver'; 5 | 6 | const { packageJson: pkg } = readPackageUpSync(); 7 | 8 | (function preflight() { 9 | const engines = pkg.engines || {}; 10 | if (!engines.node) return; 11 | const checkPassed = semver.satisfies(process.versions.node, engines.node); 12 | if (checkPassed) return; 13 | warn(engines.node); 14 | console.error(' ✋ Exiting due to engine requirement check failure...\n'); 15 | process.exit(1); 16 | }()); 17 | 18 | function warn(range, current = process.version, name = pkg.name) { 19 | const options = { 20 | borderColor: 'red', 21 | borderStyle: 'single', 22 | padding: 1, 23 | margin: 1, 24 | float: 'left', 25 | align: 'center' 26 | }; 27 | const message = `🚨 ${name} requires node ${range}\n current version is ${current}`; 28 | console.error(boxen(message, options)); 29 | } 30 | -------------------------------------------------------------------------------- /server/shared/auth/audience.js: -------------------------------------------------------------------------------- 1 | export default { 2 | Scope: { 3 | Access: 'scope:access', 4 | Setup: 'scope:setup' 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /server/shared/auth/mw.js: -------------------------------------------------------------------------------- 1 | import { auth as authConfig } from '../../../config/server/index.js'; 2 | import { createError } from '../error/helpers.js'; 3 | import get from 'lodash/get.js'; 4 | import roleConfig from '../../../config/shared/role.js'; 5 | import { UNAUTHORIZED } from 'http-status-codes'; 6 | 7 | const { user: role } = roleConfig; 8 | 9 | function authorize(...allowed) { 10 | allowed.push(role.ADMIN); 11 | return ({ user }, res, next) => { 12 | if (user && allowed.includes(user.role)) return next(); 13 | return createError(UNAUTHORIZED, 'Access restricted'); 14 | }; 15 | } 16 | 17 | function extractAuthData(req, res, next) { 18 | const path = authConfig.jwt.cookie.signed ? 'signedCookies' : 'cookies'; 19 | req.authData = get(req[path], 'auth', null); 20 | return next(); 21 | } 22 | 23 | export { 24 | authorize, 25 | extractAuthData 26 | }; 27 | -------------------------------------------------------------------------------- /server/shared/content-plugins/elementHooks.js: -------------------------------------------------------------------------------- 1 | export default { 2 | BEFORE_SAVE: 'beforeSave', 3 | AFTER_SAVE: 'afterSave', 4 | AFTER_RETRIEVE: 'afterRetrieve', 5 | AFTER_LOADED: 'afterLoaded' 6 | }; 7 | -------------------------------------------------------------------------------- /server/shared/content-plugins/index.js: -------------------------------------------------------------------------------- 1 | import containerRegistry from './containerRegistry.js'; 2 | import elementRegistry from './elementRegistry.js'; 3 | import Promise from 'bluebird'; 4 | 5 | class ContentPluginRegistry { 6 | constructor() { 7 | this.containerRegistry = containerRegistry; 8 | this.elementRegistry = elementRegistry; 9 | } 10 | 11 | initialize() { 12 | const registries = Object.values(this); 13 | return Promise.map(registries, it => it.initialize()); 14 | } 15 | } 16 | 17 | export default new ContentPluginRegistry(); 18 | -------------------------------------------------------------------------------- /server/shared/database/config.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { createLogger, Level } from '../logger.js'; 3 | 4 | const isProduction = process.env.NODE_ENV === 'production'; 5 | const logger = createLogger('db', { level: Level.DEBUG }); 6 | 7 | const config = parseConfig(); 8 | const migrationStorageTableName = 'sequelize_meta'; 9 | const benchmark = !isProduction; 10 | 11 | function logging(query, time) { 12 | const info = { query }; 13 | if (time) info.duration = `${time}ms`; 14 | return logger.debug(info); 15 | } 16 | 17 | export default { 18 | ...config, 19 | migrationStorageTableName, 20 | benchmark, 21 | logging 22 | }; 23 | 24 | function parseConfig(config = process.env) { 25 | const DATABASE_URI = config.DATABASE_URI || config.POSTGRES_URI; 26 | if (DATABASE_URI) return { url: DATABASE_URI }; 27 | if (!config.DATABASE_NAME) { 28 | throw new TypeError(`Invalid \`DATABASE_NAME\` provided: ${config.DATABASE_NAME}`); 29 | } 30 | return { 31 | database: config.DATABASE_NAME, 32 | username: config.DATABASE_USER, 33 | password: config.DATABASE_PASSWORD, 34 | host: config.DATABASE_HOST, 35 | port: config.DATABASE_PORT, 36 | dialect: config.DATABASE_ADAPTER || 'postgres' 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /server/shared/database/hooks.js: -------------------------------------------------------------------------------- 1 | import { hooks } from 'sequelize/lib/hooks'; 2 | import mapValues from 'lodash/mapValues.js'; 3 | 4 | const Hooks = mapValues(hooks, (_, key) => key); 5 | 6 | Hooks.withType = (hookType, hook) => { 7 | return function (...args) { 8 | return hook.call(this, hookType, ...args); 9 | }; 10 | }; 11 | 12 | export default Hooks; 13 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20181115140901-create-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'user'; 4 | 5 | module.exports = { 6 | up: (queryInterface, Sequelize) => queryInterface.createTable(TABLE_NAME, { 7 | id: { 8 | type: Sequelize.INTEGER, 9 | primaryKey: true, 10 | autoIncrement: true 11 | }, 12 | email: { 13 | type: Sequelize.STRING, 14 | unique: true 15 | }, 16 | password: { 17 | type: Sequelize.STRING 18 | }, 19 | role: { 20 | type: Sequelize.ENUM('ADMIN', 'USER', 'INTEGRATION') 21 | }, 22 | token: { 23 | type: Sequelize.STRING, 24 | unique: true 25 | }, 26 | createdAt: { 27 | type: Sequelize.DATE, 28 | field: 'created_at', 29 | allowNull: false 30 | }, 31 | updatedAt: { 32 | type: Sequelize.DATE, 33 | field: 'updated_at', 34 | allowNull: false 35 | }, 36 | deletedAt: { 37 | type: Sequelize.DATE, 38 | field: 'deleted_at' 39 | } 40 | }), 41 | down: queryInterface => queryInterface.dropTable(TABLE_NAME) 42 | }; 43 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20181115140902-create-course.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'course'; 4 | 5 | module.exports = { 6 | up: (queryInterface, Sequelize) => queryInterface.createTable(TABLE_NAME, { 7 | id: { 8 | type: Sequelize.INTEGER, 9 | primaryKey: true, 10 | autoIncrement: true 11 | }, 12 | uid: { 13 | type: Sequelize.UUID, 14 | unique: true, 15 | defaultValue: Sequelize.UUIDV4 16 | }, 17 | schema: { 18 | type: Sequelize.STRING 19 | }, 20 | name: { 21 | type: Sequelize.STRING 22 | }, 23 | description: { 24 | type: Sequelize.TEXT 25 | }, 26 | data: { 27 | type: Sequelize.JSONB, 28 | defaultValue: {} 29 | }, 30 | stats: { 31 | type: Sequelize.JSONB, 32 | defaultValue: { objectives: 0, assessments: 0 } 33 | }, 34 | createdAt: { 35 | type: Sequelize.DATE, 36 | field: 'created_at', 37 | allowNull: false 38 | }, 39 | updatedAt: { 40 | type: Sequelize.DATE, 41 | field: 'updated_at', 42 | allowNull: false 43 | }, 44 | deletedAt: { 45 | type: Sequelize.DATE, 46 | field: 'deleted_at' 47 | } 48 | }), 49 | down: queryInterface => queryInterface.dropTable(TABLE_NAME) 50 | }; 51 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20181115140906-create-revision.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'revision'; 4 | 5 | module.exports = { 6 | up: (queryInterface, Sequelize) => queryInterface.createTable(TABLE_NAME, { 7 | id: { 8 | type: Sequelize.INTEGER, 9 | primaryKey: true, 10 | autoIncrement: true 11 | }, 12 | userId: { 13 | type: Sequelize.INTEGER, 14 | field: 'user_id', 15 | references: { model: 'user', key: 'id' } 16 | }, 17 | courseId: { 18 | type: Sequelize.INTEGER, 19 | field: 'course_id', 20 | references: { model: 'course', key: 'id' } 21 | }, 22 | entity: { 23 | type: Sequelize.ENUM(['ACTIVITY', 'COURSE', 'TEACHING_ELEMENT']), 24 | allowNull: false 25 | }, 26 | operation: { 27 | type: Sequelize.ENUM(['CREATE', 'UPDATE', 'REMOVE']), 28 | allowNull: false 29 | }, 30 | state: { 31 | type: Sequelize.JSONB, 32 | allowNull: true 33 | }, 34 | createdAt: { 35 | type: Sequelize.DATE, 36 | field: 'created_at' 37 | }, 38 | updatedAt: { 39 | type: Sequelize.DATE, 40 | field: 'updated_at' 41 | } 42 | }), 43 | down: queryInterface => queryInterface.dropTable(TABLE_NAME) 44 | }; 45 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20181115140907-create-comment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'comment'; 4 | 5 | module.exports = { 6 | up: (queryInterface, Sequelize) => queryInterface.createTable(TABLE_NAME, { 7 | id: { 8 | type: Sequelize.INTEGER, 9 | primaryKey: true, 10 | autoIncrement: true 11 | }, 12 | authorId: { 13 | type: Sequelize.INTEGER, 14 | field: 'author_id', 15 | references: { model: 'user', key: 'id' } 16 | }, 17 | activityId: { 18 | type: Sequelize.INTEGER, 19 | field: 'activity_id', 20 | references: { model: 'activity', key: 'id' } 21 | }, 22 | courseId: { 23 | type: Sequelize.INTEGER, 24 | field: 'course_id', 25 | references: { model: 'course', key: 'id' } 26 | }, 27 | content: { 28 | type: Sequelize.TEXT, 29 | allowNull: false 30 | }, 31 | createdAt: { 32 | type: Sequelize.DATE, 33 | field: 'created_at' 34 | }, 35 | updatedAt: { 36 | type: Sequelize.DATE, 37 | field: 'updated_at' 38 | }, 39 | deletedAt: { 40 | type: Sequelize.DATE, 41 | field: 'deleted_at' 42 | } 43 | }), 44 | down: queryInterface => queryInterface.dropTable(TABLE_NAME) 45 | }; 46 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20181115140943-add-meta-to-teaching-element.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'teaching_element'; 4 | const COLUMN_NAME = 'meta'; 5 | 6 | module.exports = { 7 | up: async (queryInterface, Sequelize) => { 8 | const table = await queryInterface.describeTable(TABLE_NAME); 9 | if (table.meta) return; 10 | return queryInterface.addColumn(TABLE_NAME, COLUMN_NAME, { 11 | type: Sequelize.JSONB, 12 | defaultValue: {} 13 | }); 14 | }, 15 | down: queryInterface => { 16 | return queryInterface.removeColumn(TABLE_NAME, COLUMN_NAME); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20190416152610-change-table-cell-type.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'teaching_element'; 4 | 5 | function findTables(queryInterface) { 6 | const where = { type: 'TABLE' }; 7 | return queryInterface.rawSelect(TABLE_NAME, { where, plain: false }, null); 8 | } 9 | 10 | function setCellType({ data }, type) { 11 | if (!data.embeds) return; 12 | data.embeds = mapKeys(data.embeds, (data, id) => { 13 | return Object.assign({}, data, { type }); 14 | }); 15 | } 16 | 17 | exports.up = async queryInterface => { 18 | const tables = await findTables(queryInterface); 19 | return Promise.all(tables.map(({ id, data }) => { 20 | setCellType({ data }, 'HTML'); 21 | return queryInterface.update({}, TABLE_NAME, { data }, { id }); 22 | })); 23 | }; 24 | 25 | exports.down = async queryInterface => { 26 | const tables = await findTables(queryInterface); 27 | return Promise.all(tables.map(({ id, data }) => { 28 | setCellType({ data }, 'TABLE-CELL'); 29 | return queryInterface.update({}, TABLE_NAME, { data }, { id }); 30 | })); 31 | }; 32 | 33 | function mapKeys(obj, cb) { 34 | return Object.keys(obj).reduce((acc, key) => { 35 | const val = cb(obj[key], key); 36 | return Object.assign(acc, { [key]: val }); 37 | }, {}); 38 | } 39 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20190712153652-pin-repo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'course_user'; 4 | const COLUMN_NAME = 'pinned'; 5 | 6 | module.exports = { 7 | up: (queryInterface, { BOOLEAN }) => { 8 | return queryInterface.addColumn(TABLE_NAME, COLUMN_NAME, { 9 | type: BOOLEAN, 10 | defaultValue: false 11 | }); 12 | }, 13 | down: queryInterface => { 14 | return queryInterface.removeColumn(TABLE_NAME, COLUMN_NAME); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20190717151915-remove-course-stats.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'course'; 4 | const COLUMN_NAME = 'stats'; 5 | 6 | module.exports = { 7 | up: queryInterface => { 8 | return queryInterface.removeColumn(TABLE_NAME, COLUMN_NAME); 9 | }, 10 | down: (queryInterface, { JSONB }) => { 11 | return queryInterface.addColumn(TABLE_NAME, COLUMN_NAME, { 12 | type: JSONB, 13 | defaultValue: { objectives: 0, assessments: 0 } 14 | }); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20190717151916-add-user-info.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Promise = require('bluebird'); 4 | const { Sequelize } = require('sequelize'); 5 | 6 | const TABLE_NAME = 'user'; 7 | 8 | const COLUMNS = { 9 | first_name: { type: Sequelize.STRING(50) }, 10 | last_name: { type: Sequelize.STRING(50) }, 11 | img_url: { type: Sequelize.TEXT } 12 | }; 13 | 14 | module.exports = { 15 | up: queryInterface => Promise.map(Object.entries(COLUMNS), ([name, options]) => { 16 | return queryInterface.addColumn(TABLE_NAME, name, options); 17 | }), 18 | down: queryInterface => Promise.map(Object.entries(COLUMNS), ([name]) => { 19 | return queryInterface.removeColumn(TABLE_NAME, name); 20 | }) 21 | }; 22 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20190717151916-remove-user-token.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Sequelize } = require('sequelize'); 4 | 5 | const TABLE = 'user'; 6 | 7 | module.exports = { 8 | up: async qi => { 9 | return qi.sequelize.transaction(async transaction => { 10 | await qi.removeColumn(TABLE, 'token', { transaction }); 11 | await qi.changeColumn(TABLE, 'password', { 12 | type: Sequelize.STRING, 13 | allowNull: false 14 | }, { transaction }); 15 | }); 16 | }, 17 | down: async qi => { 18 | return qi.sequelize.transaction(async transaction => { 19 | await qi.addColumn(TABLE, 'token', { type: Sequelize.STRING, unique: true }, { transaction }); 20 | await qi.changeColumn(TABLE, 'password', { 21 | type: Sequelize.STRING, 22 | allowNull: true 23 | }, { transaction }); 24 | }); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20191023082600-rename-course-to-repository.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'course'; 4 | const NEW_TABLE_NAME = 'repository'; 5 | 6 | exports.up = queryInterface => queryInterface.renameTable(TABLE_NAME, NEW_TABLE_NAME); 7 | 8 | exports.down = queryInterface => queryInterface.renameTable(NEW_TABLE_NAME, TABLE_NAME); 9 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20191023083030-rename-course_user-to-repository_user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'course_user'; 4 | const NEW_TABLE_NAME = 'repository_user'; 5 | 6 | exports.up = queryInterface => queryInterface.renameTable(TABLE_NAME, NEW_TABLE_NAME); 7 | 8 | exports.down = queryInterface => queryInterface.renameTable(NEW_TABLE_NAME, TABLE_NAME); 9 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20191023083231-rename-course_id-to-repository_id.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Promise = require('bluebird'); 4 | 5 | const SRC_COL = 'course_id'; 6 | const DST_COL = 'repository_id'; 7 | 8 | const TABLE_NAMES = [ 9 | 'activity', 10 | 'comment', 11 | 'revision', 12 | 'repository_user', 13 | 'teaching_element' 14 | ]; 15 | 16 | exports.up = queryInterface => { 17 | return Promise.each(TABLE_NAMES, 18 | tableName => queryInterface.renameColumn(tableName, SRC_COL, DST_COL)); 19 | }; 20 | 21 | exports.down = queryInterface => { 22 | return Promise.each(TABLE_NAMES, 23 | tableName => queryInterface.renameColumn(tableName, DST_COL, SRC_COL)); 24 | }; 25 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20191023083232-update-constraint-names.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Promise = require('bluebird'); 4 | 5 | // Table, current constraint name, new constraint name 6 | const MAPPINGS = [ 7 | ['repository', 'course_pkey', 'repository_pkey'], 8 | ['repository', 'course_uid_key', 'repository_uid_key'], 9 | ['repository_user', 'course_user_pkey', 'repository_user_pkey'], 10 | ['repository_user', 'course_user_course_id_fkey', 'repository_user_repository_id_fkey'], 11 | ['repository_user', 'course_user_user_id_fkey', 'repository_user_user_id_fkey'] 12 | ]; 13 | 14 | // Standard foreign key names 15 | ['activity', 'comment', 'revision', 'teaching_element'].forEach(table => { 16 | MAPPINGS.push([table, `${table}_course_id_fkey`, `${table}_repository_id_fkey`]); 17 | }); 18 | 19 | exports.up = queryInterface => { 20 | const { sequelize: db } = queryInterface; 21 | return Promise.each(MAPPINGS, it => updateConstraint(db, it)); 22 | }; 23 | 24 | exports.down = queryInterface => { 25 | const { sequelize: db } = queryInterface; 26 | return Promise.each(MAPPINGS, it => updateConstraint(db, [it[0], it[2], it[1]])); 27 | }; 28 | 29 | function updateConstraint(db, [table, oldName, newName]) { 30 | return db.query(`ALTER TABLE ${table} RENAME CONSTRAINT ${oldName} TO ${newName}`); 31 | } 32 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20191023083234-rename-teaching-to-content-element.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'teaching_element'; 4 | const NEW_TABLE_NAME = 'content_element'; 5 | 6 | exports.up = queryInterface => queryInterface.renameTable(TABLE_NAME, NEW_TABLE_NAME); 7 | 8 | exports.down = queryInterface => queryInterface.renameTable(NEW_TABLE_NAME, TABLE_NAME); 9 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20200130124211-create-tag.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'tag'; 4 | 5 | exports.up = (queryInterface, Sequelize) => { 6 | return queryInterface.createTable(TABLE_NAME, { 7 | id: { 8 | type: Sequelize.INTEGER, 9 | primaryKey: true, 10 | autoIncrement: true 11 | }, 12 | name: { 13 | type: Sequelize.STRING(20), 14 | unique: true, 15 | allowNull: false 16 | } 17 | }); 18 | }; 19 | 20 | exports.down = queryInterface => queryInterface.dropTable(TABLE_NAME); 21 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20200130124252-create-repository-tag.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'repository_tag'; 4 | 5 | exports.up = (queryInterface, Sequelize) => { 6 | return queryInterface.createTable(TABLE_NAME, { 7 | tagId: { 8 | type: Sequelize.INTEGER, 9 | field: 'tag_id', 10 | references: { model: 'tag', key: 'id' }, 11 | onDelete: 'CASCADE' 12 | }, 13 | repositoryId: { 14 | type: Sequelize.INTEGER, 15 | field: 'repository_id', 16 | references: { model: 'repository', key: 'id' }, 17 | onDelete: 'CASCADE' 18 | } 19 | }).then(async () => { 20 | return queryInterface.addConstraint(TABLE_NAME, { 21 | name: 'repository_tag_pkey', 22 | type: 'primary key', 23 | fields: ['repository_id', 'tag_id'] 24 | }); 25 | }); 26 | }; 27 | 28 | exports.down = queryInterface => queryInterface.dropTable(TABLE_NAME); 29 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20201005125421-add-missing-uids.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Promise = require('bluebird'); 4 | 5 | const TABLE_NAMES = ['user', 'comment', 'revision', 'tag']; 6 | 7 | exports.up = async (qi, Sequelize) => { 8 | await qi.sequelize.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";'); 9 | return Promise.each(TABLE_NAMES, tableName => { 10 | return qi.addColumn(tableName, 'uid', { 11 | type: Sequelize.UUID, 12 | unique: true, 13 | allowNull: false, 14 | defaultValue: Sequelize.literal('uuid_generate_v4()') 15 | }); 16 | }); 17 | }; 18 | 19 | exports.down = qi => { 20 | return Promise.each(TABLE_NAMES, tableName => qi.removeColumn(tableName, 'uid')); 21 | }; 22 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20201113162457-add-element-id-to-comment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'comment'; 4 | const COLUMN_NAME = 'content_element_id'; 5 | 6 | exports.up = (qi, { INTEGER }) => qi.addColumn(TABLE_NAME, COLUMN_NAME, { 7 | type: INTEGER, 8 | references: { model: 'content_element', key: 'id' } 9 | }); 10 | 11 | exports.down = qi => qi.removeColumn(TABLE_NAME, COLUMN_NAME); 12 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20210125094759-add-edited_at_column_to_comment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'comment'; 4 | const COLUMN_NAME = 'edited_at'; 5 | 6 | exports.up = (qi, { DATE }) => qi.addColumn(TABLE_NAME, COLUMN_NAME, { type: DATE }); 7 | 8 | exports.down = qi => qi.removeColumn(TABLE_NAME, COLUMN_NAME); 9 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20210125094819-add-resolved_at-column-to-comment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TABLE_NAME = 'comment'; 4 | const COLUMN_NAME = 'resolved_at'; 5 | 6 | exports.up = (qi, { DATE }) => qi.addColumn(TABLE_NAME, COLUMN_NAME, { type: DATE }); 7 | 8 | exports.down = qi => qi.removeColumn(TABLE_NAME, COLUMN_NAME); 9 | -------------------------------------------------------------------------------- /server/shared/database/migrations/20210204115515-hydrate-edited_at-column.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const head = require('lodash/head'); 4 | const Promise = require('bluebird'); 5 | 6 | exports.up = async qi => { 7 | const comments = await getComments(qi); 8 | if (!comments.length) return; 9 | return updateColumnValues(comments, qi); 10 | }; 11 | 12 | exports.down = () => {}; 13 | 14 | async function getComments({ sequelize }) { 15 | const sql = ` 16 | SELECT 17 | id, 18 | created_at AS "createdAt", 19 | updated_at AS "updatedAt" 20 | FROM 21 | comment 22 | WHERE 23 | created_at != updated_at; 24 | `; 25 | return head(await sequelize.query(sql, { raw: true })); 26 | } 27 | 28 | function updateColumnValues(comments, { sequelize }) { 29 | return Promise.each(comments, ({ id, updatedAt }) => { 30 | const sql = 'UPDATE comment SET edited_at = :updatedAt WHERE id = :id'; 31 | const replacements = { id, updatedAt }; 32 | return sequelize.query(sql, { replacements }); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /server/shared/database/migrations/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /server/shared/database/pagination.js: -------------------------------------------------------------------------------- 1 | import { parsePath } from './helpers.js'; 2 | import pick from 'lodash/pick.js'; 3 | 4 | const parseOptions = ({ limit, offset, sortOrder }) => ({ 5 | limit: parseInt(limit, 10) || 100, 6 | offset: parseInt(offset, 10) || 0, 7 | sortOrder: sortOrder || 'ASC' 8 | }); 9 | 10 | function processPagination(Model) { 11 | return (req, _, next) => { 12 | const options = parseOptions(req.query); 13 | Object.assign(req.query, options); 14 | req.options = pick(options, ['limit', 'offset']); 15 | const { sortBy } = req.query; 16 | if (sortBy) { 17 | req.options.order = [[...parsePath(sortBy, Model), options.sortOrder]]; 18 | } 19 | next(); 20 | }; 21 | } 22 | 23 | export { 24 | parseOptions, 25 | processPagination 26 | }; 27 | -------------------------------------------------------------------------------- /server/shared/database/seeds/20181115140901-insert-users.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bcrypt = require('bcrypt'); 4 | const Promise = require('bluebird'); 5 | 6 | const times = (length, cb) => Array.from({ length }, (_, i) => cb(i)); 7 | 8 | const now = new Date(); 9 | const users = []; 10 | 11 | times(5, i => { 12 | const suffix = i || ''; 13 | users.push({ 14 | email: `admin${suffix}@example.com`, 15 | password: 'admin123.', 16 | role: 'ADMIN', 17 | created_at: now, 18 | updated_at: now 19 | }); 20 | }); 21 | 22 | module.exports = { 23 | up(queryInterface) { 24 | return import('../../../../config/server/index.js') 25 | .then(({ auth: config }) => ( 26 | Promise.map(users, user => encryptPassword(user, config.saltRounds)) 27 | )) 28 | .then(users => queryInterface.bulkInsert('user', users)); 29 | }, 30 | down(queryInterface) { 31 | return queryInterface.bulkDelete('user'); 32 | } 33 | }; 34 | 35 | function encryptPassword(user, saltRounds) { 36 | return bcrypt.hash(user.password, saltRounds) 37 | .then(password => (user.password = password)) 38 | .then(() => user); 39 | } 40 | -------------------------------------------------------------------------------- /server/shared/database/seeds/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /server/shared/error/helpers.js: -------------------------------------------------------------------------------- 1 | import httpError from 'http-errors'; 2 | 3 | function validationError(err) { 4 | const code = 400; 5 | return Promise.reject(httpError(code, err.message, { validation: true })); 6 | } 7 | 8 | function createError(code = 400, message = 'An error has occured') { 9 | return Promise.reject(httpError(code, message, { custom: true })); 10 | } 11 | 12 | export { 13 | createError, 14 | validationError 15 | }; 16 | -------------------------------------------------------------------------------- /server/shared/mail/formatters.js: -------------------------------------------------------------------------------- 1 | import { htmlToText } from 'html-to-text'; 2 | 3 | function html() { 4 | return (text, render) => htmlToText(render(text)); 5 | } 6 | 7 | export { html }; 8 | -------------------------------------------------------------------------------- /server/shared/mail/templates/assignee.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 11 | 16 | You've been assigned to the {{label}} "{{data.name}}". 17 | 18 | 23 | View the {{label}} status by clicking the button below: 24 | 25 | 26 | View {{data.name}} status 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /server/shared/mail/templates/assignee.txt: -------------------------------------------------------------------------------- 1 | Tailor 2 | ================================================= 3 | 4 | You've been assigned to the {{label}}: "{{data.name}}" 5 | 6 | View the {{label}} status by visiting the link: 7 | 8 | {{{href}}} 9 | 10 | Or copy and paste this URL into your browser. 11 | 12 | 13 | ------------------------------------------------- 14 | -------------------------------------------------------------------------------- /server/shared/mail/templates/comment.txt: -------------------------------------------------------------------------------- 1 | Tailor 2 | ================================================= 3 | 4 | {{author.label}} left a comment on {{repositoryName}}, {{topic}}: 5 | 6 | {{content}} 7 | 8 | View {{activityLabel}} by clicking the URL below: 9 | 10 | {{href}} 11 | 12 | Or copy and paste this URL into your browser. 13 | 14 | ------------------------------------------------- 15 | -------------------------------------------------------------------------------- /server/shared/mail/templates/components/footer.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | If you didn't request this, please ignore this email. 4 | 5 | -------------------------------------------------------------------------------- /server/shared/mail/templates/components/header.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tailor 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /server/shared/mail/templates/reset.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Hello {{recipientName}}, 9 | 10 | 11 | You have requested password reset. 12 | 13 | 14 | Please reset your password by clicking the button below: 15 | 16 | 17 | Reset password 18 | 19 | 20 | Or copy and paste this URL into your browser: 21 | 22 | {{href}} 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /server/shared/mail/templates/reset.txt: -------------------------------------------------------------------------------- 1 | Tailor 2 | ================================================= 3 | 4 | Hello {{recipientName}}, 5 | 6 | You have requested password reset. 7 | Please reset your password by clicking the URL below: 8 | 9 | {{href}} 10 | 11 | Or copy and paste this URL into your browser. 12 | 13 | 14 | ------------------------------------------------- 15 | If you didn't request this, please ignore this email. 16 | -------------------------------------------------------------------------------- /server/shared/mail/templates/welcome.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Welcome {{recipientName}}, 9 | 10 | 11 | An account has been created for you on {{hostname}}. 12 | 13 | 14 | Please finish your registration by clicking the button below: 15 | 16 | 17 | Complete registration 18 | 19 | 20 | Or copy and paste this URL into your browser: 21 | 22 | {{href}} 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /server/shared/mail/templates/welcome.txt: -------------------------------------------------------------------------------- 1 | Tailor 2 | ================================================= 3 | 4 | Welcome {{recipientName}}, 5 | 6 | An account has been created for you on {{hostname}}. 7 | Please finish your registration by clicking the URL below: 8 | 9 | {{href}} 10 | 11 | Or copy and paste this URL into your browser. 12 | 13 | 14 | ------------------------------------------------- 15 | If you didn't request this, please ignore this email. 16 | -------------------------------------------------------------------------------- /server/shared/origin.js: -------------------------------------------------------------------------------- 1 | import createLogger from './logger.js'; 2 | import { hostname } from '../../config/server/index.js'; 3 | 4 | const logger = createLogger(); 5 | const isProduction = process.env.NODE_ENV === 'production'; 6 | 7 | export default () => { 8 | if (hostname) return middleware; 9 | const message = 'Origin: "HOSTNAME" is not set, using "Host" HTTP header.'; 10 | isProduction ? logger.warn('⚠️ ', message) : logger.info(message); 11 | return middleware; 12 | }; 13 | 14 | function middleware(req, _, next) { 15 | Object.defineProperty(req, 'origin', { 16 | get: () => `${req.protocol}://${hostname || req.get('host')}` 17 | }); 18 | next(); 19 | } 20 | -------------------------------------------------------------------------------- /server/shared/storage/storage.router.js: -------------------------------------------------------------------------------- 1 | import { createError } from '../error/helpers.js'; 2 | import ctrl from './storage.controller.js'; 3 | import express from 'express'; 4 | import { FORBIDDEN } from 'http-status-codes'; 5 | import multer from 'multer'; 6 | import storage from '../../repository/storage.js'; 7 | 8 | const router = express.Router(); 9 | const upload = multer({ storage: multer.memoryStorage() }); 10 | 11 | router 12 | .get('/', validateAssetRepository, ctrl.getUrl) 13 | .post('/', upload.single('file'), ctrl.upload); 14 | 15 | function validateAssetRepository(req, res, next) { 16 | const { repository, query: { key } } = req; 17 | const repositoryAssetsPath = storage.getPath(repository.id); 18 | if (!key.startsWith(repositoryAssetsPath)) { 19 | return createError(FORBIDDEN, 'Access restricted'); 20 | } 21 | next(); 22 | } 23 | 24 | export default { 25 | path: '/assets', 26 | router 27 | }; 28 | -------------------------------------------------------------------------------- /server/shared/storage/util.js: -------------------------------------------------------------------------------- 1 | import * as fsp from 'node:fs/promises'; 2 | import crypto from 'node:crypto'; 3 | 4 | function sha256(...args) { 5 | const hash = crypto.createHash('sha256'); 6 | args.forEach(arg => hash.update(arg)); 7 | return hash.digest('hex'); 8 | } 9 | 10 | function readFile(file) { 11 | if (file.buffer) return Promise.resolve(file.buffer); 12 | return fsp.readFile(file.path); 13 | } 14 | 15 | export { 16 | sha256, 17 | readFile 18 | }; 19 | -------------------------------------------------------------------------------- /server/shared/storage/validation.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | const { ValidationError } = yup; 4 | 5 | yup.addMethod(yup.string, 'pkcs1', function () { 6 | function isValid(value) { 7 | if (!value) return false; 8 | const isValidStart = value.startsWith('-----BEGIN RSA PRIVATE KEY-----'); 9 | const isValidEnd = value.endsWith('-----END RSA PRIVATE KEY-----'); 10 | return isValidStart && isValidEnd; 11 | } 12 | return this.test('format', 'Invalid private key format', isValid); 13 | }); 14 | 15 | export { 16 | validateConfig 17 | }; 18 | 19 | function validateConfig(config, schema) { 20 | try { 21 | return schema.validateSync(config, { stripUnknown: true }); 22 | } catch (error) { 23 | if (!ValidationError.isError(error)) throw error; 24 | const err = new Error('Unsupported config structure'); 25 | err.cause = error; 26 | throw err; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/shared/transfer/formats.js: -------------------------------------------------------------------------------- 1 | import { createGunzip, createGzip } from 'zlib'; 2 | import { createReadStream, createWriteStream } from 'node:fs'; 3 | import { pack as createTar, extract as createUntar } from 'tar-fs'; 4 | import Promise from 'bluebird'; 5 | 6 | const miss = Promise.promisifyAll((await import('mississippi')).default); 7 | 8 | const useTar = Adapter => class extends Adapter { 9 | static pack(blobStore, outFile, { gzip = true } = {}) { 10 | return miss.pipeAsync(...[ 11 | createTar(blobStore.path), 12 | gzip && createGzip(), 13 | createWriteStream(outFile) 14 | ].filter(Boolean)); 15 | } 16 | 17 | static unpack(inFile, blobStore, { gunzip = true } = {}) { 18 | return miss.pipeAsync(...[ 19 | createReadStream(inFile), 20 | gunzip && createGunzip(), 21 | createUntar(blobStore.path) 22 | ].filter(Boolean)); 23 | } 24 | }; 25 | 26 | export { 27 | useTar 28 | }; 29 | -------------------------------------------------------------------------------- /server/shared/transfer/transfer.service.js: -------------------------------------------------------------------------------- 1 | import { ExportJob, ImportJob } from './job.js'; 2 | import createLogger from '../logger.js'; 3 | import PromiseQueue from 'promise-queue'; 4 | 5 | const logger = createLogger(); 6 | 7 | class TransferService { 8 | constructor() { 9 | this.queue = new PromiseQueue(1, Infinity); 10 | } 11 | 12 | createExportJob(outFile, options) { 13 | const exportJob = new ExportJob(outFile, options); 14 | this.queue.add(() => exportJob.run()); 15 | setupLogging(exportJob); 16 | return exportJob; 17 | } 18 | 19 | createImportJob(inFile, options) { 20 | const importJob = new ImportJob(inFile, options); 21 | this.queue.add(() => importJob.run()); 22 | setupLogging(importJob); 23 | return importJob; 24 | } 25 | } 26 | 27 | export default new TransferService(); 28 | 29 | function setupLogging(job) { 30 | job.once('success', () => logger.info({ job: job.toJSON() }, 'Job completed successfully')); 31 | job.once('error', err => logger.error({ job: job.toJSON(), err }, 'Job failed to complete')); 32 | } 33 | -------------------------------------------------------------------------------- /server/shared/util/Deferred.js: -------------------------------------------------------------------------------- 1 | export default function Deferred() { 2 | this.promise = new Promise((resolve, reject) => { 3 | this.resolve = resolve; 4 | this.reject = reject; 5 | }); 6 | this.callback = (err, ...args) => { 7 | return err ? this.reject(err) : this.resolve(...args); 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /server/shared/util/calculatePosition.js: -------------------------------------------------------------------------------- 1 | import findIndex from 'lodash/findIndex.js'; 2 | 3 | export default function (id, index, siblings) { 4 | let newpos; 5 | 6 | if (!index) { 7 | newpos = siblings[0].position / 2; 8 | } else if (index + 1 === siblings.length) { 9 | newpos = siblings[index].position + 1; 10 | } else { 11 | const currentIndex = findIndex(siblings, it => it.id === id); 12 | const direction = currentIndex > index ? -1 : 1; 13 | const prevPos = siblings[index].position; 14 | const nextPos = siblings[index + direction].position; 15 | newpos = (nextPos + prevPos) / 2; 16 | } 17 | 18 | return newpos; 19 | } 20 | -------------------------------------------------------------------------------- /server/shared/util/processListQuery.js: -------------------------------------------------------------------------------- 1 | import assign from 'lodash/assign.js'; 2 | import defaultsDeep from 'lodash/defaultsDeep.js'; 3 | import { Op } from 'sequelize'; 4 | import pick from 'lodash/pick.js'; 5 | 6 | const filter = { 7 | where: {}, 8 | offset: 0, 9 | limit: null, 10 | order: [['id', 'ASC']], 11 | paranoid: true 12 | }; 13 | 14 | export default function (defaults) { 15 | return function (req, res, next) { 16 | const order = [[req.query.sortBy, req.query.sortOrder]]; 17 | const query = assign(pick(req.query, ['offset', 'limit', 'paranoid']), { order }); 18 | const options = defaultsDeep({}, query, defaults, filter); 19 | 20 | if (query.integration) { options.paranoid = false; } 21 | 22 | if (query.syncedAt) { 23 | const condition = { $gte: query.syncedAt }; 24 | options.where[Op.or] = [{ updatedAt: condition }, { deletedAt: condition }]; 25 | } 26 | 27 | req.opts = options; 28 | 29 | next(); 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /server/tag/index.js: -------------------------------------------------------------------------------- 1 | import ctrl from './tag.controller.js'; 2 | import express from 'express'; 3 | 4 | const router = express.Router(); 5 | 6 | router 7 | .get('/', ctrl.list); 8 | 9 | export default { 10 | path: '/tags', 11 | router 12 | }; 13 | -------------------------------------------------------------------------------- /server/tag/repositoryTag.model.js: -------------------------------------------------------------------------------- 1 | import { Model } from 'sequelize'; 2 | 3 | class RepositoryTag extends Model { 4 | static fields({ INTEGER, DATE }) { 5 | return { 6 | repositoryId: { 7 | type: INTEGER, 8 | field: 'repository_id', 9 | primaryKey: true, 10 | unique: 'repository_tag_pkey' 11 | }, 12 | tagId: { 13 | type: INTEGER, 14 | field: 'tag_id', 15 | primaryKey: true, 16 | unique: 'repository_tag_pkey' 17 | } 18 | }; 19 | } 20 | 21 | static associate({ Repository, Tag }) { 22 | this.belongsTo(Repository, { 23 | foreignKey: { name: 'repositoryId', field: 'repository_id' } 24 | }); 25 | this.belongsTo(Tag, { 26 | foreignKey: { name: 'tagId', field: 'tag_id' } 27 | }); 28 | } 29 | 30 | static options() { 31 | return { 32 | modelName: 'RepositoryTag', 33 | tableName: 'repository_tag', 34 | underscored: true, 35 | timestamps: false 36 | }; 37 | } 38 | } 39 | 40 | export default RepositoryTag; 41 | -------------------------------------------------------------------------------- /server/tag/tag.controller.js: -------------------------------------------------------------------------------- 1 | import db from '../shared/database/index.js'; 2 | import yn from 'yn'; 3 | 4 | const { Tag } = db; 5 | 6 | async function list({ user, query: { associated } }, res) { 7 | const tags = await (yn(associated) 8 | ? Tag.getAssociated(user) 9 | : Tag.findAll()); 10 | return res.json({ data: tags }); 11 | } 12 | 13 | export default { 14 | list 15 | }; 16 | -------------------------------------------------------------------------------- /server/user/mw.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import { requestLimiter } from '../shared/request/mw.js'; 3 | 4 | const ONE_HOUR_IN_MS = 60 * 60 * 1000; 5 | 6 | const loginRequestLimiter = requestLimiter({ 7 | windowMs: ONE_HOUR_IN_MS, 8 | keyGenerator: req => req.userKey 9 | }); 10 | 11 | function setLoginLimitKey(req, res, next) { 12 | const key = [req.ip, req.body.email].join(':'); 13 | req.userKey = crypto.createHash('sha256').update(key).digest('base64'); 14 | return next(); 15 | } 16 | 17 | function resetLoginAttempts(req, res, next) { 18 | return loginRequestLimiter.resetKey(req.userKey) 19 | .then(() => next()); 20 | } 21 | 22 | export { 23 | loginRequestLimiter, 24 | setLoginLimitKey, 25 | resetLoginAttempts 26 | }; 27 | -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: '@extensionengine/stylelint-config' 5 | }; 6 | --------------------------------------------------------------------------------