mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-03-09 06:27:21 -04:00
Compare commits
233 Commits
feat/video
...
feat-lti-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45774fbc8c | ||
|
|
5ac0515d05 | ||
|
|
492e3366c0 | ||
|
|
3f1ca5d02f | ||
|
|
8bc09b69f7 | ||
|
|
7b8e4975ab | ||
|
|
df8701a515 | ||
|
|
2c1103e906 | ||
|
|
1c50dcdff8 | ||
|
|
b8fee7d06a | ||
|
|
1df6e0c10d | ||
|
|
dc328cd33c | ||
|
|
b39789c2c4 | ||
|
|
86fa084391 | ||
|
|
533a30b6cb | ||
|
|
cdbed4bc1d | ||
|
|
58d336478c | ||
|
|
d28fc114f3 | ||
|
|
0056e1ed8f | ||
|
|
2423e9517e | ||
|
|
699f4bd09d | ||
|
|
6071921908 | ||
|
|
33da2242b4 | ||
|
|
21ddd04165 | ||
|
|
96755af3b2 | ||
|
|
17526aeecd | ||
|
|
23c5c544ce | ||
|
|
ae9d614e84 | ||
|
|
e044187973 | ||
|
|
b5a76c53e1 | ||
|
|
86348f1747 | ||
|
|
c6be65edd6 | ||
|
|
4656ba1176 | ||
|
|
7e7cd10549 | ||
|
|
d5919e10af | ||
|
|
f16b61644a | ||
|
|
c486574eb0 | ||
|
|
364d22dd8e | ||
|
|
209cede39c | ||
|
|
9b8f63881a | ||
|
|
82fb2665e6 | ||
|
|
abd22426d7 | ||
|
|
86f4484c36 | ||
|
|
f226c2fe1e | ||
|
|
98111234f8 | ||
|
|
467e273ff5 | ||
|
|
166fb5c00b | ||
|
|
d3f722611d | ||
|
|
fae5634729 | ||
|
|
5cdcef2205 | ||
|
|
f6e44f8343 | ||
|
|
1b3a58bc60 | ||
|
|
befc1f0efa | ||
|
|
6075514ffa | ||
|
|
9c7cc293ce | ||
|
|
f7c675596f | ||
|
|
36d815c0cf | ||
|
|
8f28b00a63 | ||
|
|
74952f68d7 | ||
|
|
7950a4655a | ||
|
|
b76282f9e4 | ||
|
|
b405a04e34 | ||
|
|
76a27ae256 | ||
|
|
156e0bddd6 | ||
|
|
637e9eabea | ||
|
|
e12f361935 | ||
|
|
c4d569e7b0 | ||
|
|
24d15d2639 | ||
|
|
1c0805748d | ||
|
|
8e7f1ef15d | ||
|
|
d00b4ed3c5 | ||
|
|
7126cf1cbd | ||
|
|
33135f81e9 | ||
|
|
cfd305d563 | ||
|
|
d4bad33592 | ||
|
|
7c0ea60d0e | ||
|
|
c6726d1b13 | ||
|
|
7c04cc30fb | ||
|
|
f28ce5990b | ||
|
|
27828d798e | ||
|
|
ba53467033 | ||
|
|
a77761ec35 | ||
|
|
4d07ae64c6 | ||
|
|
1a96ac0703 | ||
|
|
9e0290ae49 | ||
|
|
610a22935f | ||
|
|
81e3325687 | ||
|
|
16f7b010ab | ||
|
|
68cb21ff8a | ||
|
|
2ddc3b24d7 | ||
|
|
63e96b327e | ||
|
|
48537515cb | ||
|
|
e6db138d11 | ||
|
|
2f2d32f0db | ||
|
|
f4d3439246 | ||
|
|
7fe9891942 | ||
|
|
9eb8a1ad62 | ||
|
|
23ee0dc7cc | ||
|
|
e5be39f392 | ||
|
|
f0c084fa53 | ||
|
|
571bfcc4ce | ||
|
|
c04380af47 | ||
|
|
97741f780e | ||
|
|
78cce0eb10 | ||
|
|
472b3029c4 | ||
|
|
343f1e7009 | ||
|
|
8c78b67b0c | ||
|
|
29fc7fb861 | ||
|
|
b03a33d93e | ||
|
|
64472be406 | ||
|
|
cc0f4d4645 | ||
|
|
095e4d2cb4 | ||
|
|
5c8978453e | ||
|
|
83189076e4 | ||
|
|
ca6dbf3740 | ||
|
|
8646bd70dc | ||
|
|
1f493c8e15 | ||
|
|
e11cb7ea6e | ||
|
|
3131e76ef7 | ||
|
|
809cdccc42 | ||
|
|
ed36240f45 | ||
|
|
77bafff6f6 | ||
|
|
f6252f4f77 | ||
|
|
764580287f | ||
|
|
ce6c9a0a3c | ||
|
|
1ced023a07 | ||
|
|
981fec296c | ||
|
|
40cd7916e7 | ||
|
|
bcef59c3a9 | ||
|
|
e93c8225c4 | ||
|
|
5c3c33ca84 | ||
|
|
7a954e7a3d | ||
|
|
8610df0c2b | ||
|
|
8ab9030d14 | ||
|
|
15c8dec041 | ||
|
|
9af4686bd4 | ||
|
|
bcc8a0858c | ||
|
|
549b672d48 | ||
|
|
abe950f1da | ||
|
|
5fecda02d6 | ||
|
|
3c6f8c102c | ||
|
|
2d28520cd4 | ||
|
|
4bd56da2d8 | ||
|
|
fdfa857794 | ||
|
|
2c1f27c0be | ||
|
|
2f0bbd2533 | ||
|
|
54336f6c31 | ||
|
|
37e21f7ebf | ||
|
|
3deee80dd0 | ||
|
|
2e57164831 | ||
|
|
de0c16729b | ||
|
|
2c0bba1427 | ||
|
|
54a8e41f6d | ||
|
|
78fb19b464 | ||
|
|
8e5e7991b7 | ||
|
|
5cf435eca0 | ||
|
|
5026ce73da | ||
|
|
8b2ebe2415 | ||
|
|
8df320e134 | ||
|
|
8c8f737460 | ||
|
|
995faedb08 | ||
|
|
bde300b4bd | ||
|
|
fd5c0a2908 | ||
|
|
9c145da2e2 | ||
|
|
e9e5d44c3e | ||
|
|
a624c2e5b8 | ||
|
|
748d3b39ba | ||
|
|
ddc6bf9e67 | ||
|
|
aa7dbfe534 | ||
|
|
5cc72357c6 | ||
|
|
01b061a47b | ||
|
|
fbc78e7944 | ||
|
|
9e7a8afdda | ||
|
|
5572a67019 | ||
|
|
610590972f | ||
|
|
bdf7d3c2d0 | ||
|
|
a47bf5a3f8 | ||
|
|
38caea3c7c | ||
|
|
30491bf420 | ||
|
|
d0ebe19c2a | ||
|
|
59be9f16c0 | ||
|
|
a2d898c54e | ||
|
|
9733d53c0b | ||
|
|
70e2c67f3d | ||
|
|
77721d9c0e | ||
|
|
06bc64b2c4 | ||
|
|
b9899476b9 | ||
|
|
107750406e | ||
|
|
ae4ae5a07e | ||
|
|
f346a5604c | ||
|
|
56026a1a96 | ||
|
|
a88413ce14 | ||
|
|
9dab3ad858 | ||
|
|
dfe7e8fab0 | ||
|
|
1181d16ab9 | ||
|
|
d032ee3baa | ||
|
|
93f66d206b | ||
|
|
0585513439 | ||
|
|
9667e6b0ad | ||
|
|
f56948a4a2 | ||
|
|
8b3e76b554 | ||
|
|
dc417de628 | ||
|
|
35cd56c85c | ||
|
|
f0b2451815 | ||
|
|
7696251394 | ||
|
|
b95725660b | ||
|
|
d6bf98b30e | ||
|
|
3baa8ef7d7 | ||
|
|
45246eac4f | ||
|
|
9685c1b5d4 | ||
|
|
20a1da22bb | ||
|
|
f9a94321ad | ||
|
|
f85299a600 | ||
|
|
29ab2a715b | ||
|
|
43ce685f08 | ||
|
|
8c682a76af | ||
|
|
ec6b6daa81 | ||
|
|
cf90169240 | ||
|
|
fb3f377e27 | ||
|
|
f5f9a7beac | ||
|
|
726a5b74a1 | ||
|
|
40c31f295a | ||
|
|
1d77293afc | ||
|
|
5c702387ca | ||
|
|
0001f370a9 | ||
|
|
af71d4c906 | ||
|
|
eb7503125d | ||
|
|
f897d0ba2b | ||
|
|
545cca154e | ||
|
|
ef4ff9cb1d | ||
|
|
3a40fc6d88 | ||
|
|
f67d2a4d78 | ||
|
|
295578dae2 |
22
.github/workflows/semantic-pull-request.yaml
vendored
Normal file
22
.github/workflows/semantic-pull-request.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: "Lint PR"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
environment: dev
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
47
.github/workflows/semantic-release.yaml
vendored
Normal file
47
.github/workflows/semantic-release.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Semantic Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
semantic-release:
|
||||
runs-on: ubuntu-latest
|
||||
environment: dev
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.8.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.GA_DEPLOY_KEY }}
|
||||
|
||||
# use SSH url to ensure git commit using a deploy key bypasses the main
|
||||
# branch protection rule
|
||||
- name: Configure Git for SSH Push
|
||||
run: git remote set-url origin "git@github.com:${{ github.repository }}.git"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm clean-install
|
||||
|
||||
- name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
|
||||
run: npm audit signatures
|
||||
|
||||
- name: Run Semantic Release
|
||||
run: npx semantic-release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,3 +1,4 @@
|
||||
/templates/cms/*
|
||||
/templates/*.html
|
||||
*.scss
|
||||
*.scss
|
||||
/frontend/
|
||||
100
.releaserc.json
Normal file
100
.releaserc.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"branches": [
|
||||
"main"
|
||||
],
|
||||
"plugins": [
|
||||
[
|
||||
"@semantic-release/commit-analyzer",
|
||||
{
|
||||
"preset": "conventionalcommits"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/release-notes-generator",
|
||||
{
|
||||
"preset": "conventionalcommits",
|
||||
"presetConfig": {
|
||||
"types": [
|
||||
{
|
||||
"type": "feat",
|
||||
"section": "Features"
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"section": "Bug Fixes"
|
||||
},
|
||||
{
|
||||
"type": "chore",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"type": "docs",
|
||||
"section": "Documentation"
|
||||
},
|
||||
{
|
||||
"type": "style",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"type": "refactor",
|
||||
"section": "Refactors"
|
||||
},
|
||||
{
|
||||
"type": "perf",
|
||||
"section": "Performance"
|
||||
},
|
||||
{
|
||||
"type": "test",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"type": "depr",
|
||||
"section": "Deprecations"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"semantic-release-replace-plugin",
|
||||
{
|
||||
"replacements": [
|
||||
{
|
||||
"files": [
|
||||
"package.json"
|
||||
],
|
||||
"from": "\"version\": \".*\"",
|
||||
"to": "\"version\": \"${nextRelease.version}\"",
|
||||
"results": [
|
||||
{
|
||||
"file": "package.json",
|
||||
"hasChanged": true,
|
||||
"numMatches": 1,
|
||||
"numReplacements": 1
|
||||
}
|
||||
],
|
||||
"countMatches": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/changelog",
|
||||
{
|
||||
"changelogFile": "CHANGELOG.md",
|
||||
"changelogTitle": "# Changelog"
|
||||
}
|
||||
],
|
||||
"@semantic-release/github",
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": [
|
||||
"package.json",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
37
CHANGELOG.md
Normal file
37
CHANGELOG.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## [7.5.0](https://github.com/mediacms-io/mediacms/compare/v7.4.0...v7.5.0) (2026-02-06)
|
||||
|
||||
### Features
|
||||
|
||||
* bump version ([36d815c](https://github.com/mediacms-io/mediacms/commit/36d815c0cfbe21d3136541d410d545742b9ebecd))
|
||||
|
||||
## [7.4.0](https://github.com/mediacms-io/mediacms/compare/v7.3.0...v7.4.0) (2026-02-06)
|
||||
|
||||
### Features
|
||||
|
||||
* Add video player context menu with share/embed options ([#1472](https://github.com/mediacms-io/mediacms/issues/1472)) ([74952f6](https://github.com/mediacms-io/mediacms/commit/74952f68d79bc67617edb38eac62d2f5e7457565))
|
||||
|
||||
## [7.3.0](https://github.com/mediacms-io/mediacms/compare/v7.2.0...v7.3.0) (2026-02-06)
|
||||
|
||||
### Features
|
||||
|
||||
* add package json for semantic release ([b405a04](https://github.com/mediacms-io/mediacms/commit/b405a04e346ca81b7d3f4e099eb984e7785cdd0f))
|
||||
* add semantic release github actions ([76a27ae](https://github.com/mediacms-io/mediacms/commit/76a27ae25609178c1bd47c947b9f1a082c791d61))
|
||||
* frontend unit tests ([1c15880](https://github.com/mediacms-io/mediacms/commit/1c15880ae3ef1ce77f53d5b473dfc0cc448b4977))
|
||||
* Implement persistent "Embed Mode" to hide UI shell via Session Storage ([#1484](https://github.com/mediacms-io/mediacms/issues/1484)) ([223e870](https://github.com/mediacms-io/mediacms/commit/223e87073f7d5e44130c9976854cac670db0ae66))
|
||||
* Improve Visual Distinction Between Trim and Chapters Editors ([#1445](https://github.com/mediacms-io/mediacms/issues/1445)) ([d9b1d6c](https://github.com/mediacms-io/mediacms/commit/d9b1d6cab1d2bdfc16f799a0a27b64313e2e0d22))
|
||||
* semantic release ([b76282f](https://github.com/mediacms-io/mediacms/commit/b76282f9e465a39c2da5e9a22184d1db23de3f56))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add delay to task creation ([1b3cdfd](https://github.com/mediacms-io/mediacms/commit/1b3cdfd302abc5e69ebe01ca52b5091f3b24c0b2))
|
||||
* Add regex denoter and improve celerybeat gitignore ([#1446](https://github.com/mediacms-io/mediacms/issues/1446)) ([90331f3](https://github.com/mediacms-io/mediacms/commit/90331f3b4a2a5737de9dd75ab45c096944813c42))
|
||||
* adjust poster url for audio ([01912ea](https://github.com/mediacms-io/mediacms/commit/01912ea1f99ef43793a65712539d6264f1f6410f))
|
||||
* Chapter numbering and preserve custom titles on segment reorder ([#1435](https://github.com/mediacms-io/mediacms/issues/1435)) ([cd7dd4f](https://github.com/mediacms-io/mediacms/commit/cd7dd4f72c9f0bac466c680f686a9ecfdd3a38dd))
|
||||
* Show default chapter names in textarea instead of placeholder text ([#1428](https://github.com/mediacms-io/mediacms/issues/1428)) ([5eb6faf](https://github.com/mediacms-io/mediacms/commit/5eb6fafb8c6928b8bc3fe5f0c7af315273f78a55))
|
||||
* static files ([#1429](https://github.com/mediacms-io/mediacms/issues/1429)) ([ba2c31b](https://github.com/mediacms-io/mediacms/commit/ba2c31b1e65b7f508dee598b1f2d86f01f9bf036))
|
||||
|
||||
### Documentation
|
||||
|
||||
* update page link ([aeef828](https://github.com/mediacms-io/mediacms/commit/aeef8284bfba2a9a7f69c684f96c54f0e0e0cf92))
|
||||
23
HISTORY.md
23
HISTORY.md
@@ -1,23 +0,0 @@
|
||||
# History
|
||||
|
||||
## 3.0.0
|
||||
|
||||
### Features
|
||||
- Updates Python/Django requirements and Dockerfile to use latest 3.11 Python - https://github.com/mediacms-io/mediacms/pull/826/files. This update requires some manual steps, for existing (not new) installations. Check the update section under the [Admin docs](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#2-server-installation), either for single server or for Docker Compose installations
|
||||
- Upgrade postgres on Docker Compose - https://github.com/mediacms-io/mediacms/pull/749
|
||||
|
||||
### Fixes
|
||||
- video player options for HLS - https://github.com/mediacms-io/mediacms/pull/832
|
||||
- AVI videos not correctly recognised as videos - https://github.com/mediacms-io/mediacms/pull/833
|
||||
|
||||
## 2.1.0
|
||||
|
||||
### Fixes
|
||||
- Increase uwsgi buffer-size parameter. This prevents an error by uwsgi with large headers - [#5b60](https://github.com/mediacms-io/mediacms/commit/5b601698a41ad97f08c1830e14b1c18f73ab8315)
|
||||
- Fix issues with comments. These were not reported on the tracker but it is certain that they would not show comments on media files (non videos but also videos). Unfortunately this reverts work done with Timestamps on comments + Mentions on comments, more on PR [#802](https://github.com/mediacms-io/mediacms/pull/802)
|
||||
|
||||
### Features
|
||||
- Allow tags to contains other characters too, not only English alphabet ones [#801](https://github.com/mediacms-io/mediacms/pull/801)
|
||||
- Add simple cookie consent code [#799](https://github.com/mediacms-io/mediacms/pull/799)
|
||||
- Allow password reset & email verify pages on global login required [#790](https://github.com/mediacms-io/mediacms/pull/790)
|
||||
- Add api_url field to search api [#692](https://github.com/mediacms-io/mediacms/pull/692)
|
||||
@@ -108,7 +108,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
|
||||
|
||||
## Technology
|
||||
|
||||
This software uses the following list of awesome technologies: Python, Django, Django Rest Framework, Celery, PostgreSQL, Redis, Nginx, uWSGI, React, Fine Uploader, video.js, FFMPEG, Bento4
|
||||
This software uses the following list of awesome technologies: Python, Django, Django Rest Framework, Celery, PostgreSQL, Redis, Nginx, Gunicorn, React, Fine Uploader, video.js, FFMPEG, Bento4
|
||||
|
||||
|
||||
## Who is using it
|
||||
|
||||
@@ -24,6 +24,7 @@ INSTALLED_APPS = [
|
||||
"actions.apps.ActionsConfig",
|
||||
"rbac.apps.RbacConfig",
|
||||
"identity_providers.apps.IdentityProvidersConfig",
|
||||
"lti.apps.LtiConfig",
|
||||
"debug_toolbar",
|
||||
"mptt",
|
||||
"crispy_forms",
|
||||
|
||||
@@ -300,6 +300,7 @@ INSTALLED_APPS = [
|
||||
"actions.apps.ActionsConfig",
|
||||
"rbac.apps.RbacConfig",
|
||||
"identity_providers.apps.IdentityProvidersConfig",
|
||||
"lti.apps.LtiConfig",
|
||||
"debug_toolbar",
|
||||
"mptt",
|
||||
"crispy_forms",
|
||||
@@ -555,6 +556,7 @@ DJANGO_ADMIN_URL = "admin/"
|
||||
USE_SAML = False
|
||||
USE_RBAC = False
|
||||
USE_IDENTITY_PROVIDERS = False
|
||||
USE_LTI = False # Enable LTI 1.3 integration
|
||||
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
|
||||
|
||||
USE_ROUNDED_CORNERS = True
|
||||
@@ -650,3 +652,18 @@ if USERS_NEEDS_TO_BE_APPROVED:
|
||||
)
|
||||
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
||||
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")
|
||||
|
||||
|
||||
# LTI 1.3 Integration Settings
|
||||
if USE_LTI:
|
||||
# Session timeout for LTI launches (seconds)
|
||||
LTI_SESSION_TIMEOUT = 3600 # 1 hour
|
||||
|
||||
# Cookie settings required for iframe embedding from LMS
|
||||
# IMPORTANT: Requires HTTPS to be enabled
|
||||
SESSION_COOKIE_SAMESITE = 'None'
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SAMESITE = 'None'
|
||||
CSRF_COOKIE_SECURE = True
|
||||
# SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
|
||||
# Consider using cached_db for reliability if sessions are lost between many LTI launches
|
||||
|
||||
@@ -25,6 +25,7 @@ urlpatterns = [
|
||||
re_path(r"^", include("files.urls")),
|
||||
re_path(r"^", include("users.urls")),
|
||||
re_path(r"^accounts/", include("allauth.urls")),
|
||||
re_path(r"^lti/", include("lti.urls")),
|
||||
re_path(r"^api-auth/", include("rest_framework.urls")),
|
||||
path(settings.DJANGO_ADMIN_URL, admin.site.urls),
|
||||
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||
|
||||
@@ -1 +1 @@
|
||||
VERSION = "7.7"
|
||||
VERSION = "7.ki"
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# Use existing X-Forwarded-Proto from reverse proxy if present, otherwise use $scheme
|
||||
map $http_x_forwarded_proto $forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
'' $scheme;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 ;
|
||||
|
||||
@@ -28,7 +34,10 @@ server {
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
|
||||
include /etc/nginx/sites-enabled/uwsgi_params;
|
||||
uwsgi_pass 127.0.0.1:9000;
|
||||
proxy_pass http://127.0.0.1:9000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ fi
|
||||
|
||||
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-available/default
|
||||
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-enabled/default
|
||||
cp deploy/docker/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
|
||||
cp deploy/docker/nginx.conf /etc/nginx/
|
||||
|
||||
#### Supervisord Configurations #####
|
||||
@@ -45,12 +44,12 @@ cp deploy/docker/nginx.conf /etc/nginx/
|
||||
cp deploy/docker/supervisord/supervisord-debian.conf /etc/supervisor/conf.d/supervisord-debian.conf
|
||||
|
||||
if [ X"$ENABLE_UWSGI" = X"yes" ] ; then
|
||||
echo "Enabling uwsgi app server"
|
||||
cp deploy/docker/supervisord/supervisord-uwsgi.conf /etc/supervisor/conf.d/supervisord-uwsgi.conf
|
||||
echo "Enabling gunicorn app server"
|
||||
cp deploy/docker/supervisord/supervisord-gunicorn.conf /etc/supervisor/conf.d/supervisord-gunicorn.conf
|
||||
fi
|
||||
|
||||
if [ X"$ENABLE_NGINX" = X"yes" ] ; then
|
||||
echo "Enabling nginx as uwsgi app proxy and media server"
|
||||
echo "Enabling nginx as gunicorn app proxy and media server"
|
||||
cp deploy/docker/supervisord/supervisord-nginx.conf /etc/supervisor/conf.d/supervisord-nginx.conf
|
||||
fi
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ else
|
||||
echo "There is no script $PRE_START_PATH"
|
||||
fi
|
||||
|
||||
# Start Supervisor, with Nginx and uWSGI
|
||||
# Start Supervisor, with Nginx and Gunicorn
|
||||
echo "Starting server using supervisord..."
|
||||
|
||||
exec /usr/bin/supervisord
|
||||
|
||||
9
deploy/docker/supervisord/supervisord-gunicorn.conf
Normal file
9
deploy/docker/supervisord/supervisord-gunicorn.conf
Normal file
@@ -0,0 +1,9 @@
|
||||
[program:gunicorn]
|
||||
command=/home/mediacms.io/bin/gunicorn cms.wsgi:application --workers=2 --threads=2 --worker-class=gthread --bind=127.0.0.1:9000 --user=www-data --group=www-data --timeout=120 --keep-alive=5 --max-requests=1000 --max-requests-jitter=50 --access-logfile=- --error-logfile=- --log-level=info --chdir=/home/mediacms.io/mediacms
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
priority=100
|
||||
startinorder=true
|
||||
startsecs=0
|
||||
@@ -1,9 +0,0 @@
|
||||
[program:uwsgi]
|
||||
command=/home/mediacms.io/bin/uwsgi --ini /home/mediacms.io/mediacms/deploy/docker/uwsgi.ini
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
priority=100
|
||||
startinorder=true
|
||||
startsecs=0
|
||||
@@ -1,24 +0,0 @@
|
||||
[uwsgi]
|
||||
|
||||
chdir = /home/mediacms.io/mediacms/
|
||||
virtualenv = /home/mediacms.io
|
||||
module = cms.wsgi
|
||||
|
||||
uid=www-data
|
||||
gid=www-data
|
||||
|
||||
processes = 2
|
||||
threads = 2
|
||||
|
||||
master = true
|
||||
|
||||
socket = 127.0.0.1:9000
|
||||
|
||||
workers = 2
|
||||
|
||||
vacuum = true
|
||||
|
||||
hook-master-start = unix_signal:15 gracefully_kill_them_all
|
||||
need-app = true
|
||||
die-on-term = true
|
||||
buffer-size=32768
|
||||
@@ -1,16 +0,0 @@
|
||||
uwsgi_param QUERY_STRING $query_string;
|
||||
uwsgi_param REQUEST_METHOD $request_method;
|
||||
uwsgi_param CONTENT_TYPE $content_type;
|
||||
uwsgi_param CONTENT_LENGTH $content_length;
|
||||
|
||||
uwsgi_param REQUEST_URI $request_uri;
|
||||
uwsgi_param PATH_INFO $document_uri;
|
||||
uwsgi_param DOCUMENT_ROOT $document_root;
|
||||
uwsgi_param SERVER_PROTOCOL $server_protocol;
|
||||
uwsgi_param REQUEST_SCHEME $scheme;
|
||||
uwsgi_param HTTPS $https if_not_empty;
|
||||
|
||||
uwsgi_param REMOTE_ADDR $remote_addr;
|
||||
uwsgi_param REMOTE_PORT $remote_port;
|
||||
uwsgi_param SERVER_PORT $server_port;
|
||||
uwsgi_param SERVER_NAME $server_name;
|
||||
@@ -1,22 +0,0 @@
|
||||
[Unit]
|
||||
Description=MediaCMS celery beat
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/home/mediacms.io/mediacms
|
||||
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
|
||||
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/beat%n.pid"
|
||||
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/beat%N.log"
|
||||
Environment=CELERYD_LOG_LEVEL="INFO"
|
||||
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms beat --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL}'
|
||||
ExecStop=/bin/kill -s TERM $MAINPID
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
[Unit]
|
||||
Description=MediaCMS celery long queue
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/home/mediacms.io/mediacms
|
||||
Environment=CELERYD_NODES="long1"
|
||||
Environment=CELERY_QUEUE="long_tasks"
|
||||
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
|
||||
Environment=CELERYD_MULTI="multi"
|
||||
Environment=CELERYD_OPTS="-Ofair --prefetch-multiplier=1"
|
||||
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/%n.pid"
|
||||
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/%N.log"
|
||||
Environment=CELERYD_LOG_LEVEL="INFO"
|
||||
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms multi start ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
ExecStop=/bin/sh -c '${CELERY_BIN} -A cms multi stopwait ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE}'
|
||||
|
||||
ExecReload=/bin/sh -c '${CELERY_BIN} -A cms multi restart ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
[Unit]
|
||||
Description=MediaCMS celery short queue
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/home/mediacms.io/mediacms
|
||||
Environment=CELERYD_NODES="short1 short2"
|
||||
Environment=CELERY_QUEUE="short_tasks"
|
||||
# Absolute or relative path to the 'celery' command:
|
||||
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
|
||||
# App instance to use
|
||||
# comment out this line if you don't use an app
|
||||
# or fully qualified:
|
||||
#CELERY_APP="proj.tasks:app"
|
||||
# How to call manage.py
|
||||
Environment=CELERYD_MULTI="multi"
|
||||
# Extra command-line arguments to the worker
|
||||
Environment=CELERYD_OPTS="--soft-time-limit=300 -c10"
|
||||
# - %n will be replaced with the first part of the nodename.
|
||||
# - %I will be replaced with the current child process index
|
||||
# and is important when using the prefork pool to avoid race conditions.
|
||||
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/%n.pid"
|
||||
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/%N.log"
|
||||
Environment=CELERYD_LOG_LEVEL="INFO"
|
||||
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms multi start ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
ExecStop=/bin/sh -c '${CELERY_BIN} -A cms multi stopwait ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE}'
|
||||
|
||||
ExecReload=/bin/sh -c '${CELERY_BIN} -A cms multi restart ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
-----BEGIN DH PARAMETERS-----
|
||||
MIICCAKCAgEAo3MMiEY/fNbu+usIM0cDi6x8G3JBApv0Lswta4kiyedWT1WN51iQ
|
||||
9zhOFpmcu6517f/fR9MUdyhVKHxxSqWQTcmTEFtz4P3VLTS/W1N5VbKE2VEMLpIi
|
||||
wr350aGvV1Er0ujcp5n4O4h0I1tn4/fNyDe7+pHCdwM+hxe8hJ3T0/tKtad4fnIs
|
||||
WHDjl4f7m7KuFfheiK7Efb8MsT64HDDAYXn+INjtDZrbE5XPw20BqyWkrf07FcPx
|
||||
8o9GW50Ox7/FYq7jVMI/skEu0BRc8u6uUD9+UOuWUQpdeHeFcvLOgW53Z03XwWuX
|
||||
RXosUKzBPuGtUDAaKD/HsGW6xmGr2W9yRmu27jKpfYLUb/eWbbnRJwCw04LdzPqv
|
||||
jmtq02Gioo3lf5H5wYV9IYF6M8+q/slpbttsAcKERimD1273FBRt5VhSugkXWKjr
|
||||
XDhoXu6vZgj8Opei38qPa8pI1RUFoXHFlCe6WpZQmU8efL8gAMrJr9jUIY8eea1n
|
||||
u20t5B9ueb9JMjrNafcq6QkKhZLi6fRDDTUyeDvc0dN9R/3Yts97SXfdi1/lX7HS
|
||||
Ht4zXd5hEkvjo8GcnjsfZpAC39QfHWkDaeUGEqsl3jXjVMfkvoVY51OuokPWZzrJ
|
||||
M5+wyXNpfGbH67dPk7iHgN7VJvgX0SYscDPTtms50Vk7RwEzLeGuSHMCAQI=
|
||||
-----END DH PARAMETERS-----
|
||||
@@ -1,84 +0,0 @@
|
||||
server {
|
||||
listen 80 ;
|
||||
server_name localhost;
|
||||
|
||||
gzip on;
|
||||
access_log /var/log/nginx/mediacms.io.access.log;
|
||||
|
||||
error_log /var/log/nginx/mediacms.io.error.log warn;
|
||||
|
||||
# # redirect to https if logged in
|
||||
# if ($http_cookie ~* "sessionid") {
|
||||
# rewrite ^/(.*)$ https://localhost/$1 permanent;
|
||||
# }
|
||||
|
||||
# # redirect basic forms to https
|
||||
# location ~ (login|login_form|register|mail_password_form)$ {
|
||||
# rewrite ^/(.*)$ https://localhost/$1 permanent;
|
||||
# }
|
||||
|
||||
location /static {
|
||||
alias /home/mediacms.io/mediacms/static ;
|
||||
}
|
||||
|
||||
location /media/original {
|
||||
alias /home/mediacms.io/mediacms/media_files/original;
|
||||
}
|
||||
|
||||
location /media {
|
||||
alias /home/mediacms.io/mediacms/media_files ;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
|
||||
include /etc/nginx/sites-enabled/uwsgi_params;
|
||||
uwsgi_pass 127.0.0.1:9000;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name localhost;
|
||||
|
||||
ssl_certificate_key /etc/letsencrypt/live/localhost/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/localhost/fullchain.pem;
|
||||
ssl_dhparam /etc/nginx/dhparams/dhparams.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_ecdh_curve secp521r1:secp384r1;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
gzip on;
|
||||
access_log /var/log/nginx/mediacms.io.access.log;
|
||||
|
||||
error_log /var/log/nginx/mediacms.io.error.log warn;
|
||||
|
||||
location /static {
|
||||
alias /home/mediacms.io/mediacms/static ;
|
||||
}
|
||||
|
||||
location /media/original {
|
||||
alias /home/mediacms.io/mediacms/media_files/original;
|
||||
#auth_basic "auth protected area";
|
||||
#auth_basic_user_file /home/mediacms.io/mediacms/deploy/local_install/.htpasswd;
|
||||
}
|
||||
|
||||
location /media {
|
||||
alias /home/mediacms.io/mediacms/media_files ;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
|
||||
include /etc/nginx/sites-enabled/uwsgi_params;
|
||||
uwsgi_pass 127.0.0.1:9000;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFTjCCBDagAwIBAgISBNOUeDlerH9MkKmHLvZJeMYgMA0GCSqGSIb3DQEBCwUA
|
||||
MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD
|
||||
ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0yMDAzMTAxNzUxNDFaFw0y
|
||||
MDA2MDgxNzUxNDFaMBYxFDASBgNVBAMTC21lZGlhY21zLmlvMIIBIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAps5Jn18nW2tq/LYFDgQ1YZGLlpF/B2AAPvvH
|
||||
3yuD+AcT4skKdZouVL/a5pXrptuYL5lthO9dlcja2tuO2ltYrb7Dp01dAIFaJE8O
|
||||
DKd+Sv5wr8VWQZykqzMiMBgviml7TBvUHQjvCJg8UwmnN0XSUILCttd6u4qOzS7d
|
||||
lKMMsKpYzLhElBT0rzhhsWulDiy6aAZbMV95bfR74nIWsBJacy6jx3jvxAuvCtkB
|
||||
OVdOoVL6BPjDE3SNEk53bAZGIb5A9ri0O5jh/zBFT6tQSjUhAUTkmv9oZP547RnV
|
||||
fDj+rdvCVk/fE+Jno36mcT183Qd/Ty3fWuqFoM5g/luhnfvWEwIDAQABo4ICYDCC
|
||||
AlwwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
|
||||
AjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTd5EZBt74zu5XxT1uXQs6oM8qOuDAf
|
||||
BgNVHSMEGDAWgBSoSmpjBH3duubRObemRWXv86jsoTBvBggrBgEFBQcBAQRjMGEw
|
||||
LgYIKwYBBQUHMAGGImh0dHA6Ly9vY3NwLmludC14My5sZXRzZW5jcnlwdC5vcmcw
|
||||
LwYIKwYBBQUHMAKGI2h0dHA6Ly9jZXJ0LmludC14My5sZXRzZW5jcnlwdC5vcmcv
|
||||
MBYGA1UdEQQPMA2CC21lZGlhY21zLmlvMEwGA1UdIARFMEMwCAYGZ4EMAQIBMDcG
|
||||
CysGAQQBgt8TAQEBMCgwJgYIKwYBBQUHAgEWGmh0dHA6Ly9jcHMubGV0c2VuY3J5
|
||||
cHQub3JnMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHYAXqdz+d9WwOe1Nkh90Eng
|
||||
MnqRmgyEoRIShBh1loFxRVgAAAFwxcnL+AAABAMARzBFAiAb3yeBuW3j9MxcRc0T
|
||||
icUBvEa/rH7Fv2eB0oQlnZ1exQIhAPf+CtTXmzxoeT/BBiivj4AmGDsq4xWhe/U6
|
||||
BytYrKLeAHYAB7dcG+V9aP/xsMYdIxXHuuZXfFeUt2ruvGE6GmnTohwAAAFwxcnM
|
||||
HAAABAMARzBFAiAuP5gKyyaT0LVXxwjYD9zhezvxf4Icx0P9pk75c5ao+AIhAK0+
|
||||
fSJv+WTXciMT6gA1sk/tuCHuDFAuexSA/6TcRXcVMA0GCSqGSIb3DQEBCwUAA4IB
|
||||
AQCPCYBU4Q/ro2MUkjDPKGmeqdxQycS4R9WvKTG/nmoahKNg30bnLaDPUcpyMU2k
|
||||
sPDemdZ7uTGLZ3ZrlIva8DbrnJmrTPf9BMwaM6j+ZV/QhxvKZVIWkLkZrwiVI57X
|
||||
Ba+rs5IEB4oWJ0EBaeIrzeKG5zLMkRcIdE4Hlhuwu3zGG56c+wmAPuvpIDlYoO6o
|
||||
W22xRdxoTIHBvkzwonpVYUaRcaIw+48xnllxh1dHO+X69DT45wlF4tKveOUi+L50
|
||||
4GWJ8Vjv7Fot/WNHEM4Mnmw0jHj9TPkIZKnPNRMdHmJ5CF/FJFDiptOeuzbfohG+
|
||||
mdvuInb8JDc0XBE99Gf/S4/y
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEkjCCA3qgAwIBAgIQCgFBQgAAAVOFc2oLheynCDANBgkqhkiG9w0BAQsFADA/
|
||||
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
|
||||
DkRTVCBSb290IENBIFgzMB4XDTE2MDMxNzE2NDA0NloXDTIxMDMxNzE2NDA0Nlow
|
||||
SjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUxldCdzIEVuY3J5cHQxIzAhBgNVBAMT
|
||||
GkxldCdzIEVuY3J5cHQgQXV0aG9yaXR5IFgzMIIBIjANBgkqhkiG9w0BAQEFAAOC
|
||||
AQ8AMIIBCgKCAQEAnNMM8FrlLke3cl03g7NoYzDq1zUmGSXhvb418XCSL7e4S0EF
|
||||
q6meNQhY7LEqxGiHC6PjdeTm86dicbp5gWAf15Gan/PQeGdxyGkOlZHP/uaZ6WA8
|
||||
SMx+yk13EiSdRxta67nsHjcAHJyse6cF6s5K671B5TaYucv9bTyWaN8jKkKQDIZ0
|
||||
Z8h/pZq4UmEUEz9l6YKHy9v6Dlb2honzhT+Xhq+w3Brvaw2VFn3EK6BlspkENnWA
|
||||
a6xK8xuQSXgvopZPKiAlKQTGdMDQMc2PMTiVFrqoM7hD8bEfwzB/onkxEz0tNvjj
|
||||
/PIzark5McWvxI0NHWQWM6r6hCm21AvA2H3DkwIDAQABo4IBfTCCAXkwEgYDVR0T
|
||||
AQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwfwYIKwYBBQUHAQEEczBxMDIG
|
||||
CCsGAQUFBzABhiZodHRwOi8vaXNyZy50cnVzdGlkLm9jc3AuaWRlbnRydXN0LmNv
|
||||
bTA7BggrBgEFBQcwAoYvaHR0cDovL2FwcHMuaWRlbnRydXN0LmNvbS9yb290cy9k
|
||||
c3Ryb290Y2F4My5wN2MwHwYDVR0jBBgwFoAUxKexpHsscfrb4UuQdf/EFWCFiRAw
|
||||
VAYDVR0gBE0wSzAIBgZngQwBAgEwPwYLKwYBBAGC3xMBAQEwMDAuBggrBgEFBQcC
|
||||
ARYiaHR0cDovL2Nwcy5yb290LXgxLmxldHNlbmNyeXB0Lm9yZzA8BgNVHR8ENTAz
|
||||
MDGgL6AthitodHRwOi8vY3JsLmlkZW50cnVzdC5jb20vRFNUUk9PVENBWDNDUkwu
|
||||
Y3JsMB0GA1UdDgQWBBSoSmpjBH3duubRObemRWXv86jsoTANBgkqhkiG9w0BAQsF
|
||||
AAOCAQEA3TPXEfNjWDjdGBX7CVW+dla5cEilaUcne8IkCJLxWh9KEik3JHRRHGJo
|
||||
uM2VcGfl96S8TihRzZvoroed6ti6WqEBmtzw3Wodatg+VyOeph4EYpr/1wXKtx8/
|
||||
wApIvJSwtmVi4MFU5aMqrSDE6ea73Mj2tcMyo5jMd6jmeWUHK8so/joWUoHOUgwu
|
||||
X4Po1QYz+3dszkDqMp4fklxBwXRsW10KXzPMTZ+sOPAveyxindmjkW8lGy+QsRlG
|
||||
PfZ+G6Z6h7mjem0Y+iWlkYcV4PIWL1iwBi8saCbGS5jN2p8M+X+Q7UNKEkROb3N6
|
||||
KOqkqm57TH2H3eDJAkSnh6/DNFu0Qg==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,28 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmzkmfXydba2r8
|
||||
tgUOBDVhkYuWkX8HYAA++8ffK4P4BxPiyQp1mi5Uv9rmleum25gvmW2E712VyNra
|
||||
247aW1itvsOnTV0AgVokTw4Mp35K/nCvxVZBnKSrMyIwGC+KaXtMG9QdCO8ImDxT
|
||||
Cac3RdJQgsK213q7io7NLt2UowywqljMuESUFPSvOGGxa6UOLLpoBlsxX3lt9Hvi
|
||||
chawElpzLqPHeO/EC68K2QE5V06hUvoE+MMTdI0STndsBkYhvkD2uLQ7mOH/MEVP
|
||||
q1BKNSEBROSa/2hk/njtGdV8OP6t28JWT98T4mejfqZxPXzdB39PLd9a6oWgzmD+
|
||||
W6Gd+9YTAgMBAAECggEADnEJuryYQbf5GUwBAAepP3tEZJLQNqk/HDTcRxwTXuPt
|
||||
+tKBD1F79WZu40vTjSyx7l0QOFQo/BDZsd0Ubx89fD1p3xA5nxOT5FTb2IifzIpe
|
||||
4zjokOGo+BGDQjq10vvy6tH1+VWOrGXRwzawvX5UCRhpFz9sptQGLQmDsZy0Oo9B
|
||||
LtavYVUqsbyqRWlzaclHgbythegIACWkqcalOzOtx+l6TGBRjej+c7URcwYBfr7t
|
||||
XTAzbP+vnpaJovZyZT1eekr0OLzMpnjx4HvRvzL+NxauRpn6KfabsTfZlk8nrs4I
|
||||
UdSjeukj1Iz8rGQilHdN/4dVJ3KzrlHVkVTBSjmMUQKBgQDaVXZnhAScfdiKeZbO
|
||||
rdUAWcnwfkDghtRuAmzHaRM/FhFBEoVhdSbBuu+OUyBnIw/Ra4o2ePuEBcKIUiQO
|
||||
w2tnE1CY5PPAcjw+OCSpvzy5xxjaqaRbm9BJp3FTeEYGLXERnchPpHg/NpexuF22
|
||||
QOJ+FrysPyNMxuQp47ZwO9WT3QKBgQDDlSGjq/eeWxemwf7ZqMVlRyqsdJsgnCew
|
||||
DkC62IGiYCBDfeEmndN+vcA/uzJHYV4iXiqS3aYJCWGaZFMhdIhIn5MgULvO1j5G
|
||||
u/MxuzaaNPz22FlNCWTLBw4T1HOOvyTL+nLtZDKJ/BHxgHCmur1kiGvvZWrcCthD
|
||||
afLEmseqrwKBgBuLZKCymxJTHhp6NHhmndSpfzyD8RNibzJhw+90ZiUzV4HqIEGn
|
||||
Ufhm6Qn/mrroRXqaIpm0saZ6Q4yHMF1cchRS73wahlXlE4yV8KopojOd1pjfhgi4
|
||||
o5JnOXjaV5s36GfcjATgLvtqm8CkDc6MaQaXP75LSNzKysYuIDoQkmVRAoGAAghF
|
||||
rja2Pv4BU+lGJarcSj4gEmSvy/nza5/qSka/qhlHnIvtUAJp1TJRkhf24MkBOmgy
|
||||
Fw6YkBV53ynVt05HsEGAPOC54t9VDFUdpNGmMpoEWuhKnUNQuc9b9RbLEJup3TjA
|
||||
Avl8kPR+lzzXbtQX7biBLp6mKp0uPB0YubRGCN8CgYA0JMxK0x38Q2x3AQVhOmZh
|
||||
YubtIa0JqVJhvpweOCFnkq3ebBpLsWYwiLTn86vuD0jupe5M3sxtefjkJmAKd8xY
|
||||
aBU7QWhjh1fX4mzmggnbjcrIFbkIHsxwMeg567U/4AGxOOUsv9QUn37mqycqRKEn
|
||||
YfUyYNLM6F3MmQAOs2kaHw==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -1,13 +0,0 @@
|
||||
[Unit]
|
||||
Description=MediaCMS uwsgi
|
||||
|
||||
[Service]
|
||||
ExecStart=/home/mediacms.io/bin/uwsgi --ini /home/mediacms.io/mediacms/deploy/local_install/uwsgi.ini
|
||||
ExecStop=/usr/bin/killall -9 uwsgi
|
||||
RestartSec=3
|
||||
#ExecRestart=killall -9 uwsgi; sleep 5; /home/sss/bin/uwsgi --ini /home/sss/wordgames/uwsgi.ini
|
||||
Restart=always
|
||||
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,7 +0,0 @@
|
||||
/home/mediacms.io/mediacms/logs/*.log {
|
||||
weekly
|
||||
missingok
|
||||
rotate 7
|
||||
compress
|
||||
notifempty
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
user www-data;
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 10240;
|
||||
}
|
||||
|
||||
worker_rlimit_nofile 20000; #each connection needs a filehandle (or 2 if you are proxying)
|
||||
http {
|
||||
proxy_connect_timeout 75;
|
||||
proxy_read_timeout 12000;
|
||||
client_max_body_size 5800M;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 10;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
|
||||
log_format compression '$remote_addr - $remote_user [$time_local] '
|
||||
'"$request" $status $body_bytes_sent '
|
||||
'"$http_referer" "$http_user_agent" "$gzip_ratio"';
|
||||
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
include /etc/nginx/sites-enabled/*;
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
module selinux-mediacms 1.0;
|
||||
|
||||
require {
|
||||
type init_t;
|
||||
type var_t;
|
||||
type redis_port_t;
|
||||
type postgresql_port_t;
|
||||
type httpd_t;
|
||||
type httpd_sys_content_t;
|
||||
type httpd_sys_rw_content_t;
|
||||
class file { append create execute execute_no_trans getattr ioctl lock open read rename setattr unlink write };
|
||||
class dir { add_name remove_name rmdir };
|
||||
class tcp_socket name_connect;
|
||||
class lnk_file read;
|
||||
}
|
||||
|
||||
#============= httpd_t ==============
|
||||
|
||||
allow httpd_t var_t:file { getattr open read };
|
||||
|
||||
#============= init_t ==============
|
||||
allow init_t postgresql_port_t:tcp_socket name_connect;
|
||||
|
||||
allow init_t redis_port_t:tcp_socket name_connect;
|
||||
|
||||
allow init_t httpd_sys_content_t:dir rmdir;
|
||||
|
||||
allow init_t httpd_sys_content_t:file { append create execute execute_no_trans ioctl lock open read rename setattr unlink write };
|
||||
|
||||
allow init_t httpd_sys_content_t:lnk_file read;
|
||||
|
||||
allow init_t httpd_sys_rw_content_t:dir { add_name remove_name rmdir };
|
||||
|
||||
allow init_t httpd_sys_rw_content_t:file { create ioctl lock open read setattr unlink write };
|
||||
@@ -1,27 +0,0 @@
|
||||
[uwsgi]
|
||||
|
||||
chdir = /home/mediacms.io/mediacms/
|
||||
virtualenv = /home/mediacms.io
|
||||
module = cms.wsgi
|
||||
|
||||
uid=www-data
|
||||
gid=www-data
|
||||
|
||||
processes = 2
|
||||
threads = 2
|
||||
|
||||
master = true
|
||||
|
||||
socket = 127.0.0.1:9000
|
||||
#socket = /home/mediacms.io/mediacms/deploy/uwsgi.sock
|
||||
|
||||
|
||||
workers = 2
|
||||
|
||||
|
||||
vacuum = true
|
||||
|
||||
logto = /home/mediacms.io/mediacms/logs/errorlog.txt
|
||||
|
||||
disable-logging = true
|
||||
buffer-size=32768
|
||||
@@ -1,16 +0,0 @@
|
||||
uwsgi_param QUERY_STRING $query_string;
|
||||
uwsgi_param REQUEST_METHOD $request_method;
|
||||
uwsgi_param CONTENT_TYPE $content_type;
|
||||
uwsgi_param CONTENT_LENGTH $content_length;
|
||||
|
||||
uwsgi_param REQUEST_URI $request_uri;
|
||||
uwsgi_param PATH_INFO $document_uri;
|
||||
uwsgi_param DOCUMENT_ROOT $document_root;
|
||||
uwsgi_param SERVER_PROTOCOL $server_protocol;
|
||||
uwsgi_param REQUEST_SCHEME $scheme;
|
||||
uwsgi_param HTTPS $https if_not_empty;
|
||||
|
||||
uwsgi_param REMOTE_ADDR $remote_addr;
|
||||
uwsgi_param REMOTE_PORT $remote_port;
|
||||
uwsgi_param SERVER_PORT $server_port;
|
||||
uwsgi_param SERVER_NAME $server_name;
|
||||
@@ -23,7 +23,7 @@ and will start all services required for MediaCMS, as Celery/Redis for asynchron
|
||||
For Django, the changes from the image produced by docker-compose.yaml are these:
|
||||
|
||||
* Django runs in debug mode, with `python manage.py runserver`
|
||||
* uwsgi and nginx are not run
|
||||
* gunicorn and nginx are not run
|
||||
* Django runs in Debug mode, with Debug Toolbar
|
||||
* Static files (js/css) are loaded from static/ folder
|
||||
* corsheaders is installed and configured to allow all origins
|
||||
|
||||
@@ -65,6 +65,7 @@ class CategoryAdminForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Category
|
||||
# LTI fields will be shown as read-only when USE_LTI is enabled
|
||||
fields = '__all__'
|
||||
|
||||
def clean(self):
|
||||
@@ -135,7 +136,7 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "user", "add_date", "media_count"]
|
||||
list_filter = []
|
||||
ordering = ("-add_date",)
|
||||
readonly_fields = ("user", "media_count")
|
||||
readonly_fields = ("user", "media_count", "lti_platform", "lti_context_id")
|
||||
change_form_template = 'admin/files/category/change_form.html'
|
||||
|
||||
def get_list_filter(self, request):
|
||||
@@ -145,6 +146,8 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
list_filter.insert(0, "is_rbac_category")
|
||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
list_filter.insert(-1, "identity_provider")
|
||||
if getattr(settings, 'USE_LTI', False):
|
||||
list_filter.append("is_lms_course")
|
||||
|
||||
return list_filter
|
||||
|
||||
@@ -154,6 +157,8 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display.insert(-1, "is_rbac_category")
|
||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
list_display.insert(-1, "identity_provider")
|
||||
if getattr(settings, 'USE_LTI', False):
|
||||
list_display.insert(-1, "is_lms_course")
|
||||
|
||||
return list_display
|
||||
|
||||
@@ -167,6 +172,14 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
),
|
||||
]
|
||||
|
||||
additional_fieldsets = []
|
||||
|
||||
if getattr(settings, 'USE_LTI', False):
|
||||
lti_fieldset = [
|
||||
('LTI Integration', {'fields': ['lti_platform', 'lti_context_id'], 'classes': ['tab'], 'description': 'LTI/LMS integration settings (automatically managed by LTI provisioning)'}),
|
||||
]
|
||||
additional_fieldsets.extend(lti_fieldset)
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_fieldset = [
|
||||
('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
|
||||
@@ -177,9 +190,9 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
('RBAC Settings', {'fields': ['is_rbac_category', 'identity_provider'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
|
||||
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
|
||||
]
|
||||
return basic_fieldset + rbac_fieldset
|
||||
else:
|
||||
return basic_fieldset
|
||||
additional_fieldsets.extend(rbac_fieldset)
|
||||
|
||||
return basic_fieldset + additional_fieldsets
|
||||
|
||||
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -64,4 +64,10 @@ def stuff(request):
|
||||
if request.user.is_superuser:
|
||||
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL
|
||||
|
||||
if getattr(settings, 'USE_LTI', False):
|
||||
lti_session = request.session.get('lti_session')
|
||||
|
||||
if lti_session and request.user.is_authenticated:
|
||||
ret['lti_session'] = lti_session
|
||||
|
||||
return ret
|
||||
|
||||
@@ -127,6 +127,7 @@ class MediaPublishForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
self.request = kwargs.pop('request', None)
|
||||
super(MediaPublishForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.has_custom_permissions = self.instance.permissions.exists() if self.instance.pk else False
|
||||
@@ -169,6 +170,16 @@ class MediaPublishForm(forms.ModelForm):
|
||||
|
||||
self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title')
|
||||
|
||||
# Filter for LMS courses only when in embed mode
|
||||
if self.request and 'category' in self.fields:
|
||||
is_embed_mode = self._check_embed_mode()
|
||||
if is_embed_mode:
|
||||
current_queryset = self.fields['category'].queryset
|
||||
self.fields['category'].queryset = current_queryset.filter(is_lms_course=True)
|
||||
self.fields['category'].label = 'Course'
|
||||
self.fields['category'].help_text = 'Media can be shared with one or more courses'
|
||||
self.fields['category'].widget.is_lms_mode = True
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = True
|
||||
self.helper.form_class = 'post-form'
|
||||
@@ -186,6 +197,22 @@ class MediaPublishForm(forms.ModelForm):
|
||||
|
||||
self.helper.layout.append(FormActions(Submit('submit', 'Publish Media', css_class='primaryAction')))
|
||||
|
||||
def _check_embed_mode(self):
|
||||
"""Check if the current request is in embed mode"""
|
||||
if not self.request:
|
||||
return False
|
||||
|
||||
# Check query parameter
|
||||
mode = self.request.GET.get('mode', '')
|
||||
if mode == 'lms_embed_mode':
|
||||
return True
|
||||
|
||||
# Check session storage
|
||||
if self.request.session.get('lms_embed_mode') == 'true':
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
state = cleaned_data.get("state")
|
||||
|
||||
@@ -965,3 +965,13 @@ def get_alphanumeric_only(string):
|
||||
"""
|
||||
string = "".join([char for char in string if char.isalnum()])
|
||||
return string.lower()
|
||||
|
||||
|
||||
def get_alphanumeric_and_spaces(string):
|
||||
"""Returns a query that contains only alphanumeric characters and spaces
|
||||
This include characters other than the English alphabet too
|
||||
"""
|
||||
string = "".join([char for char in string if char.isalnum() or char.isspace()])
|
||||
# Replace multiple spaces with single space and strip
|
||||
string = " ".join(string.split())
|
||||
return string
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-29 16:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0014_alter_subtitle_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='is_lms_course',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='Whether this category represents an LMS course'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='lti_context_id',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='LTI context ID from platform', max_length=255),
|
||||
),
|
||||
]
|
||||
21
files/migrations/0016_category_lti_platform.py
Normal file
21
files/migrations/0016_category_lti_platform.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-29 16:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0015_category_is_lms_course_category_lti_context_id'),
|
||||
('lti', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='lti_platform',
|
||||
field=models.ForeignKey(
|
||||
blank=True, help_text='LTI Platform if this is an LTI course', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='categories', to='lti.ltiplatform'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -47,6 +47,13 @@ class Category(models.Model):
|
||||
verbose_name='IDP Config Name',
|
||||
)
|
||||
|
||||
# LTI/LMS integration fields
|
||||
is_lms_course = models.BooleanField(default=False, db_index=True, help_text='Whether this category represents an LMS course')
|
||||
|
||||
lti_platform = models.ForeignKey('lti.LTIPlatform', blank=True, null=True, on_delete=models.SET_NULL, related_name='categories', help_text='LTI Platform if this is an LTI course')
|
||||
|
||||
lti_context_id = models.CharField(max_length=255, blank=True, db_index=True, help_text='LTI context ID from platform')
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@@ -137,7 +144,7 @@ class Tag(models.Model):
|
||||
return True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.title = helpers.get_alphanumeric_only(self.title)
|
||||
self.title = helpers.get_alphanumeric_and_spaces(self.title)
|
||||
self.title = self.title[:100]
|
||||
super(Tag, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -352,20 +352,11 @@ class Media(models.Model):
|
||||
# first get anything interesting out of the media
|
||||
# that needs to be search able
|
||||
|
||||
a_tags = b_tags = ""
|
||||
a_tags = ""
|
||||
if self.id:
|
||||
a_tags = " ".join([tag.title for tag in self.tags.all()])
|
||||
b_tags = " ".join([tag.title.replace("-", " ") for tag in self.tags.all()])
|
||||
|
||||
items = [
|
||||
self.title,
|
||||
self.user.username,
|
||||
self.user.email,
|
||||
self.user.name,
|
||||
self.description,
|
||||
a_tags,
|
||||
b_tags,
|
||||
]
|
||||
items = [self.friendly_token, self.title, self.user.username, self.user.email, self.user.name, self.description, a_tags]
|
||||
|
||||
for subtitle in self.subtitles.all():
|
||||
items.append(subtitle.subtitle_text)
|
||||
@@ -745,7 +736,7 @@ class Media(models.Model):
|
||||
|
||||
ret = []
|
||||
for cat in self.category.all():
|
||||
ret.append({"title": cat.title, "url": cat.get_absolute_url()})
|
||||
ret.append({"title": cat.title, "url": cat.get_absolute_url(), "is_lms_course": cat.is_lms_course})
|
||||
return ret
|
||||
|
||||
@property
|
||||
|
||||
@@ -226,6 +226,7 @@ class CategorySerializer(serializers.ModelSerializer):
|
||||
"media_count",
|
||||
"user",
|
||||
"thumbnail_url",
|
||||
"is_lms_course",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ urlpatterns = [
|
||||
views.trim_video,
|
||||
),
|
||||
re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
|
||||
re_path(r"^api/v1/categories/contributor$", views.CategoryListContributor.as_view()),
|
||||
re_path(r"^api/v1/tags$", views.TagList.as_view()),
|
||||
re_path(r"^api/v1/comments$", views.CommentList.as_view()),
|
||||
re_path(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Import all views for backward compatibility
|
||||
|
||||
from .auth import custom_login_view, saml_metadata # noqa: F401
|
||||
from .categories import CategoryList, TagList # noqa: F401
|
||||
from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401
|
||||
from .comments import CommentDetail, CommentList # noqa: F401
|
||||
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
|
||||
from .media import MediaActions # noqa: F401
|
||||
|
||||
@@ -43,6 +43,49 @@ class CategoryList(APIView):
|
||||
return Response(ret)
|
||||
|
||||
|
||||
class CategoryListContributor(APIView):
|
||||
"""List categories where user has contributor access"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
name='lms_courses_only',
|
||||
type=openapi.TYPE_BOOLEAN,
|
||||
in_=openapi.IN_QUERY,
|
||||
description='Filter to show only LMS courses (categories with is_lms_course=True)',
|
||||
),
|
||||
],
|
||||
tags=['Categories'],
|
||||
operation_summary='Lists Categories for Contributors',
|
||||
operation_description='Lists all categories where the user has contributor access',
|
||||
responses={
|
||||
200: openapi.Response('response description', CategorySerializer),
|
||||
},
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
if not request.user.is_authenticated:
|
||||
return Response([])
|
||||
|
||||
categories = Category.objects.none()
|
||||
|
||||
# Filter for LMS courses only if requested
|
||||
lms_courses_only = request.GET.get('lms_courses_only', '').lower() in ['true', '1', 'yes']
|
||||
if lms_courses_only:
|
||||
categories = categories.filter(is_lms_course=True)
|
||||
else:
|
||||
categories = Category.objects.filter(is_rbac_category=False).prefetch_related("user")
|
||||
|
||||
# Get RBAC categories where user has contributor access
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_categories = request.user.get_rbac_categories_as_contributor()
|
||||
categories = categories.union(rbac_categories)
|
||||
|
||||
categories = categories.order_by("title")
|
||||
|
||||
serializer = CategorySerializer(categories, many=True, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class TagList(APIView):
|
||||
"""List tags"""
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ class MediaList(APIView):
|
||||
rbac_categories = request.user.get_rbac_categories_as_member()
|
||||
conditions |= Q(category__in=rbac_categories)
|
||||
|
||||
media = base_queryset.filter(conditions).distinct()
|
||||
media = base_queryset.filter(conditions).exclude(user=request.user).distinct()
|
||||
elif author_param:
|
||||
user_queryset = User.objects.all()
|
||||
user = get_object_or_404(user_queryset, username=author_param)
|
||||
@@ -574,12 +574,31 @@ class MediaBulkUserActions(APIView):
|
||||
|
||||
elif action == "add_to_category":
|
||||
category_uids = request.data.get('category_uids', [])
|
||||
if not category_uids:
|
||||
return Response({"detail": "category_uids is required for add_to_category action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
lti_context_id = request.data.get('lti_context_id')
|
||||
|
||||
if not category_uids and not lti_context_id:
|
||||
return Response({"detail": "category_uids or lti_context_id is required for add_to_category action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
categories = Category.objects.none()
|
||||
|
||||
# Prioritize category_uids
|
||||
if category_uids:
|
||||
categories = Category.objects.filter(uid__in=category_uids)
|
||||
elif lti_context_id:
|
||||
# Filter categories by lti_context_id and ensure they ARE RBAC categories
|
||||
potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True)
|
||||
|
||||
# Check user access (must have contributor access)
|
||||
valid_category_ids = []
|
||||
for cat in potential_categories:
|
||||
if request.user.has_contributor_access_to_category(cat):
|
||||
valid_category_ids.append(cat.id)
|
||||
|
||||
if valid_category_ids:
|
||||
categories = Category.objects.filter(id__in=valid_category_ids)
|
||||
|
||||
categories = Category.objects.filter(uid__in=category_uids)
|
||||
if not categories:
|
||||
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"detail": "No matching categories found or access denied"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
added_count = 0
|
||||
for category in categories:
|
||||
@@ -697,12 +716,9 @@ class MediaDetail(APIView):
|
||||
return media
|
||||
|
||||
serializer = SingleMediaSerializer(media, context={"request": request})
|
||||
if media.state == "private":
|
||||
related_media = []
|
||||
else:
|
||||
related_media = show_related_media(media, request=request, limit=100)
|
||||
related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request})
|
||||
related_media = related_media_serializer.data
|
||||
related_media = show_related_media(media, request=request, limit=100)
|
||||
related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request})
|
||||
related_media = related_media_serializer.data
|
||||
ret = serializer.data
|
||||
|
||||
# update rattings info with user specific ratings
|
||||
|
||||
@@ -24,7 +24,7 @@ from ..forms import (
|
||||
WhisperSubtitlesForm,
|
||||
)
|
||||
from ..frontend_translations import translate_string
|
||||
from ..helpers import get_alphanumeric_only
|
||||
from ..helpers import get_alphanumeric_and_spaces
|
||||
from ..methods import (
|
||||
can_transcribe_video,
|
||||
create_video_trim_request,
|
||||
@@ -310,8 +310,8 @@ def edit_media(request):
|
||||
media.tags.remove(tag)
|
||||
if form.cleaned_data.get("new_tags"):
|
||||
for tag in form.cleaned_data.get("new_tags").split(","):
|
||||
tag = get_alphanumeric_only(tag)
|
||||
tag = tag[:99]
|
||||
tag = get_alphanumeric_and_spaces(tag)
|
||||
tag = tag[:100]
|
||||
if tag:
|
||||
try:
|
||||
tag = Tag.objects.get(title=tag)
|
||||
@@ -350,13 +350,13 @@ def publish_media(request):
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
|
||||
if request.method == "POST":
|
||||
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media)
|
||||
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media, request=request)
|
||||
if form.is_valid():
|
||||
media = form.save()
|
||||
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
else:
|
||||
form = MediaPublishForm(request.user, instance=media)
|
||||
form = MediaPublishForm(request.user, instance=media, request=request)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
@@ -3,6 +3,8 @@ import json
|
||||
from django import forms
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from .models import Category
|
||||
|
||||
|
||||
class CategoryModalWidget(forms.SelectMultiple):
|
||||
"""Two-panel category selector with modal"""
|
||||
@@ -12,28 +14,42 @@ class CategoryModalWidget(forms.SelectMultiple):
|
||||
js = ('js/category_modal.js',)
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
is_lms_mode = getattr(self, 'is_lms_mode', False)
|
||||
|
||||
# Get all categories as JSON
|
||||
categories = []
|
||||
for opt_value, opt_label in self.choices:
|
||||
if opt_value: # Skip empty choice
|
||||
categories.append({'id': str(opt_value), 'title': str(opt_label)})
|
||||
# Extract the actual ID value from ModelChoiceIteratorValue if needed
|
||||
category_id = opt_value.value if hasattr(opt_value, 'value') else opt_value
|
||||
|
||||
# Get is_lms_course info from the Category object
|
||||
try:
|
||||
cat_obj = Category.objects.get(id=category_id)
|
||||
categories.append({'id': str(category_id), 'title': str(opt_label), 'is_lms_course': cat_obj.is_lms_course})
|
||||
except Category.DoesNotExist:
|
||||
categories.append({'id': str(category_id), 'title': str(opt_label), 'is_lms_course': False})
|
||||
|
||||
all_categories_json = json.dumps(categories)
|
||||
selected_ids_json = json.dumps([str(v) for v in (value or [])])
|
||||
lms_mode_json = json.dumps(is_lms_mode)
|
||||
|
||||
search_placeholder = "Search courses..." if is_lms_mode else "Search categories..."
|
||||
selected_header = "Selected Courses" if is_lms_mode else "Selected Categories"
|
||||
|
||||
html = f'''<div class="category-widget" data-name="{name}">
|
||||
<div class="category-content">
|
||||
<div class="category-panel">
|
||||
<input type="text" class="category-search" placeholder="Search categories...">
|
||||
<input type="text" class="category-search" placeholder="{search_placeholder}">
|
||||
<div class="category-list scrollable" data-panel="left"></div>
|
||||
</div>
|
||||
<div class="category-panel">
|
||||
<h3>Selected Categories</h3>
|
||||
<h3>{selected_header}</h3>
|
||||
<div class="category-list scrollable" data-panel="right"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden-inputs"></div>
|
||||
<script type="application/json" class="category-data">{{"all":{all_categories_json},"selected":{selected_ids_json}}}</script>
|
||||
<script type="application/json" class="category-data">{{"all":{all_categories_json},"selected":{selected_ids_json},"lms_mode":{lms_mode_json}}}</script>
|
||||
</div>'''
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
34
frontend-tools/video-js/examples/full-screen-video.html
Normal file
34
frontend-tools/video-js/examples/full-screen-video.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" style="height: 100%">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embedded Video - Full Screen</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe
|
||||
src="https://demo.mediacms.io/embed?m=zK2nirNLC"
|
||||
style="
|
||||
width: 100%;
|
||||
max-width: calc(100vh * 16 / 9);
|
||||
aspect-ratio: 16 / 9;
|
||||
display: block;
|
||||
margin: auto;
|
||||
border: 0;
|
||||
"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</body>
|
||||
</html>
|
||||
@@ -204,6 +204,54 @@ class SeekIndicator extends Component {
|
||||
</div>
|
||||
`;
|
||||
textEl.textContent = 'Pause';
|
||||
} else if (direction === 'copy-url') {
|
||||
iconEl.innerHTML = `
|
||||
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
|
||||
<div style="
|
||||
width: ${circleSize};
|
||||
height: ${circleSize};
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
">
|
||||
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
textEl.textContent = '';
|
||||
} else if (direction === 'copy-embed') {
|
||||
iconEl.innerHTML = `
|
||||
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
|
||||
<div style="
|
||||
width: ${circleSize};
|
||||
height: ${circleSize};
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
">
|
||||
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
|
||||
<path d="M16 18l6-6-6-6"/>
|
||||
<path d="M8 6l-6 6 6 6"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
textEl.textContent = '';
|
||||
}
|
||||
|
||||
// Clear any text content in the text element
|
||||
@@ -239,6 +287,11 @@ class SeekIndicator extends Component {
|
||||
this.showTimeout = setTimeout(() => {
|
||||
this.hide();
|
||||
}, 500);
|
||||
} else if (direction === 'copy-url' || direction === 'copy-embed') {
|
||||
// Copy operations: 500ms (same as play/pause)
|
||||
this.showTimeout = setTimeout(() => {
|
||||
this.hide();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,22 @@ class EmbedInfoOverlay extends Component {
|
||||
this.authorThumbnail = options.authorThumbnail || '';
|
||||
this.videoTitle = options.videoTitle || 'Video';
|
||||
this.videoUrl = options.videoUrl || '';
|
||||
this.showTitle = options.showTitle !== undefined ? options.showTitle : true;
|
||||
this.showRelated = options.showRelated !== undefined ? options.showRelated : true;
|
||||
this.showUserAvatar = options.showUserAvatar !== undefined ? options.showUserAvatar : true;
|
||||
this.linkTitle = options.linkTitle !== undefined ? options.linkTitle : true;
|
||||
|
||||
// Initialize after player is ready
|
||||
this.player().ready(() => {
|
||||
this.createOverlay();
|
||||
if (this.showTitle) {
|
||||
this.createOverlay();
|
||||
} else {
|
||||
// Hide overlay element if showTitle is false
|
||||
const overlay = this.el();
|
||||
overlay.style.display = 'none';
|
||||
overlay.style.opacity = '0';
|
||||
overlay.style.visibility = 'hidden';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,7 +61,7 @@ class EmbedInfoOverlay extends Component {
|
||||
`;
|
||||
|
||||
// Create avatar container
|
||||
if (this.authorThumbnail) {
|
||||
if (this.authorThumbnail && this.showUserAvatar) {
|
||||
const avatarContainer = document.createElement('div');
|
||||
avatarContainer.className = 'embed-avatar-container';
|
||||
avatarContainer.style.cssText = `
|
||||
@@ -125,7 +137,7 @@ class EmbedInfoOverlay extends Component {
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
if (this.videoUrl) {
|
||||
if (this.videoUrl && this.linkTitle) {
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.href = this.videoUrl;
|
||||
titleLink.target = '_blank';
|
||||
@@ -186,10 +198,16 @@ class EmbedInfoOverlay extends Component {
|
||||
const player = this.player();
|
||||
const overlay = this.el();
|
||||
|
||||
// If showTitle is false, ensure overlay is hidden
|
||||
if (!this.showTitle) {
|
||||
overlay.style.display = 'none';
|
||||
overlay.style.opacity = '0';
|
||||
overlay.style.visibility = 'hidden';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync overlay visibility with control bar visibility
|
||||
const updateOverlayVisibility = () => {
|
||||
const controlBar = player.getChild('controlBar');
|
||||
|
||||
if (!player.hasStarted()) {
|
||||
// Show overlay when video hasn't started (poster is showing) - like before
|
||||
overlay.style.opacity = '1';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
.video-context-menu {
|
||||
position: fixed;
|
||||
background-color: #282828;
|
||||
border-radius: 4px;
|
||||
padding: 4px 0;
|
||||
min-width: 240px;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
|
||||
.video-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.video-context-menu-item:hover {
|
||||
background-color: #3d3d3d;
|
||||
}
|
||||
|
||||
.video-context-menu-item:active {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.video-context-menu-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.video-context-menu-item span {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import './VideoContextMenu.css';
|
||||
|
||||
function VideoContextMenu({ visible, position, onClose, onCopyVideoUrl, onCopyVideoUrlAtTime, onCopyEmbedCode }) {
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && menuRef.current) {
|
||||
// Position the menu
|
||||
menuRef.current.style.left = `${position.x}px`;
|
||||
menuRef.current.style.top = `${position.y}px`;
|
||||
|
||||
// Adjust if menu goes off screen
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (rect.right > windowWidth) {
|
||||
menuRef.current.style.left = `${position.x - rect.width}px`;
|
||||
}
|
||||
if (rect.bottom > windowHeight) {
|
||||
menuRef.current.style.top = `${position.y - rect.height}px`;
|
||||
}
|
||||
}
|
||||
}, [visible, position]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (visible && menuRef.current && !menuRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape' && visible) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (visible) {
|
||||
// Use capture phase to catch events earlier, before they can be stopped
|
||||
// Listen to both mousedown and click to ensure we catch all clicks
|
||||
document.addEventListener('mousedown', handleClickOutside, true);
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside, true);
|
||||
document.removeEventListener('click', handleClickOutside, true);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [visible, onClose]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="video-context-menu" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="video-context-menu-item" onClick={onCopyVideoUrl}>
|
||||
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>Copy video URL</span>
|
||||
</div>
|
||||
<div className="video-context-menu-item" onClick={onCopyVideoUrlAtTime}>
|
||||
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>Copy video URL at current time</span>
|
||||
</div>
|
||||
<div className="video-context-menu-item" onClick={onCopyEmbedCode}>
|
||||
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 18l6-6-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M8 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>Copy embed code</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VideoContextMenu;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react';
|
||||
import videojs from 'video.js';
|
||||
import 'video.js/dist/video-js.css';
|
||||
import '../../styles/embed.css';
|
||||
@@ -17,6 +17,7 @@ import CustomRemainingTime from '../controls/CustomRemainingTime';
|
||||
import CustomChaptersOverlay from '../controls/CustomChaptersOverlay';
|
||||
import CustomSettingsMenu from '../controls/CustomSettingsMenu';
|
||||
import SeekIndicator from '../controls/SeekIndicator';
|
||||
import VideoContextMenu from '../overlays/VideoContextMenu';
|
||||
import UserPreferences from '../../utils/UserPreferences';
|
||||
import PlayerConfig from '../../config/playerConfig';
|
||||
import { AutoplayHandler } from '../../utils/AutoplayHandler';
|
||||
@@ -169,7 +170,7 @@ const enableStandardButtonTooltips = (player) => {
|
||||
}, 500); // Delay to ensure all components are ready
|
||||
};
|
||||
|
||||
function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelated = true, showUserAvatar = true, linkTitle = true, urlTimestamp = null }) {
|
||||
const videoRef = useRef(null);
|
||||
const playerRef = useRef(null); // Track the player instance
|
||||
const userPreferences = useRef(new UserPreferences()); // User preferences instance
|
||||
@@ -177,25 +178,17 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
const keyboardHandler = useRef(null); // Keyboard handler instance
|
||||
const playbackEventHandler = useRef(null); // Playback event handler instance
|
||||
|
||||
// Context menu state
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Check if this is an embed player (disable next video and autoplay features)
|
||||
const isEmbedPlayer = videoId === 'video-embed';
|
||||
|
||||
// Utility function to detect touch devices
|
||||
const isTouchDevice = useMemo(() => {
|
||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
||||
}, []);
|
||||
|
||||
// Utility function to detect iOS devices
|
||||
const isIOS = useMemo(() => {
|
||||
return (
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Environment-based development mode configuration
|
||||
const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app');
|
||||
// Safely access window.MEDIA_DATA with fallback using useMemo
|
||||
|
||||
// Read options from window.MEDIA_DATA if available (for consistency with embed logic)
|
||||
const mediaData = useMemo(
|
||||
() =>
|
||||
typeof window !== 'undefined' && window.MEDIA_DATA
|
||||
@@ -214,12 +207,37 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
},
|
||||
siteUrl: 'https://deic.mediacms.io',
|
||||
nextLink: 'https://deic.mediacms.io/view?m=elygiagorgechania',
|
||||
urlAutoplay: true,
|
||||
urlMuted: false,
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Helper to get effective value (prop or MEDIA_DATA or default)
|
||||
const getOption = (propKey, mediaDataKey, defaultValue) => {
|
||||
if (isEmbedPlayer) {
|
||||
if (mediaData[mediaDataKey] !== undefined) return mediaData[mediaDataKey];
|
||||
}
|
||||
return propKey !== undefined ? propKey : defaultValue;
|
||||
};
|
||||
|
||||
const finalShowTitle = getOption(showTitle, 'showTitle', true);
|
||||
const finalShowRelated = getOption(showRelated, 'showRelated', true);
|
||||
const finalShowUserAvatar = getOption(showUserAvatar, 'showUserAvatar', true);
|
||||
const finalLinkTitle = getOption(linkTitle, 'linkTitle', true);
|
||||
const finalTimestamp = getOption(urlTimestamp, 'urlTimestamp', null);
|
||||
|
||||
// Utility function to detect touch devices
|
||||
const isTouchDevice = useMemo(() => {
|
||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
||||
}, []);
|
||||
|
||||
// Utility function to detect iOS devices
|
||||
const isIOS = useMemo(() => {
|
||||
return (
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Define chapters as JSON object
|
||||
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
|
||||
// CONDITIONAL LOGIC:
|
||||
@@ -531,8 +549,6 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
isPlayList: mediaData?.isPlayList,
|
||||
related_media: mediaData.data?.related_media || [],
|
||||
nextLink: mediaData?.nextLink || null,
|
||||
urlAutoplay: mediaData?.urlAutoplay || true,
|
||||
urlMuted: mediaData?.urlMuted || false,
|
||||
sources: getVideoSources(),
|
||||
};
|
||||
|
||||
@@ -738,6 +754,212 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Context menu handlers
|
||||
const handleContextMenu = useCallback((e) => {
|
||||
// Only handle if clicking on video player area
|
||||
const target = e.target;
|
||||
const isVideoPlayerArea =
|
||||
target.closest('.video-js') ||
|
||||
target.classList.contains('vjs-tech') ||
|
||||
target.tagName === 'VIDEO' ||
|
||||
target.closest('video');
|
||||
|
||||
if (isVideoPlayerArea) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
||||
setContextMenuVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenuVisible(false);
|
||||
};
|
||||
|
||||
// Helper function to get media ID
|
||||
const getMediaId = () => {
|
||||
if (typeof window !== 'undefined' && window.MEDIA_DATA?.data?.friendly_token) {
|
||||
return window.MEDIA_DATA.data.friendly_token;
|
||||
}
|
||||
if (mediaData?.data?.friendly_token) {
|
||||
return mediaData.data.friendly_token;
|
||||
}
|
||||
// Try to get from URL (works for both main page and embed page)
|
||||
if (typeof window !== 'undefined') {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const mediaIdFromUrl = urlParams.get('m');
|
||||
if (mediaIdFromUrl) {
|
||||
return mediaIdFromUrl;
|
||||
}
|
||||
// Also check if we're on an embed page with media ID in path
|
||||
const pathMatch = window.location.pathname.match(/\/embed\/([^/?]+)/);
|
||||
if (pathMatch) {
|
||||
return pathMatch[1];
|
||||
}
|
||||
}
|
||||
return currentVideo.id || 'default-video';
|
||||
};
|
||||
|
||||
// Helper function to get base origin URL (handles embed mode)
|
||||
const getBaseOrigin = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// In embed mode, try to get origin from parent window if possible
|
||||
// Otherwise use current window origin
|
||||
try {
|
||||
// Check if we're in an iframe and can access parent
|
||||
if (window.parent !== window && window.parent.location.origin) {
|
||||
return window.parent.location.origin;
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin iframe, use current origin
|
||||
}
|
||||
return window.location.origin;
|
||||
}
|
||||
return mediaData.siteUrl || 'https://deic.mediacms.io';
|
||||
};
|
||||
|
||||
// Helper function to get embed URL
|
||||
const getEmbedUrl = () => {
|
||||
const mediaId = getMediaId();
|
||||
const origin = getBaseOrigin();
|
||||
|
||||
// Try to get embed URL from config or construct it
|
||||
if (typeof window !== 'undefined' && window.MediaCMS?.config?.url?.embed) {
|
||||
return window.MediaCMS.config.url.embed + mediaId;
|
||||
}
|
||||
|
||||
// Fallback: construct embed URL (check if current URL is embed format)
|
||||
if (typeof window !== 'undefined' && window.location.pathname.includes('/embed')) {
|
||||
// If we're already on an embed page, use current URL format
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set('m', mediaId);
|
||||
return currentUrl.toString();
|
||||
}
|
||||
|
||||
// Default embed URL format
|
||||
return `${origin}/embed?m=${mediaId}`;
|
||||
};
|
||||
|
||||
// Copy video URL to clipboard
|
||||
const handleCopyVideoUrl = async () => {
|
||||
const mediaId = getMediaId();
|
||||
const origin = getBaseOrigin();
|
||||
const videoUrl = `${origin}/view?m=${mediaId}`;
|
||||
|
||||
// Show copy icon
|
||||
if (customComponents.current?.seekIndicator) {
|
||||
customComponents.current.seekIndicator.show('copy-url');
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(videoUrl);
|
||||
closeContextMenu();
|
||||
// You can add a notification here if needed
|
||||
} catch (err) {
|
||||
console.error('Failed to copy video URL:', err);
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = videoUrl;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
// Copy video URL at current time to clipboard
|
||||
const handleCopyVideoUrlAtTime = async () => {
|
||||
if (!playerRef.current) {
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Math.floor(playerRef.current.currentTime() || 0);
|
||||
const mediaId = getMediaId();
|
||||
const origin = getBaseOrigin();
|
||||
const videoUrl = `${origin}/view?m=${mediaId}&t=${currentTime}`;
|
||||
|
||||
// Show copy icon
|
||||
if (customComponents.current?.seekIndicator) {
|
||||
customComponents.current.seekIndicator.show('copy-url');
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(videoUrl);
|
||||
closeContextMenu();
|
||||
} catch (err) {
|
||||
console.error('Failed to copy video URL at time:', err);
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = videoUrl;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
// Copy embed code to clipboard
|
||||
const handleCopyEmbedCode = async () => {
|
||||
const embedUrl = getEmbedUrl();
|
||||
const embedCode = `<iframe width="560" height="315" src="${embedUrl}" frameborder="0" allowfullscreen></iframe>`;
|
||||
|
||||
// Show copy embed icon
|
||||
if (customComponents.current?.seekIndicator) {
|
||||
customComponents.current.seekIndicator.show('copy-embed');
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(embedCode);
|
||||
closeContextMenu();
|
||||
} catch (err) {
|
||||
console.error('Failed to copy embed code:', err);
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = embedCode;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
// Add context menu handler directly to video element and document (works before and after Video.js initialization)
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
|
||||
// Attach to document with capture to catch all contextmenu events, then filter
|
||||
const documentHandler = (e) => {
|
||||
// Check if the event originated from within the video player
|
||||
const target = e.target;
|
||||
const playerWrapper =
|
||||
videoElement?.closest('.video-js') || document.querySelector(`#${videoId}`)?.closest('.video-js');
|
||||
|
||||
if (playerWrapper && (playerWrapper.contains(target) || target === playerWrapper)) {
|
||||
handleContextMenu(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Use capture phase on document to catch before anything else
|
||||
document.addEventListener('contextmenu', documentHandler, true);
|
||||
|
||||
// Also attach directly to video element
|
||||
if (videoElement) {
|
||||
videoElement.addEventListener('contextmenu', handleContextMenu, true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', documentHandler, true);
|
||||
if (videoElement) {
|
||||
videoElement.removeEventListener('contextmenu', handleContextMenu, true);
|
||||
}
|
||||
};
|
||||
}, [handleContextMenu, videoId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only initialize if we don't already have a player and element exists
|
||||
if (videoRef.current && !playerRef.current) {
|
||||
@@ -1078,6 +1300,9 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
currentVideo,
|
||||
relatedVideos,
|
||||
goToNextVideo,
|
||||
showRelated: finalShowRelated,
|
||||
showUserAvatar: finalShowUserAvatar,
|
||||
linkTitle: finalLinkTitle,
|
||||
});
|
||||
customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup
|
||||
|
||||
@@ -1098,8 +1323,8 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
}
|
||||
|
||||
// Handle URL timestamp parameter
|
||||
if (mediaData.urlTimestamp !== null && mediaData.urlTimestamp >= 0) {
|
||||
const timestamp = mediaData.urlTimestamp;
|
||||
if (finalTimestamp !== null && finalTimestamp >= 0) {
|
||||
const timestamp = finalTimestamp;
|
||||
|
||||
// Wait for video metadata to be loaded before seeking
|
||||
if (playerRef.current.readyState() >= 1) {
|
||||
@@ -1997,6 +2222,10 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
authorThumbnail: currentVideo.author_thumbnail,
|
||||
videoTitle: currentVideo.title,
|
||||
videoUrl: currentVideo.url,
|
||||
showTitle: finalShowTitle,
|
||||
showRelated: finalShowRelated,
|
||||
showUserAvatar: finalShowUserAvatar,
|
||||
linkTitle: finalLinkTitle,
|
||||
});
|
||||
}
|
||||
// END: Add Embed Info Overlay Component
|
||||
@@ -2083,52 +2312,113 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
// Make the video element focusable
|
||||
const videoElement = playerRef.current.el();
|
||||
videoElement.setAttribute('tabindex', '0');
|
||||
videoElement.focus();
|
||||
|
||||
if (!isEmbedPlayer) {
|
||||
videoElement.focus();
|
||||
}
|
||||
|
||||
// Add context menu (right-click) handler to the player wrapper and video element
|
||||
// Attach to player wrapper (this catches all clicks on the player)
|
||||
videoElement.addEventListener('contextmenu', handleContextMenu, true);
|
||||
|
||||
// Also try to attach to the actual video tech element
|
||||
const attachContextMenu = () => {
|
||||
const techElement =
|
||||
playerRef.current.el().querySelector('.vjs-tech') ||
|
||||
playerRef.current.el().querySelector('video') ||
|
||||
(playerRef.current.tech() && playerRef.current.tech().el());
|
||||
|
||||
if (techElement && techElement !== videoRef.current && techElement !== videoElement) {
|
||||
// Use capture phase to catch before Video.js might prevent it
|
||||
techElement.addEventListener('contextmenu', handleContextMenu, true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Try to attach immediately
|
||||
attachContextMenu();
|
||||
|
||||
// Also try after a short delay in case elements aren't ready yet
|
||||
setTimeout(() => {
|
||||
attachContextMenu();
|
||||
}, 100);
|
||||
|
||||
// Also try when video is loaded
|
||||
playerRef.current.one('loadedmetadata', () => {
|
||||
attachContextMenu();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
//}, 0);
|
||||
}
|
||||
|
||||
// Cleanup: Remove context menu event listener
|
||||
return () => {
|
||||
if (playerRef.current && playerRef.current.el()) {
|
||||
const playerEl = playerRef.current.el();
|
||||
playerEl.removeEventListener('contextmenu', handleContextMenu, true);
|
||||
|
||||
const techElement =
|
||||
playerEl.querySelector('.vjs-tech') ||
|
||||
playerEl.querySelector('video') ||
|
||||
(playerRef.current.tech() && playerRef.current.tech().el());
|
||||
if (techElement) {
|
||||
techElement.removeEventListener('contextmenu', handleContextMenu, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
id={videoId}
|
||||
controls={true}
|
||||
className={`video-js vjs-fluid vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`}
|
||||
preload="auto"
|
||||
poster={currentVideo.poster}
|
||||
tabIndex="0"
|
||||
>
|
||||
{/* <source src="/videos/sample-video.mp4" type="video/mp4" />
|
||||
<source src="/videos/sample-video.webm" type="video/webm" /> */}
|
||||
<p className="vjs-no-js">
|
||||
To view this video please enable JavaScript, and consider upgrading to a web browser that
|
||||
<a href="https://videojs.com/html5-video-support/" target="_blank">
|
||||
supports HTML5 video
|
||||
</a>
|
||||
</p>
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
id={videoId}
|
||||
controls={true}
|
||||
className={`video-js ${isEmbedPlayer ? 'vjs-fill' : 'vjs-fluid'} vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`}
|
||||
preload="auto"
|
||||
poster={currentVideo.poster}
|
||||
tabIndex="0"
|
||||
>
|
||||
{/* <source src="/videos/sample-video.mp4" type="video/mp4" />
|
||||
<source src="/videos/sample-video.webm" type="video/webm" /> */}
|
||||
<p className="vjs-no-js">
|
||||
To view this video please enable JavaScript, and consider upgrading to a web browser that
|
||||
<a href="https://videojs.com/html5-video-support/" target="_blank">
|
||||
supports HTML5 video
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{/* Add subtitle tracks */}
|
||||
{/* {subtitleTracks &&
|
||||
subtitleTracks.map((track, index) => (
|
||||
<track
|
||||
key={index}
|
||||
kind={track.kind}
|
||||
src={track.src}
|
||||
srcLang={track.srclang}
|
||||
label={track.label}
|
||||
default={track.default}
|
||||
/>
|
||||
))} */}
|
||||
{/*
|
||||
<track kind="chapters" src="/sample-chapters.vtt" /> */}
|
||||
{/* Add chapters track */}
|
||||
{/* {chaptersData &&
|
||||
chaptersData.length > 0 &&
|
||||
(console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */}
|
||||
</video>
|
||||
{/* Add subtitle tracks */}
|
||||
{/* {subtitleTracks &&
|
||||
subtitleTracks.map((track, index) => (
|
||||
<track
|
||||
key={index}
|
||||
kind={track.kind}
|
||||
src={track.src}
|
||||
srcLang={track.srclang}
|
||||
label={track.label}
|
||||
default={track.default}
|
||||
/>
|
||||
))} */}
|
||||
{/*
|
||||
<track kind="chapters" src="/sample-chapters.vtt" /> */}
|
||||
{/* Add chapters track */}
|
||||
{/* {chaptersData &&
|
||||
chaptersData.length > 0 &&
|
||||
(console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */}
|
||||
</video>
|
||||
<VideoContextMenu
|
||||
visible={contextMenuVisible}
|
||||
position={contextMenuPosition}
|
||||
onClose={closeContextMenu}
|
||||
onCopyVideoUrl={handleCopyVideoUrl}
|
||||
onCopyVideoUrlAtTime={handleCopyVideoUrlAtTime}
|
||||
onCopyEmbedCode={handleCopyEmbedCode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,17 @@ export class EndScreenHandler {
|
||||
}
|
||||
|
||||
handleVideoEnded() {
|
||||
const { isEmbedPlayer, userPreferences, mediaData, currentVideo, relatedVideos, goToNextVideo } = this.options;
|
||||
const {
|
||||
isEmbedPlayer,
|
||||
userPreferences,
|
||||
mediaData,
|
||||
currentVideo,
|
||||
relatedVideos,
|
||||
goToNextVideo,
|
||||
showRelated,
|
||||
showUserAvatar,
|
||||
linkTitle,
|
||||
} = this.options;
|
||||
|
||||
// For embed players, show big play button when video ends
|
||||
if (isEmbedPlayer) {
|
||||
@@ -73,6 +83,34 @@ export class EndScreenHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// If showRelated is false, we don't show the end screen or autoplay countdown
|
||||
if (showRelated === false) {
|
||||
// But we still want to keep the control bar visible and hide the poster
|
||||
setTimeout(() => {
|
||||
if (this.player && !this.player.isDisposed()) {
|
||||
const playerEl = this.player.el();
|
||||
if (playerEl) {
|
||||
// Hide poster elements
|
||||
const posterElements = playerEl.querySelectorAll('.vjs-poster');
|
||||
posterElements.forEach((posterEl) => {
|
||||
posterEl.style.display = 'none';
|
||||
posterEl.style.visibility = 'hidden';
|
||||
posterEl.style.opacity = '0';
|
||||
});
|
||||
|
||||
// Keep control bar visible
|
||||
const controlBar = this.player.getChild('controlBar');
|
||||
if (controlBar) {
|
||||
controlBar.show();
|
||||
controlBar.el().style.opacity = '1';
|
||||
controlBar.el().style.pointerEvents = 'auto';
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep controls active after video ends
|
||||
setTimeout(() => {
|
||||
if (this.player && !this.player.isDisposed()) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './BulkActionCategoryModal.scss';
|
||||
import { translateString } from '../utils/helpers/';
|
||||
import { inEmbeddedApp } from '../utils/helpers/embeddedApp';
|
||||
|
||||
interface Category {
|
||||
title: string;
|
||||
@@ -24,6 +25,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
onError,
|
||||
csrfToken,
|
||||
}) => {
|
||||
const isLmsMode = inEmbeddedApp();
|
||||
const [existingCategories, setExistingCategories] = useState<Category[]>([]);
|
||||
const [allCategories, setAllCategories] = useState<Category[]>([]);
|
||||
const [categoriesToAdd, setCategoriesToAdd] = useState<Category[]>([]);
|
||||
@@ -66,20 +68,27 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
const existingData = await existingResponse.json();
|
||||
const existing = existingData.results || [];
|
||||
|
||||
// Fetch all categories
|
||||
const allResponse = await fetch('/api/v1/categories');
|
||||
// Fetch all categories (or LMS courses only in embed mode)
|
||||
const categoriesUrl = isLmsMode
|
||||
? '/api/v1/categories/contributor?lms_courses_only=true'
|
||||
: '/api/v1/categories';
|
||||
const allResponse = await fetch(categoriesUrl);
|
||||
if (!allResponse.ok) {
|
||||
throw new Error(translateString('Failed to fetch all categories'));
|
||||
throw new Error(isLmsMode ? translateString('Failed to fetch courses') : translateString('Failed to fetch all categories'));
|
||||
}
|
||||
|
||||
const allData = await allResponse.json();
|
||||
const all = allData.results || allData;
|
||||
|
||||
setExistingCategories(existing);
|
||||
// In LMS mode, filter existing to only show LMS course categories
|
||||
const allUids = new Set(all.map((c: Category) => c.uid));
|
||||
const filteredExisting = isLmsMode ? existing.filter((c: Category) => allUids.has(c.uid)) : existing;
|
||||
|
||||
setExistingCategories(filteredExisting);
|
||||
setAllCategories(all);
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
onError(translateString('Failed to load categories'));
|
||||
onError(isLmsMode ? translateString('Failed to load courses') : translateString('Failed to load categories'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -126,7 +135,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
});
|
||||
|
||||
if (!addResponse.ok) {
|
||||
throw new Error(translateString('Failed to add categories'));
|
||||
throw new Error(isLmsMode ? translateString('Failed to add courses') : translateString('Failed to add categories'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,15 +156,15 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
});
|
||||
|
||||
if (!removeResponse.ok) {
|
||||
throw new Error(translateString('Failed to remove categories'));
|
||||
throw new Error(isLmsMode ? translateString('Failed to remove courses') : translateString('Failed to remove categories'));
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess(translateString('Successfully updated categories'));
|
||||
onSuccess(isLmsMode ? translateString('Successfully updated courses') : translateString('Successfully updated categories'));
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
console.error('Error processing categories:', error);
|
||||
onError(translateString('Failed to update categories. Please try again.'));
|
||||
onError(isLmsMode ? translateString('Failed to update courses. Please try again.') : translateString('Failed to update categories. Please try again.'));
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -184,7 +193,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
<div className="category-modal-overlay">
|
||||
<div className="category-modal">
|
||||
<div className="category-modal-header">
|
||||
<h2>{translateString('Add / Remove from Categories')}</h2>
|
||||
<h2>{isLmsMode ? translateString('Share with Course') : translateString('Add / Remove from Categories')}</h2>
|
||||
<button className="category-modal-close" onClick={onCancel}>
|
||||
×
|
||||
</button>
|
||||
@@ -192,14 +201,14 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
|
||||
<div className="category-modal-content">
|
||||
<div className="category-panel">
|
||||
<h3>{translateString('Categories')}</h3>
|
||||
<h3>{isLmsMode ? translateString('Courses') : translateString('Categories')}</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="loading-message">{translateString('Loading categories...')}</div>
|
||||
<div className="loading-message">{isLmsMode ? translateString('Loading courses...') : translateString('Loading categories...')}</div>
|
||||
) : (
|
||||
<div className="category-list scrollable">
|
||||
{leftPanelCategories.length === 0 ? (
|
||||
<div className="empty-message">{translateString('All categories already added')}</div>
|
||||
<div className="empty-message">{isLmsMode ? translateString('All courses already added') : translateString('All categories already added')}</div>
|
||||
) : (
|
||||
leftPanelCategories.map((category) => (
|
||||
<div
|
||||
@@ -227,11 +236,11 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="loading-message">{translateString('Loading categories...')}</div>
|
||||
<div className="loading-message">{isLmsMode ? translateString('Loading courses...') : translateString('Loading categories...')}</div>
|
||||
) : (
|
||||
<div className="category-list scrollable">
|
||||
{rightPanelCategories.length === 0 ? (
|
||||
<div className="empty-message">{translateString('No categories')}</div>
|
||||
<div className="empty-message">{isLmsMode ? translateString('No courses') : translateString('No categories')}</div>
|
||||
) : (
|
||||
rightPanelCategories.map((category) => {
|
||||
const isExisting = existingCategories.some((c) => c.uid === category.uid);
|
||||
@@ -251,7 +260,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
removeCategoryFromAddList(category);
|
||||
}
|
||||
}}
|
||||
title={isMarkedForRemoval ? translateString('Undo removal') : isExisting ? translateString('Remove category') : translateString('Remove from list')}
|
||||
title={isMarkedForRemoval ? translateString('Undo removal') : isExisting ? (isLmsMode ? translateString('Remove course') : translateString('Remove category')) : translateString('Remove from list')}
|
||||
>
|
||||
{isMarkedForRemoval ? '↺' : '×'}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import './BulkActionsDropdown.scss';
|
||||
import { translateString } from '../utils/helpers/';
|
||||
import { inEmbeddedApp } from '../utils/helpers/embeddedApp';
|
||||
|
||||
interface BulkActionsDropdownProps {
|
||||
selectedCount: number;
|
||||
@@ -12,7 +13,7 @@ const BULK_ACTIONS = [
|
||||
{ value: 'add-remove-coeditors', label: translateString('Add / Remove Co-Editors'), enabled: true },
|
||||
{ value: 'add-remove-coowners', label: translateString('Add / Remove Co-Owners'), enabled: true },
|
||||
{ value: 'add-remove-playlist', label: translateString('Add to / Remove from Playlist'), enabled: true },
|
||||
{ value: 'add-remove-category', label: translateString('Add to / Remove from Category'), enabled: true },
|
||||
{ value: 'add-remove-category', label: inEmbeddedApp() ? translateString('Share with Course') : translateString('Add to / Remove from Category'), enabled: true },
|
||||
{ value: 'add-remove-tags', label: translateString('Add / Remove Tags'), enabled: true },
|
||||
{ value: 'enable-comments', label: translateString('Enable Comments'), enabled: true },
|
||||
{ value: 'disable-comments', label: translateString('Disable Comments'), enabled: true },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { translateString } from '../utils/helpers/';
|
||||
import { translateString, inSelectMediaEmbedMode } from '../utils/helpers/';
|
||||
|
||||
interface MediaListHeaderProps {
|
||||
title?: string;
|
||||
@@ -11,10 +11,12 @@ interface MediaListHeaderProps {
|
||||
|
||||
export const MediaListHeader: React.FC<MediaListHeaderProps> = (props) => {
|
||||
const viewAllText = props.viewAllText || translateString('VIEW ALL');
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
return (
|
||||
<div className={(props.className ? props.className + ' ' : '') + 'media-list-header'} style={props.style}>
|
||||
<h2>{props.title}</h2>
|
||||
{props.viewAllLink ? (
|
||||
{!isSelectMediaMode && props.viewAllLink ? (
|
||||
<h3>
|
||||
{' '}
|
||||
<a href={props.viewAllLink} title={viewAllText}>
|
||||
|
||||
@@ -31,8 +31,11 @@ const VideoJSEmbed = ({
|
||||
poster,
|
||||
previewSprite,
|
||||
subtitlesInfo,
|
||||
enableAutoplay,
|
||||
inEmbed,
|
||||
showTitle,
|
||||
showRelated,
|
||||
showUserAvatar,
|
||||
linkTitle,
|
||||
hasTheaterMode,
|
||||
hasNextLink,
|
||||
nextLink,
|
||||
@@ -62,8 +65,10 @@ const VideoJSEmbed = ({
|
||||
if (typeof window !== 'undefined') {
|
||||
// Get URL parameters for autoplay, muted, and timestamp
|
||||
const urlTimestamp = getUrlParameter('t');
|
||||
const urlAutoplay = getUrlParameter('autoplay');
|
||||
const urlMuted = getUrlParameter('muted');
|
||||
const urlShowRelated = getUrlParameter('showRelated');
|
||||
const urlShowUserAvatar = getUrlParameter('showUserAvatar');
|
||||
const urlLinkTitle = getUrlParameter('linkTitle');
|
||||
|
||||
window.MEDIA_DATA = {
|
||||
data: data || {},
|
||||
@@ -71,7 +76,7 @@ const VideoJSEmbed = ({
|
||||
version: version,
|
||||
isPlayList: isPlayList,
|
||||
playerVolume: playerVolume || 0.5,
|
||||
playerSoundMuted: playerSoundMuted || (urlMuted === '1'),
|
||||
playerSoundMuted: urlMuted === '1',
|
||||
videoQuality: videoQuality || 'auto',
|
||||
videoPlaybackSpeed: videoPlaybackSpeed || 1,
|
||||
inTheaterMode: inTheaterMode || false,
|
||||
@@ -83,8 +88,11 @@ const VideoJSEmbed = ({
|
||||
poster: poster || '',
|
||||
previewSprite: previewSprite || null,
|
||||
subtitlesInfo: subtitlesInfo || [],
|
||||
enableAutoplay: enableAutoplay || (urlAutoplay === '1'),
|
||||
inEmbed: inEmbed || false,
|
||||
showTitle: showTitle || false,
|
||||
showRelated: showRelated !== undefined ? showRelated : (urlShowRelated === '1' || urlShowRelated === 'true' || urlShowRelated === null),
|
||||
showUserAvatar: showUserAvatar !== undefined ? showUserAvatar : (urlShowUserAvatar === '1' || urlShowUserAvatar === 'true' || urlShowUserAvatar === null),
|
||||
linkTitle: linkTitle !== undefined ? linkTitle : (urlLinkTitle === '1' || urlLinkTitle === 'true' || urlLinkTitle === null),
|
||||
hasTheaterMode: hasTheaterMode || false,
|
||||
hasNextLink: hasNextLink || false,
|
||||
nextLink: nextLink || null,
|
||||
@@ -92,8 +100,10 @@ const VideoJSEmbed = ({
|
||||
errorMessage: errorMessage || '',
|
||||
// URL parameters
|
||||
urlTimestamp: urlTimestamp ? parseInt(urlTimestamp, 10) : null,
|
||||
urlAutoplay: urlAutoplay === '1',
|
||||
urlMuted: urlMuted === '1',
|
||||
urlShowRelated: urlShowRelated === '1' || urlShowRelated === 'true',
|
||||
urlShowUserAvatar: urlShowUserAvatar === '1' || urlShowUserAvatar === 'true',
|
||||
urlLinkTitle: urlLinkTitle === '1' || urlLinkTitle === 'true',
|
||||
onClickNextCallback: onClickNextCallback || null,
|
||||
onClickPreviousCallback: onClickPreviousCallback || null,
|
||||
onStateUpdateCallback: onStateUpdateCallback || null,
|
||||
@@ -176,11 +186,17 @@ const VideoJSEmbed = ({
|
||||
// Scroll to the video player with smooth behavior
|
||||
const videoElement = document.querySelector(inEmbedRef.current ? '#video-embed' : '#video-main');
|
||||
if (videoElement) {
|
||||
videoElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
const urlScroll = getUrlParameter('scroll');
|
||||
const isIframe = window.parent !== window;
|
||||
|
||||
// Only scroll if not in an iframe, OR if explicitly requested via scroll=1 parameter
|
||||
if (!isIframe || urlScroll === '1' || urlScroll === 'true') {
|
||||
videoElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('VideoJS player not found for timestamp navigation');
|
||||
@@ -220,7 +236,14 @@ const VideoJSEmbed = ({
|
||||
|
||||
return (
|
||||
<div className="video-js-wrapper" ref={containerRef}>
|
||||
{inEmbed ? <div id="video-js-root-embed" className="video-js-root-embed" /> : <div id="video-js-root-main" className="video-js-root-main" />}
|
||||
{inEmbed ? (
|
||||
<div
|
||||
id="video-js-root-embed"
|
||||
className="video-js-root-embed"
|
||||
/>
|
||||
) : (
|
||||
<div id="video-js-root-main" className="video-js-root-main" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,50 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useMediaItem } from '../../utils/hooks/';
|
||||
import { PositiveInteger, PositiveIntegerOrZero } from '../../utils/helpers/';
|
||||
import { PositiveInteger, PositiveIntegerOrZero, inSelectMediaEmbedMode } from '../../utils/helpers/';
|
||||
import { MediaItemThumbnailLink, itemClassname } from './includes/items/';
|
||||
import { Item } from './Item';
|
||||
|
||||
export function MediaItem(props) {
|
||||
const type = props.type;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
const [titleComponentOrig, descriptionComponent, thumbnailUrl, UnderThumbWrapperOrig, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
useMediaItem({ ...props, type });
|
||||
|
||||
// In embed mode, override components to remove links
|
||||
const ItemTitle = ({ title }) => (
|
||||
<h3>
|
||||
<span>{title}</span>
|
||||
</h3>
|
||||
);
|
||||
|
||||
const ItemMain = ({ children }) => <div className="item-main">{children}</div>;
|
||||
|
||||
const titleComponent = isSelectMediaMode
|
||||
? () => <ItemTitle title={props.title} />
|
||||
: titleComponentOrig;
|
||||
|
||||
const UnderThumbWrapper = isSelectMediaMode ? ItemMain : UnderThumbWrapperOrig;
|
||||
|
||||
function thumbnailComponent() {
|
||||
if (isSelectMediaMode) {
|
||||
// In embed mode, render thumbnail without link
|
||||
const thumbStyle = thumbnailUrl ? { backgroundImage: "url('" + thumbnailUrl + "')" } : null;
|
||||
return (
|
||||
<div
|
||||
key="item-thumb"
|
||||
className={'item-thumb' + (!thumbnailUrl ? ' no-thumb' : '')}
|
||||
style={thumbStyle}
|
||||
>
|
||||
{thumbnailUrl ? (
|
||||
<div key="item-type-icon" className="item-type-icon">
|
||||
<div></div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <MediaItemThumbnailLink src={thumbnailUrl} title={props.title} link={props.link} />;
|
||||
}
|
||||
|
||||
@@ -25,11 +57,13 @@ export function MediaItem(props) {
|
||||
const finalClassname = containerClassname +
|
||||
(props.showSelection ? ' with-selection' : '') +
|
||||
(props.isSelected ? ' selected' : '') +
|
||||
(props.hasAnySelection ? ' has-any-selection' : '');
|
||||
(props.hasAnySelection || isSelectMediaMode ? ' has-any-selection' : '');
|
||||
|
||||
const handleItemClick = (e) => {
|
||||
// If there's any selection active, clicking the item should toggle selection
|
||||
if (props.hasAnySelection && props.onCheckboxChange) {
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
// In select media mode or if there's any selection active, clicking the item should toggle selection
|
||||
if ((isSelectMediaMode || props.hasAnySelection) && props.onCheckboxChange) {
|
||||
// Check if clicking on the checkbox itself, edit icon, or view icon
|
||||
if (e.target.closest('.item-selection-checkbox') ||
|
||||
e.target.closest('.item-edit-icon') ||
|
||||
@@ -59,16 +93,24 @@ export function MediaItem(props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMediaComponent()}
|
||||
{viewMediaComponent()}
|
||||
{!isSelectMediaMode && editMediaComponent()}
|
||||
{!isSelectMediaMode && viewMediaComponent()}
|
||||
|
||||
{thumbnailComponent()}
|
||||
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
{isSelectMediaMode ? (
|
||||
<UnderThumbWrapper>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
) : (
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useMediaItem } from '../../utils/hooks/';
|
||||
import { PositiveIntegerOrZero } from '../../utils/helpers/';
|
||||
import { PositiveIntegerOrZero, inSelectMediaEmbedMode } from '../../utils/helpers/';
|
||||
import { MediaDurationInfo } from '../../utils/classes/';
|
||||
import { MediaPlaylistOptions } from '../media-playlist-options/MediaPlaylistOptions';
|
||||
import { MediaItemDuration, MediaItemPlaylistIndex, itemClassname } from './includes/items/';
|
||||
@@ -9,10 +9,26 @@ import { MediaItem } from './MediaItem';
|
||||
|
||||
export function MediaItemAudio(props) {
|
||||
const type = props.type;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
const [titleComponentOrig, descriptionComponent, thumbnailUrl, UnderThumbWrapperOrig, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
useMediaItem({ ...props, type });
|
||||
|
||||
// In embed mode, override components to remove links
|
||||
const ItemTitle = ({ title }) => (
|
||||
<h3>
|
||||
<span>{title}</span>
|
||||
</h3>
|
||||
);
|
||||
|
||||
const ItemMain = ({ children }) => <div className="item-main">{children}</div>;
|
||||
|
||||
const titleComponent = isSelectMediaMode
|
||||
? () => <ItemTitle title={props.title} />
|
||||
: titleComponentOrig;
|
||||
|
||||
const UnderThumbWrapper = isSelectMediaMode ? ItemMain : UnderThumbWrapperOrig;
|
||||
|
||||
const _MediaDurationInfo = new MediaDurationInfo();
|
||||
|
||||
_MediaDurationInfo.update(props.duration);
|
||||
@@ -22,6 +38,21 @@ export function MediaItemAudio(props) {
|
||||
const durationISO8601 = _MediaDurationInfo.ISO8601();
|
||||
|
||||
function thumbnailComponent() {
|
||||
if (isSelectMediaMode) {
|
||||
// In embed mode, render thumbnail without link
|
||||
return (
|
||||
<div
|
||||
key="item-thumb"
|
||||
className={'item-thumb' + (!thumbnailUrl ? ' no-thumb' : '')}
|
||||
style={!thumbnailUrl ? null : { backgroundImage: "url('" + thumbnailUrl + "')" }}
|
||||
>
|
||||
{props.inPlaylistView ? null : (
|
||||
<MediaItemDuration ariaLabel={duration} time={durationISO8601} text={durationStr} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const attr = {
|
||||
key: 'item-thumb',
|
||||
href: props.link,
|
||||
@@ -68,11 +99,11 @@ export function MediaItemAudio(props) {
|
||||
const finalClassname = containerClassname +
|
||||
(props.showSelection ? ' with-selection' : '') +
|
||||
(props.isSelected ? ' selected' : '') +
|
||||
(props.hasAnySelection ? ' has-any-selection' : '');
|
||||
(props.hasAnySelection || isSelectMediaMode ? ' has-any-selection' : '');
|
||||
|
||||
const handleItemClick = (e) => {
|
||||
// If there's any selection active, clicking the item should toggle selection
|
||||
if (props.hasAnySelection && props.onCheckboxChange) {
|
||||
// In embed mode or if there's any selection active, clicking the item should toggle selection
|
||||
if ((isSelectMediaMode || props.hasAnySelection) && props.onCheckboxChange) {
|
||||
// Check if clicking on the checkbox itself, edit icon, or view icon
|
||||
if (e.target.closest('.item-selection-checkbox') ||
|
||||
e.target.closest('.item-edit-icon') ||
|
||||
@@ -104,16 +135,24 @@ export function MediaItemAudio(props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMediaComponent()}
|
||||
{viewMediaComponent()}
|
||||
{!isSelectMediaMode && editMediaComponent()}
|
||||
{!isSelectMediaMode && viewMediaComponent()}
|
||||
|
||||
{thumbnailComponent()}
|
||||
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
{isSelectMediaMode ? (
|
||||
<UnderThumbWrapper>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
) : (
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
)}
|
||||
|
||||
{playlistOptionsComponent()}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useMediaItem } from '../../utils/hooks/';
|
||||
import { PositiveIntegerOrZero } from '../../utils/helpers/';
|
||||
import { PositiveIntegerOrZero, inSelectMediaEmbedMode } from '../../utils/helpers/';
|
||||
import { MediaDurationInfo } from '../../utils/classes/';
|
||||
import { MediaPlaylistOptions } from '../media-playlist-options/MediaPlaylistOptions.jsx';
|
||||
import { MediaItemVideoPlayer, MediaItemDuration, MediaItemVideoPreviewer, MediaItemPlaylistIndex, itemClassname } from './includes/items/';
|
||||
@@ -9,10 +9,26 @@ import { MediaItem } from './MediaItem';
|
||||
|
||||
export function MediaItemVideo(props) {
|
||||
const type = props.type;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
const [titleComponentOrig, descriptionComponent, thumbnailUrl, UnderThumbWrapperOrig, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
useMediaItem({ ...props, type });
|
||||
|
||||
// In embed mode, override components to remove links
|
||||
const ItemTitle = ({ title }) => (
|
||||
<h3>
|
||||
<span>{title}</span>
|
||||
</h3>
|
||||
);
|
||||
|
||||
const ItemMain = ({ children }) => <div className="item-main">{children}</div>;
|
||||
|
||||
const titleComponent = isSelectMediaMode
|
||||
? () => <ItemTitle title={props.title} />
|
||||
: titleComponentOrig;
|
||||
|
||||
const UnderThumbWrapper = isSelectMediaMode ? ItemMain : UnderThumbWrapperOrig;
|
||||
|
||||
const _MediaDurationInfo = new MediaDurationInfo();
|
||||
|
||||
_MediaDurationInfo.update(props.duration);
|
||||
@@ -26,6 +42,24 @@ export function MediaItemVideo(props) {
|
||||
}
|
||||
|
||||
function thumbnailComponent() {
|
||||
if (isSelectMediaMode) {
|
||||
// In select media mode, render thumbnail without link
|
||||
return (
|
||||
<div
|
||||
key="item-thumb"
|
||||
className={'item-thumb' + (!thumbnailUrl ? ' no-thumb' : '')}
|
||||
style={!thumbnailUrl ? null : { backgroundImage: "url('" + thumbnailUrl + "')" }}
|
||||
>
|
||||
{props.inPlaylistView ? null : (
|
||||
<MediaItemDuration ariaLabel={duration} time={durationISO8601} text={durationStr} />
|
||||
)}
|
||||
{props.inPlaylistView || props.inPlaylistPage ? null : (
|
||||
<MediaItemVideoPreviewer url={props.preview_thumbnail} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const attr = {
|
||||
key: 'item-thumb',
|
||||
href: props.link,
|
||||
@@ -75,11 +109,11 @@ export function MediaItemVideo(props) {
|
||||
const finalClassname = containerClassname +
|
||||
(props.showSelection ? ' with-selection' : '') +
|
||||
(props.isSelected ? ' selected' : '') +
|
||||
(props.hasAnySelection ? ' has-any-selection' : '');
|
||||
(props.hasAnySelection || isSelectMediaMode ? ' has-any-selection' : '');
|
||||
|
||||
const handleItemClick = (e) => {
|
||||
// If there's any selection active, clicking the item should toggle selection
|
||||
if (props.hasAnySelection && props.onCheckboxChange) {
|
||||
// In select media mode or if there's any selection active, clicking the item should toggle selection
|
||||
if ((isSelectMediaMode || props.hasAnySelection) && props.onCheckboxChange) {
|
||||
// Check if clicking on the checkbox itself, edit icon, or view icon
|
||||
if (e.target.closest('.item-selection-checkbox') ||
|
||||
e.target.closest('.item-edit-icon') ||
|
||||
@@ -111,19 +145,27 @@ export function MediaItemVideo(props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMediaComponent()}
|
||||
{viewMediaComponent()}
|
||||
{!isSelectMediaMode && editMediaComponent()}
|
||||
{!isSelectMediaMode && viewMediaComponent()}
|
||||
|
||||
{props.hasMediaViewer ? videoViewerComponent() : thumbnailComponent()}
|
||||
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
</div>
|
||||
{isSelectMediaMode ? (
|
||||
<UnderThumbWrapper>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
) : (
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
)}
|
||||
|
||||
{playlistOptionsComponent()}
|
||||
{playlistOptionsComponent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,32 @@ import { LinksContext, SiteConsumer } from '../../utils/contexts/';
|
||||
import { PageStore, MediaPageStore } from '../../utils/stores/';
|
||||
import { PageActions, MediaPageActions } from '../../utils/actions/';
|
||||
import { CircleIconButton, MaterialIcon, NumericInputWithUnit } from '../_shared/';
|
||||
import VideoViewer from '../media-viewer/VideoViewer';
|
||||
|
||||
const EMBED_OPTIONS_STORAGE_KEY = 'mediacms_embed_options';
|
||||
|
||||
function loadEmbedOptions() {
|
||||
try {
|
||||
const saved = localStorage.getItem(EMBED_OPTIONS_STORAGE_KEY);
|
||||
if (saved) {
|
||||
return JSON.parse(saved);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveEmbedOptions(options) {
|
||||
try {
|
||||
localStorage.setItem(EMBED_OPTIONS_STORAGE_KEY, JSON.stringify(options));
|
||||
} catch (e) {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function MediaShareEmbed(props) {
|
||||
const embedVideoDimensions = PageStore.get('config-options').embedded.video.dimensions;
|
||||
const savedOptions = loadEmbedOptions();
|
||||
|
||||
const links = useContext(LinksContext);
|
||||
|
||||
@@ -18,12 +40,19 @@ export function MediaShareEmbed(props) {
|
||||
const onRightBottomRef = useRef(null);
|
||||
|
||||
const [maxHeight, setMaxHeight] = useState(window.innerHeight - 144 + 56);
|
||||
const [keepAspectRatio, setKeepAspectRatio] = useState(false);
|
||||
const [aspectRatio, setAspectRatio] = useState('16:9');
|
||||
const [embedWidthValue, setEmbedWidthValue] = useState(embedVideoDimensions.width);
|
||||
const [embedWidthUnit, setEmbedWidthUnit] = useState(embedVideoDimensions.widthUnit);
|
||||
const [embedHeightValue, setEmbedHeightValue] = useState(embedVideoDimensions.height);
|
||||
const [embedHeightUnit, setEmbedHeightUnit] = useState(embedVideoDimensions.heightUnit);
|
||||
const [keepAspectRatio, setKeepAspectRatio] = useState(savedOptions?.keepAspectRatio ?? true);
|
||||
const [showTitle, setShowTitle] = useState(savedOptions?.showTitle ?? true);
|
||||
const [showRelated, setShowRelated] = useState(savedOptions?.showRelated ?? true);
|
||||
const [showUserAvatar, setShowUserAvatar] = useState(savedOptions?.showUserAvatar ?? true);
|
||||
const [linkTitle, setLinkTitle] = useState(savedOptions?.linkTitle ?? true);
|
||||
const [responsive, setResponsive] = useState(savedOptions?.responsive ?? false);
|
||||
const [startAt, setStartAt] = useState(false);
|
||||
const [startTime, setStartTime] = useState('0:00');
|
||||
const [aspectRatio, setAspectRatio] = useState(savedOptions?.aspectRatio ?? '16:9');
|
||||
const [embedWidthValue, setEmbedWidthValue] = useState(savedOptions?.embedWidthValue ?? embedVideoDimensions.width);
|
||||
const [embedWidthUnit, setEmbedWidthUnit] = useState(savedOptions?.embedWidthUnit ?? embedVideoDimensions.widthUnit);
|
||||
const [embedHeightValue, setEmbedHeightValue] = useState(savedOptions?.embedHeightValue ?? embedVideoDimensions.height);
|
||||
const [embedHeightUnit, setEmbedHeightUnit] = useState(savedOptions?.embedHeightUnit ?? embedVideoDimensions.heightUnit);
|
||||
const [rightMiddlePositionTop, setRightMiddlePositionTop] = useState(60);
|
||||
const [rightMiddlePositionBottom, setRightMiddlePositionBottom] = useState(60);
|
||||
const [unitOptions, setUnitOptions] = useState([
|
||||
@@ -71,36 +100,65 @@ export function MediaShareEmbed(props) {
|
||||
setEmbedHeightUnit(newVal);
|
||||
}
|
||||
|
||||
function onKeepAspectRatioChange() {
|
||||
const newVal = !keepAspectRatio;
|
||||
function onShowTitleChange() {
|
||||
setShowTitle(!showTitle);
|
||||
}
|
||||
|
||||
const arr = aspectRatio.split(':');
|
||||
const x = arr[0];
|
||||
const y = arr[1];
|
||||
function onShowRelatedChange() {
|
||||
setShowRelated(!showRelated);
|
||||
}
|
||||
|
||||
setKeepAspectRatio(newVal);
|
||||
setEmbedWidthUnit(newVal ? 'px' : embedWidthUnit);
|
||||
setEmbedHeightUnit(newVal ? 'px' : embedHeightUnit);
|
||||
setEmbedHeightValue(newVal ? parseInt((embedWidthValue * y) / x, 10) : embedHeightValue);
|
||||
setUnitOptions(
|
||||
newVal
|
||||
? [{ key: 'px', label: 'px' }]
|
||||
: [
|
||||
{ key: 'px', label: 'px' },
|
||||
{ key: 'percent', label: '%' },
|
||||
]
|
||||
);
|
||||
function onShowUserAvatarChange() {
|
||||
setShowUserAvatar(!showUserAvatar);
|
||||
}
|
||||
|
||||
function onLinkTitleChange() {
|
||||
setLinkTitle(!linkTitle);
|
||||
}
|
||||
|
||||
function onResponsiveChange() {
|
||||
const nextResponsive = !responsive;
|
||||
setResponsive(nextResponsive);
|
||||
|
||||
if (!nextResponsive) {
|
||||
if (aspectRatio !== 'custom') {
|
||||
const arr = aspectRatio.split(':');
|
||||
const x = arr[0];
|
||||
const y = arr[1];
|
||||
|
||||
setKeepAspectRatio(true);
|
||||
setEmbedHeightValue(parseInt((embedWidthValue * y) / x, 10));
|
||||
} else {
|
||||
setKeepAspectRatio(false);
|
||||
}
|
||||
} else {
|
||||
setKeepAspectRatio(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onStartAtChange() {
|
||||
setStartAt(!startAt);
|
||||
}
|
||||
|
||||
function onStartTimeChange(e) {
|
||||
setStartTime(e.target.value);
|
||||
}
|
||||
|
||||
function onAspectRatioChange() {
|
||||
const newVal = aspectRatioValueRef.current.value;
|
||||
|
||||
const arr = newVal.split(':');
|
||||
const x = arr[0];
|
||||
const y = arr[1];
|
||||
if (newVal === 'custom') {
|
||||
setAspectRatio(newVal);
|
||||
setKeepAspectRatio(false);
|
||||
} else {
|
||||
const arr = newVal.split(':');
|
||||
const x = arr[0];
|
||||
const y = arr[1];
|
||||
|
||||
setAspectRatio(newVal);
|
||||
setEmbedHeightValue(keepAspectRatio ? parseInt((embedWidthValue * y) / x, 10) : embedHeightValue);
|
||||
setAspectRatio(newVal);
|
||||
setKeepAspectRatio(true);
|
||||
setEmbedHeightValue(parseInt((embedWidthValue * y) / x, 10));
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
@@ -130,13 +188,88 @@ export function MediaShareEmbed(props) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Save embed options to localStorage when they change (except startAt/startTime)
|
||||
useEffect(() => {
|
||||
saveEmbedOptions({
|
||||
showTitle,
|
||||
showRelated,
|
||||
showUserAvatar,
|
||||
linkTitle,
|
||||
responsive,
|
||||
aspectRatio,
|
||||
embedWidthValue,
|
||||
embedWidthUnit,
|
||||
embedHeightValue,
|
||||
embedHeightUnit,
|
||||
keepAspectRatio,
|
||||
});
|
||||
}, [showTitle, showRelated, showUserAvatar, linkTitle, responsive, aspectRatio, embedWidthValue, embedWidthUnit, embedHeightValue, embedHeightUnit, keepAspectRatio]);
|
||||
|
||||
function getEmbedCode() {
|
||||
const mediaId = MediaPageStore.get('media-id');
|
||||
const params = new URLSearchParams();
|
||||
if (showTitle) params.set('showTitle', '1');
|
||||
else params.set('showTitle', '0');
|
||||
|
||||
if (showRelated) params.set('showRelated', '1');
|
||||
else params.set('showRelated', '0');
|
||||
|
||||
if (showUserAvatar) params.set('showUserAvatar', '1');
|
||||
else params.set('showUserAvatar', '0');
|
||||
|
||||
if (linkTitle) params.set('linkTitle', '1');
|
||||
else params.set('linkTitle', '0');
|
||||
|
||||
if (startAt && startTime) {
|
||||
const parts = startTime.split(':').reverse();
|
||||
let seconds = 0;
|
||||
if (parts[0]) seconds += parseInt(parts[0], 10) || 0;
|
||||
if (parts[1]) seconds += (parseInt(parts[1], 10) || 0) * 60;
|
||||
if (parts[2]) seconds += (parseInt(parts[2], 10) || 0) * 3600;
|
||||
if (seconds > 0) params.set('t', seconds);
|
||||
}
|
||||
|
||||
const separator = links.embed.includes('?') ? '&' : '?';
|
||||
const finalUrl = `${links.embed}${mediaId}${separator}${params.toString()}`;
|
||||
|
||||
if (responsive) {
|
||||
if (aspectRatio === 'custom') {
|
||||
// Use current width/height values to calculate aspect ratio for custom
|
||||
const ratio = `${embedWidthValue} / ${embedHeightValue}`;
|
||||
const maxWidth = `calc(100vh * ${embedWidthValue} / ${embedHeightValue})`;
|
||||
return `<iframe src="${finalUrl}" style="width:100%;max-width:${maxWidth};aspect-ratio:${ratio};display:block;margin:auto;border:0;" allowFullScreen></iframe>`;
|
||||
}
|
||||
const arr = aspectRatio.split(':');
|
||||
const ratio = `${arr[0]} / ${arr[1]}`;
|
||||
const maxWidth = `calc(100vh * ${arr[0]} / ${arr[1]})`;
|
||||
return `<iframe src="${finalUrl}" style="width:100%;max-width:${maxWidth};aspect-ratio:${ratio};display:block;margin:auto;border:0;" allowFullScreen></iframe>`;
|
||||
}
|
||||
|
||||
const width = 'percent' === embedWidthUnit ? embedWidthValue + '%' : embedWidthValue;
|
||||
const height = 'percent' === embedHeightUnit ? embedHeightValue + '%' : embedHeightValue;
|
||||
return `<iframe width="${width}" height="${height}" src="${finalUrl}" frameBorder="0" allowFullScreen></iframe>`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="share-embed" style={{ maxHeight: maxHeight + 'px' }}>
|
||||
<div className="share-embed-inner">
|
||||
<div className="on-left">
|
||||
<div className="media-embed-wrap">
|
||||
<SiteConsumer>
|
||||
{(site) => <VideoViewer data={MediaPageStore.get('media-data')} siteUrl={site.url} inEmbed={true} />}
|
||||
{(site) => {
|
||||
const previewUrl = `${links.embed + MediaPageStore.get('media-id')}&showTitle=${showTitle ? '1' : '0'}&showRelated=${showRelated ? '1' : '0'}&showUserAvatar=${showUserAvatar ? '1' : '0'}&linkTitle=${linkTitle ? '1' : '0'}${startAt ? '&t=' + (startTime.split(':').reverse().reduce((acc, cur, i) => acc + (parseInt(cur, 10) || 0) * Math.pow(60, i), 0)) : ''}`;
|
||||
|
||||
const style = {};
|
||||
style.width = '100%';
|
||||
style.height = '480px';
|
||||
style.overflow = 'hidden';
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<iframe width="100%" height="100%" src={previewUrl} frameBorder="0" allowFullScreen></iframe>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SiteConsumer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,16 +291,7 @@ export function MediaShareEmbed(props) {
|
||||
>
|
||||
<textarea
|
||||
readOnly
|
||||
value={
|
||||
'<iframe width="' +
|
||||
('percent' === embedWidthUnit ? embedWidthValue + '%' : embedWidthValue) +
|
||||
'" height="' +
|
||||
('percent' === embedHeightUnit ? embedHeightValue + '%' : embedHeightValue) +
|
||||
'" src="' +
|
||||
links.embed +
|
||||
MediaPageStore.get('media-id') +
|
||||
'" frameborder="0" allowfullscreen></iframe>'
|
||||
}
|
||||
value={getEmbedCode()}
|
||||
></textarea>
|
||||
|
||||
<div className="iframe-config">
|
||||
@@ -179,59 +303,106 @@ export function MediaShareEmbed(props) {
|
||||
</div>*/}
|
||||
|
||||
<div className="option-content">
|
||||
<div className="ratio-options">
|
||||
<div className="ratio-options" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 10px' }}>
|
||||
<div className="options-group">
|
||||
<label style={{ minHeight: '36px' }}>
|
||||
<input type="checkbox" checked={keepAspectRatio} onChange={onKeepAspectRatioChange} />
|
||||
Keep aspect ratio
|
||||
<label style={{ minHeight: '36px', whiteSpace: 'nowrap' }}>
|
||||
<input type="checkbox" checked={showTitle} onChange={onShowTitleChange} />
|
||||
Show title
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!keepAspectRatio ? null : (
|
||||
<div className="options-group">
|
||||
<select ref={aspectRatioValueRef} onChange={onAspectRatioChange} value={aspectRatio}>
|
||||
<optgroup label="Horizontal orientation">
|
||||
<option value="16:9">16:9</option>
|
||||
<option value="4:3">4:3</option>
|
||||
<option value="3:2">3:2</option>
|
||||
</optgroup>
|
||||
<optgroup label="Vertical orientation">
|
||||
<option value="9:16">9:16</option>
|
||||
<option value="3:4">3:4</option>
|
||||
<option value="2:3">2:3</option>
|
||||
</optgroup>
|
||||
<div className="options-group">
|
||||
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', opacity: showTitle ? 1 : 0.5 }}>
|
||||
<input type="checkbox" checked={linkTitle} onChange={onLinkTitleChange} disabled={!showTitle} />
|
||||
Link title
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="options-group">
|
||||
<label style={{ minHeight: '36px', whiteSpace: 'nowrap' }}>
|
||||
<input type="checkbox" checked={showRelated} onChange={onShowRelatedChange} />
|
||||
Show related
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="options-group">
|
||||
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', opacity: showTitle ? 1 : 0.5 }}>
|
||||
<input type="checkbox" checked={showUserAvatar} onChange={onShowUserAvatarChange} disabled={!showTitle} />
|
||||
Show user avatar
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="options-group" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', marginRight: '10px' }}>
|
||||
<input type="checkbox" checked={responsive} onChange={onResponsiveChange} />
|
||||
Responsive
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="options-group" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', marginRight: '10px' }}>
|
||||
<input type="checkbox" checked={startAt} onChange={onStartAtChange} />
|
||||
Start at
|
||||
</label>
|
||||
{startAt && (
|
||||
<input
|
||||
type="text"
|
||||
value={startTime}
|
||||
onChange={onStartTimeChange}
|
||||
style={{ width: '60px', height: '28px', fontSize: '12px', padding: '2px 5px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="options-group" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<div style={{ fontSize: '12px', marginBottom: '4px', color: 'rgba(0,0,0,0.6)' }}>Aspect Ratio</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<select
|
||||
ref={aspectRatioValueRef}
|
||||
onChange={onAspectRatioChange}
|
||||
value={aspectRatio}
|
||||
style={{ height: '28px', fontSize: '12px' }}
|
||||
>
|
||||
<option value="16:9">16:9</option>
|
||||
<option value="4:3">4:3</option>
|
||||
<option value="3:2">3:2</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div className="options-group">
|
||||
<NumericInputWithUnit
|
||||
valueCallback={onEmbedWidthValueChange}
|
||||
unitCallback={onEmbedWidthUnitChange}
|
||||
label={'Width'}
|
||||
defaultValue={parseInt(embedWidthValue, 10)}
|
||||
defaultUnit={embedWidthUnit}
|
||||
minValue={1}
|
||||
maxValue={99999}
|
||||
units={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
{!responsive && (
|
||||
<>
|
||||
<div className="options-group">
|
||||
<NumericInputWithUnit
|
||||
valueCallback={onEmbedWidthValueChange}
|
||||
unitCallback={onEmbedWidthUnitChange}
|
||||
label={'Width'}
|
||||
defaultValue={parseInt(embedWidthValue, 10)}
|
||||
defaultUnit={embedWidthUnit}
|
||||
minValue={1}
|
||||
maxValue={99999}
|
||||
units={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="options-group">
|
||||
<NumericInputWithUnit
|
||||
valueCallback={onEmbedHeightValueChange}
|
||||
unitCallback={onEmbedHeightUnitChange}
|
||||
label={'Height'}
|
||||
defaultValue={parseInt(embedHeightValue, 10)}
|
||||
defaultUnit={embedHeightUnit}
|
||||
minValue={1}
|
||||
maxValue={99999}
|
||||
units={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="options-group">
|
||||
<NumericInputWithUnit
|
||||
valueCallback={onEmbedHeightValueChange}
|
||||
unitCallback={onEmbedHeightUnitChange}
|
||||
label={'Height'}
|
||||
defaultValue={parseInt(embedHeightValue, 10)}
|
||||
defaultUnit={embedHeightUnit}
|
||||
minValue={1}
|
||||
maxValue={99999}
|
||||
units={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1930,9 +1930,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.media-embed-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
.media-embed-wrap {
|
||||
display: block;
|
||||
|
||||
.player-container,
|
||||
.player-container-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-top: 0;
|
||||
background: #000;
|
||||
}
|
||||
.player-container,
|
||||
.player-container-inner {
|
||||
width: 100%;
|
||||
@@ -1946,6 +1958,10 @@
|
||||
.circle-icon-button {
|
||||
}
|
||||
|
||||
.video-js.vjs-mediacms {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
.video-js.vjs-mediacms {
|
||||
padding-top: math.div(9, 16) * 100%;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ function EditMediaButton(props) {
|
||||
link = '/edit-media.html';
|
||||
}
|
||||
|
||||
if (link && inEmbeddedApp()) {
|
||||
link += '&mode=lms_embed_mode';
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media-icon">
|
||||
<i className="material-icons">edit</i>
|
||||
@@ -94,11 +98,18 @@ export default function ViewerInfoContent(props) {
|
||||
!PageStore.get('config-enabled').taxonomies.tags || PageStore.get('config-enabled').taxonomies.tags.enabled
|
||||
? metafield(MediaPageStore.get('media-tags'))
|
||||
: [];
|
||||
let mediaCategories = MediaPageStore.get('media-categories');
|
||||
|
||||
// Filter to show only LMS courses when in embed mode
|
||||
if (inEmbeddedApp()) {
|
||||
mediaCategories = mediaCategories.filter(cat => cat.is_lms_course === true);
|
||||
}
|
||||
|
||||
const categoriesContent = PageStore.get('config-options').pages.media.categoriesWithTitle
|
||||
? []
|
||||
: !PageStore.get('config-enabled').taxonomies.categories ||
|
||||
PageStore.get('config-enabled').taxonomies.categories.enabled
|
||||
? metafield(MediaPageStore.get('media-categories'))
|
||||
? metafield(mediaCategories)
|
||||
: [];
|
||||
|
||||
let summary = MediaPageStore.get('media-summary');
|
||||
@@ -220,9 +231,13 @@ export default function ViewerInfoContent(props) {
|
||||
<MediaMetaField
|
||||
value={categoriesContent}
|
||||
title={
|
||||
1 < categoriesContent.length
|
||||
? translateString('Categories')
|
||||
: translateString('Category')
|
||||
inEmbeddedApp()
|
||||
? (1 < categoriesContent.length
|
||||
? translateString('Courses')
|
||||
: translateString('Course'))
|
||||
: (1 < categoriesContent.length
|
||||
? translateString('Categories')
|
||||
: translateString('Category'))
|
||||
}
|
||||
id="categories"
|
||||
/>
|
||||
@@ -274,7 +289,7 @@ export default function ViewerInfoContent(props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!inEmbeddedApp() && <CommentsList />}
|
||||
<CommentsList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { VideoViewerActions } from '../../../utils/actions/';
|
||||
import { SiteContext, SiteConsumer } from '../../../utils/contexts/';
|
||||
import { PageStore, MediaPageStore, VideoViewerStore } from '../../../utils/stores/';
|
||||
import { addClassname, removeClassname, formatInnerLink } from '../../../utils/helpers/';
|
||||
import { addClassname, removeClassname, formatInnerLink, inEmbeddedApp } from '../../../utils/helpers/';
|
||||
import { BrowserCache, UpNextLoaderView, MediaDurationInfo } from '../../../utils/classes/';
|
||||
import {
|
||||
orderedSupportedVideoFormats,
|
||||
@@ -176,11 +176,13 @@ export default class VideoViewer extends React.PureComponent {
|
||||
topLeftHtml = document.createElement('div');
|
||||
topLeftHtml.setAttribute('class', 'media-links-top-left');
|
||||
|
||||
const linkTarget = inEmbeddedApp() || window.location.href.indexOf('lms_embed_mode') > -1 ? '_self' : '_blank';
|
||||
|
||||
if (titleLink) {
|
||||
titleLink.setAttribute('class', 'title-link');
|
||||
titleLink.setAttribute('href', this.props.data.url);
|
||||
titleLink.setAttribute('title', this.props.data.title);
|
||||
titleLink.setAttribute('target', '_blank');
|
||||
titleLink.setAttribute('target', linkTarget);
|
||||
titleLink.innerHTML = this.props.data.title;
|
||||
}
|
||||
|
||||
@@ -191,7 +193,7 @@ export default class VideoViewer extends React.PureComponent {
|
||||
formatInnerLink(this.props.data.author_profile, this.props.siteUrl)
|
||||
);
|
||||
userThumbLink.setAttribute('title', this.props.data.author_name);
|
||||
userThumbLink.setAttribute('target', '_blank');
|
||||
userThumbLink.setAttribute('target', linkTarget);
|
||||
userThumbLink.setAttribute(
|
||||
'style',
|
||||
'background-image:url(' +
|
||||
@@ -410,8 +412,12 @@ export default class VideoViewer extends React.PureComponent {
|
||||
poster: this.videoPoster,
|
||||
previewSprite: previewSprite,
|
||||
subtitlesInfo: this.props.data.subtitles_info,
|
||||
enableAutoplay: !this.props.inEmbed,
|
||||
inEmbed: this.props.inEmbed,
|
||||
showTitle: this.props.showTitle,
|
||||
showRelated: this.props.showRelated,
|
||||
showUserAvatar: this.props.showUserAvatar,
|
||||
linkTitle: this.props.linkTitle,
|
||||
urlTimestamp: this.props.timestamp,
|
||||
hasTheaterMode: !this.props.inEmbed,
|
||||
hasNextLink: !!nextLink,
|
||||
nextLink: nextLink,
|
||||
@@ -435,9 +441,19 @@ export default class VideoViewer extends React.PureComponent {
|
||||
|
||||
VideoViewer.defaultProps = {
|
||||
inEmbed: !0,
|
||||
showTitle: !0,
|
||||
showRelated: !0,
|
||||
showUserAvatar: !0,
|
||||
linkTitle: !0,
|
||||
timestamp: null,
|
||||
siteUrl: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
VideoViewer.propTypes = {
|
||||
inEmbed: PropTypes.bool,
|
||||
showTitle: PropTypes.bool,
|
||||
showRelated: PropTypes.bool,
|
||||
showUserAvatar: PropTypes.bool,
|
||||
linkTitle: PropTypes.bool,
|
||||
timestamp: PropTypes.number,
|
||||
};
|
||||
@@ -656,30 +656,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.fixed-nav {
|
||||
.profile-info-nav-wrap {
|
||||
padding-bottom: $_authorPage-navHeight;
|
||||
}
|
||||
|
||||
.profile-nav {
|
||||
z-index: 3;
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.visible-sidebar & {
|
||||
padding-left: var(--sidebar-width);
|
||||
}
|
||||
|
||||
.sliding-sidebar & {
|
||||
transition-property: padding-left;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-main {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LinksContext, MemberContext, SiteContext } from '../../utils/contexts/'
|
||||
import { PageStore, ProfilePageStore } from '../../utils/stores/';
|
||||
import { PageActions, ProfilePageActions } from '../../utils/actions/';
|
||||
import { CircleIconButton, PopupMain } from '../_shared';
|
||||
import { translateString } from '../../utils/helpers/';
|
||||
import { translateString, inEmbeddedApp, inSelectMediaEmbedMode, isSelectMediaMode } from '../../utils/helpers/';
|
||||
|
||||
class ProfileSearchBar extends React.PureComponent {
|
||||
constructor(props) {
|
||||
@@ -372,30 +372,47 @@ class NavMenuInlineTabs extends React.PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
// Append action=select_media to links when in select mode
|
||||
const mediaLink = isSelectMediaMode
|
||||
? `${LinksContext._currentValue.profile.media}${LinksContext._currentValue.profile.media.includes('?') ? '&' : '?'}action=select_media`
|
||||
: LinksContext._currentValue.profile.media;
|
||||
|
||||
const sharedByMeLink = isSelectMediaMode
|
||||
? `${LinksContext._currentValue.profile.shared_by_me}${LinksContext._currentValue.profile.shared_by_me.includes('?') ? '&' : '?'}action=select_media`
|
||||
: LinksContext._currentValue.profile.shared_by_me;
|
||||
|
||||
const sharedWithMeLink = isSelectMediaMode
|
||||
? `${LinksContext._currentValue.profile.shared_with_me}${LinksContext._currentValue.profile.shared_with_me.includes('?') ? '&' : '?'}action=select_media`
|
||||
: LinksContext._currentValue.profile.shared_with_me;
|
||||
|
||||
return (
|
||||
<nav ref="tabsNav" className="profile-nav items-list-outer list-inline list-slider">
|
||||
<div className="profile-nav-inner items-list-outer">
|
||||
{this.state.displayPrev ? this.previousBtn : null}
|
||||
|
||||
<ul className="items-list-wrap" ref="itemsListWrap">
|
||||
<InlineTab
|
||||
id="about"
|
||||
isActive={'about' === this.props.type}
|
||||
label={translateString('About')}
|
||||
link={LinksContext._currentValue.profile.about}
|
||||
/>
|
||||
{!isSelectMediaMode && !inEmbeddedApp() ? (
|
||||
<InlineTab
|
||||
id="about"
|
||||
isActive={'about' === this.props.type}
|
||||
label={translateString('About')}
|
||||
link={LinksContext._currentValue.profile.about}
|
||||
/>
|
||||
) : null}
|
||||
<InlineTab
|
||||
id="media"
|
||||
isActive={'media' === this.props.type}
|
||||
label={translateString(this.userIsAuthor ? 'Media I own' : 'Media')}
|
||||
link={LinksContext._currentValue.profile.media}
|
||||
link={mediaLink}
|
||||
/>
|
||||
{this.userIsAuthor ? (
|
||||
<InlineTab
|
||||
id="shared_by_me"
|
||||
isActive={'shared_by_me' === this.props.type}
|
||||
label={translateString('Shared by me')}
|
||||
link={LinksContext._currentValue.profile.shared_by_me}
|
||||
link={sharedByMeLink}
|
||||
/>
|
||||
) : null}
|
||||
{this.userIsAuthor ? (
|
||||
@@ -403,11 +420,11 @@ class NavMenuInlineTabs extends React.PureComponent {
|
||||
id="shared_with_me"
|
||||
isActive={'shared_with_me' === this.props.type}
|
||||
label={translateString('Shared with me')}
|
||||
link={LinksContext._currentValue.profile.shared_with_me}
|
||||
link={sharedWithMeLink}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{MemberContext._currentValue.can.saveMedia ? (
|
||||
{!isSelectMediaMode && MemberContext._currentValue.can.saveMedia ? (
|
||||
<InlineTab
|
||||
id="playlists"
|
||||
isActive={'playlists' === this.props.type}
|
||||
@@ -606,12 +623,6 @@ export default function ProfilePagesHeader(props) {
|
||||
const profilePageHeaderRef = useRef(null);
|
||||
const profileNavRef = useRef(null);
|
||||
|
||||
const [fixedNav, setFixedNav] = useState(false);
|
||||
|
||||
const positions = {
|
||||
profileNavTop: 0,
|
||||
};
|
||||
|
||||
const userIsAdmin = !MemberContext._currentValue.is.anonymous && MemberContext._currentValue.is.admin;
|
||||
const userIsAuthor =
|
||||
!MemberContext._currentValue.is.anonymous &&
|
||||
@@ -623,18 +634,6 @@ export default function ProfilePagesHeader(props) {
|
||||
userIsAuthor ||
|
||||
(!MemberContext._currentValue.is.anonymous && MemberContext._currentValue.can.deleteProfile);
|
||||
|
||||
function updateProfileNavTopPosition() {
|
||||
positions.profileHeaderTop = profilePageHeaderRef.current.offsetTop;
|
||||
positions.profileNavTop =
|
||||
positions.profileHeaderTop +
|
||||
profilePageHeaderRef.current.offsetHeight -
|
||||
profileNavRef.current.refs.tabsNav.offsetHeight;
|
||||
}
|
||||
|
||||
function updateFixedNavPosition() {
|
||||
setFixedNav(positions.profileHeaderTop + window.scrollY > positions.profileNavTop);
|
||||
}
|
||||
|
||||
function cancelProfileRemoval() {
|
||||
popupContentRef.current.toggle();
|
||||
}
|
||||
@@ -669,42 +668,26 @@ export default function ProfilePagesHeader(props) {
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
updateProfileNavTopPosition();
|
||||
updateFixedNavPosition();
|
||||
}
|
||||
|
||||
function onWindowScroll() {
|
||||
updateFixedNavPosition();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (userCanDeleteProfile) {
|
||||
ProfilePageStore.on('profile_delete', onProfileDelete);
|
||||
ProfilePageStore.on('profile_delete_fail', onProfileDeleteFail);
|
||||
}
|
||||
|
||||
PageStore.on('resize', onWindowResize);
|
||||
PageStore.on('changed_page_sidebar_visibility', onWindowResize);
|
||||
PageStore.on('window_scroll', onWindowScroll);
|
||||
|
||||
updateProfileNavTopPosition();
|
||||
updateFixedNavPosition();
|
||||
|
||||
return () => {
|
||||
if (userCanDeleteProfile) {
|
||||
ProfilePageStore.removeListener('profile_delete', onProfileDelete);
|
||||
ProfilePageStore.removeListener('profile_delete_fail', onProfileDeleteFail);
|
||||
}
|
||||
|
||||
PageStore.removeListener('resize', onWindowResize);
|
||||
PageStore.removeListener('changed_page_sidebar_visibility', onWindowResize);
|
||||
PageStore.removeListener('window_scroll', onWindowScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={profilePageHeaderRef} className={'profile-page-header' + (fixedNav ? ' fixed-nav' : '')}>
|
||||
<div
|
||||
ref={profilePageHeaderRef}
|
||||
className={'profile-page-header'}
|
||||
{...(isSelectMediaMode() ? { 'data-action': 'select_media' } : {})}
|
||||
>
|
||||
{!props.hideChannelBanner && (
|
||||
<span className="profile-banner-wrap">
|
||||
{props.author.banner_thumbnail_url ? (
|
||||
@@ -768,7 +751,7 @@ export default function ProfilePagesHeader(props) {
|
||||
)}
|
||||
|
||||
<div className="profile-info-nav-wrap">
|
||||
{props.author.thumbnail_url || props.author.name ? (
|
||||
{!inEmbeddedApp() && (props.author.thumbnail_url || props.author.name) ? (
|
||||
<div className="profile-info">
|
||||
<div className="profile-info-inner">
|
||||
<div>
|
||||
|
||||
@@ -3,14 +3,14 @@ import { ApiUrlConsumer } from '../utils/contexts/';
|
||||
import { MediaListWrapper } from '../components/MediaListWrapper';
|
||||
import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListAsync.jsx';
|
||||
import { Page } from './Page';
|
||||
import { translateString } from '../utils/helpers/';
|
||||
import { translateString, inEmbeddedApp } from '../utils/helpers/';
|
||||
|
||||
interface CategoriesPageProps {
|
||||
id?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const CategoriesPage: React.FC<CategoriesPageProps> = ({ id = 'categories', title = translateString('Categories') }) => (
|
||||
export const CategoriesPage: React.FC<CategoriesPageProps> = ({ id = 'categories', title = inEmbeddedApp() ? translateString('Courses') : translateString('Categories') }) => (
|
||||
<Page id={id}>
|
||||
<ApiUrlConsumer>
|
||||
{(apiUrl) => (
|
||||
|
||||
@@ -41,7 +41,7 @@ export const EmbedPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="embed-wrap" style={wrapperStyles}>
|
||||
<div className="embed-wrap media-embed-wrap" style={wrapperStyles}>
|
||||
{failedMediaLoad && (
|
||||
<div className="player-container player-container-error" style={containerStyles}>
|
||||
<div className="player-container-inner" style={containerStyles}>
|
||||
@@ -59,9 +59,32 @@ export const EmbedPage: React.FC = () => {
|
||||
|
||||
{loadedVideo && (
|
||||
<SiteConsumer>
|
||||
{(site) => (
|
||||
<VideoViewer data={MediaPageStore.get('media-data')} siteUrl={site.url} containerStyles={containerStyles} />
|
||||
)}
|
||||
{(site) => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlShowTitle = urlParams.get('showTitle');
|
||||
const showTitle = urlShowTitle !== '0';
|
||||
const urlShowRelated = urlParams.get('showRelated');
|
||||
const showRelated = urlShowRelated !== '0';
|
||||
const urlShowUserAvatar = urlParams.get('showUserAvatar');
|
||||
const showUserAvatar = urlShowUserAvatar !== '0';
|
||||
const urlLinkTitle = urlParams.get('linkTitle');
|
||||
const linkTitle = urlLinkTitle !== '0';
|
||||
const urlTimestamp = urlParams.get('t');
|
||||
const timestamp = urlTimestamp ? parseInt(urlTimestamp, 10) : null;
|
||||
|
||||
return (
|
||||
<VideoViewer
|
||||
data={MediaPageStore.get('media-data')}
|
||||
siteUrl={site.url}
|
||||
containerStyles={containerStyles}
|
||||
showTitle={showTitle}
|
||||
showRelated={showRelated}
|
||||
showUserAvatar={showUserAvatar}
|
||||
linkTitle={linkTitle}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</SiteConsumer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { ApiUrlContext, LinksConsumer, MemberContext } from '../utils/contexts';
|
||||
import { PageStore, ProfilePageStore } from '../utils/stores';
|
||||
import { ProfilePageActions, PageActions } from '../utils/actions';
|
||||
import { inEmbeddedApp, translateString } from '../utils/helpers/';
|
||||
import { inEmbeddedApp, inSelectMediaEmbedMode, translateString } from '../utils/helpers/';
|
||||
import { MediaListWrapper } from '../components/MediaListWrapper';
|
||||
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
|
||||
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
|
||||
@@ -202,13 +202,45 @@ export class ProfileMediaPage extends Page {
|
||||
}
|
||||
|
||||
handleMediaSelection(mediaId, isSelected) {
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
this.setState((prevState) => {
|
||||
const newSelectedMedia = new Set(prevState.selectedMedia);
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
const newSelectedMedia = new Set();
|
||||
|
||||
// In select media mode, only allow single selection
|
||||
if (isSelectMediaMode) {
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
console.log('Selected media item:', mediaId);
|
||||
|
||||
// Send postMessage to parent window (Moodle TinyMCE plugin)
|
||||
if (window.parent !== window) {
|
||||
// Construct the embed URL
|
||||
const baseUrl = window.location.origin;
|
||||
const embedUrl = `${baseUrl}/embed?m=${mediaId}`;
|
||||
|
||||
// Send message in the format expected by the Moodle plugin
|
||||
window.parent.postMessage({
|
||||
type: 'videoSelected',
|
||||
embedUrl: embedUrl,
|
||||
videoId: mediaId
|
||||
}, '*');
|
||||
|
||||
console.log('Sent postMessage to parent:', { embedUrl, videoId: mediaId });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newSelectedMedia.delete(mediaId);
|
||||
// Normal mode: allow multiple selection
|
||||
newSelectedMedia.clear();
|
||||
prevState.selectedMedia.forEach((id) => newSelectedMedia.add(id));
|
||||
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
} else {
|
||||
newSelectedMedia.delete(mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
return { selectedMedia: newSelectedMedia };
|
||||
});
|
||||
}
|
||||
@@ -917,6 +949,7 @@ export class ProfileMediaPage extends Page {
|
||||
const authorData = ProfilePageStore.get('author-data');
|
||||
|
||||
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
// Check if any filters are active (excluding default sort and tags)
|
||||
const hasActiveFilters =
|
||||
@@ -948,15 +981,16 @@ export class ProfileMediaPage extends Page {
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
<MediaListWrapper
|
||||
title={this.state.title}
|
||||
title={inEmbeddedApp() ? undefined : this.state.title}
|
||||
className="items-list-ver"
|
||||
showBulkActions={isMediaAuthor}
|
||||
style={inEmbeddedApp() ? { marginTop: '24px' } : undefined}
|
||||
showBulkActions={!isSelectMediaMode && isMediaAuthor}
|
||||
selectedCount={this.state.selectedMedia.size}
|
||||
totalCount={this.state.availableMediaIds.length}
|
||||
onBulkAction={this.handleBulkAction}
|
||||
onSelectAll={this.handleSelectAll}
|
||||
onDeselectAll={this.handleDeselectAll}
|
||||
showAddMediaButton={isMediaAuthor}
|
||||
showAddMediaButton={!isSelectMediaMode && isMediaAuthor}
|
||||
>
|
||||
<ProfileMediaFilters
|
||||
hidden={this.state.hiddenFilters}
|
||||
@@ -979,7 +1013,7 @@ export class ProfileMediaPage extends Page {
|
||||
hideViews={!PageStore.get('config-media-item').displayViews}
|
||||
hideDate={!PageStore.get('config-media-item').displayPublishDate}
|
||||
canEdit={isMediaAuthor}
|
||||
showSelection={isMediaAuthor}
|
||||
showSelection={isMediaAuthor || isSelectMediaMode}
|
||||
hasAnySelection={this.state.selectedMedia.size > 0}
|
||||
selectedMedia={this.state.selectedMedia}
|
||||
onMediaSelection={this.handleMediaSelection}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFi
|
||||
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
|
||||
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
|
||||
import { BulkActionsModals } from '../components/BulkActionsModals';
|
||||
import { inEmbeddedApp, translateString } from '../utils/helpers';
|
||||
import { inEmbeddedApp, inSelectMediaEmbedMode } from '../utils/helpers';
|
||||
import { withBulkActions } from '../utils/hoc/withBulkActions';
|
||||
|
||||
import { Page } from './_Page';
|
||||
@@ -51,6 +51,7 @@ class ProfileSharedByMePage extends Page {
|
||||
availableTags: [],
|
||||
selectedTag: 'all',
|
||||
selectedSort: 'date_added_desc',
|
||||
selectedMedia: new Set(), // For select media mode
|
||||
};
|
||||
|
||||
this.authorDataLoad = this.authorDataLoad.bind(this);
|
||||
@@ -64,6 +65,7 @@ class ProfileSharedByMePage extends Page {
|
||||
this.onTagSelect = this.onTagSelect.bind(this);
|
||||
this.onSortSelect = this.onSortSelect.bind(this);
|
||||
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
|
||||
this.handleMediaSelection = this.handleMediaSelection.bind(this);
|
||||
|
||||
ProfilePageStore.on('load-author-data', this.authorDataLoad);
|
||||
}
|
||||
@@ -343,10 +345,55 @@ class ProfileSharedByMePage extends Page {
|
||||
}
|
||||
}
|
||||
|
||||
handleMediaSelection(mediaId, isSelected) {
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
this.setState((prevState) => {
|
||||
const newSelectedMedia = new Set();
|
||||
|
||||
// In select media mode, only allow single selection
|
||||
if (isSelectMediaMode) {
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
console.log('Selected media item:', mediaId);
|
||||
|
||||
// Send postMessage to parent window (Moodle TinyMCE plugin)
|
||||
if (window.parent !== window) {
|
||||
// Construct the embed URL
|
||||
const baseUrl = window.location.origin;
|
||||
const embedUrl = `${baseUrl}/embed?m=${mediaId}`;
|
||||
|
||||
// Send message in the format expected by the Moodle plugin
|
||||
window.parent.postMessage({
|
||||
type: 'videoSelected',
|
||||
embedUrl: embedUrl,
|
||||
videoId: mediaId
|
||||
}, '*');
|
||||
|
||||
console.log('Sent postMessage to parent:', { embedUrl, videoId: mediaId });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal mode: should not reach here as bulk actions handle this
|
||||
newSelectedMedia.clear();
|
||||
prevState.selectedMedia.forEach((id) => newSelectedMedia.add(id));
|
||||
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
} else {
|
||||
newSelectedMedia.delete(mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
return { selectedMedia: newSelectedMedia };
|
||||
});
|
||||
}
|
||||
|
||||
pageContent() {
|
||||
const authorData = ProfilePageStore.get('author-data');
|
||||
|
||||
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters =
|
||||
@@ -375,11 +422,12 @@ class ProfileSharedByMePage extends Page {
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
<MediaListWrapper
|
||||
title={this.state.title}
|
||||
title={inEmbeddedApp() ? undefined : this.state.title}
|
||||
className="items-list-ver"
|
||||
showBulkActions={isMediaAuthor}
|
||||
selectedCount={this.props.bulkActions.selectedMedia.size}
|
||||
totalCount={this.props.bulkActions.availableMediaIds.length}
|
||||
style={inEmbeddedApp() ? { marginTop: '24px' } : undefined}
|
||||
showBulkActions={!isSelectMediaMode && isMediaAuthor}
|
||||
selectedCount={isSelectMediaMode ? this.state.selectedMedia.size : this.props.bulkActions.selectedMedia.size}
|
||||
totalCount={isSelectMediaMode ? 0 : this.props.bulkActions.availableMediaIds.length}
|
||||
onBulkAction={this.props.bulkActions.handleBulkAction}
|
||||
onSelectAll={this.props.bulkActions.handleSelectAll}
|
||||
onDeselectAll={this.props.bulkActions.handleDeselectAll}
|
||||
@@ -396,19 +444,19 @@ class ProfileSharedByMePage extends Page {
|
||||
/>
|
||||
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
|
||||
<LazyLoadItemListAsync
|
||||
key={`${this.state.requestUrl}-${this.props.bulkActions.listKey}`}
|
||||
key={isSelectMediaMode ? this.state.requestUrl : `${this.state.requestUrl}-${this.props.bulkActions.listKey}`}
|
||||
requestUrl={this.state.requestUrl}
|
||||
hideAuthor={true}
|
||||
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
|
||||
hideViews={!PageStore.get('config-media-item').displayViews}
|
||||
hideDate={!PageStore.get('config-media-item').displayPublishDate}
|
||||
canEdit={isMediaAuthor}
|
||||
canEdit={!isSelectMediaMode && isMediaAuthor}
|
||||
onResponseDataLoaded={this.onResponseDataLoaded}
|
||||
showSelection={isMediaAuthor}
|
||||
hasAnySelection={this.props.bulkActions.selectedMedia.size > 0}
|
||||
selectedMedia={this.props.bulkActions.selectedMedia}
|
||||
onMediaSelection={this.props.bulkActions.handleMediaSelection}
|
||||
onItemsUpdate={this.props.bulkActions.handleItemsUpdate}
|
||||
showSelection={isMediaAuthor || isSelectMediaMode}
|
||||
hasAnySelection={isSelectMediaMode ? this.state.selectedMedia.size > 0 : this.props.bulkActions.selectedMedia.size > 0}
|
||||
selectedMedia={isSelectMediaMode ? this.state.selectedMedia : this.props.bulkActions.selectedMedia}
|
||||
onMediaSelection={isSelectMediaMode ? this.handleMediaSelection : this.props.bulkActions.handleMediaSelection}
|
||||
onItemsUpdate={!isSelectMediaMode ? this.props.bulkActions.handleItemsUpdate : undefined}
|
||||
/>
|
||||
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
|
||||
<EmptySharedByMe name={this.state.author.name} />
|
||||
@@ -416,7 +464,7 @@ class ProfileSharedByMePage extends Page {
|
||||
</MediaListWrapper>
|
||||
</ProfilePagesContent>
|
||||
) : null,
|
||||
this.state.author && isMediaAuthor ? (
|
||||
this.state.author && isMediaAuthor && !isSelectMediaMode ? (
|
||||
<BulkActionsModals
|
||||
key="BulkActionsModals"
|
||||
{...this.props.bulkActions}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListA
|
||||
import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters';
|
||||
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
|
||||
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
|
||||
import { inEmbeddedApp, translateString } from '../utils/helpers';
|
||||
import { inEmbeddedApp, inSelectMediaEmbedMode } from '../utils/helpers';
|
||||
|
||||
import { Page } from './_Page';
|
||||
|
||||
@@ -49,6 +49,7 @@ export class ProfileSharedWithMePage extends Page {
|
||||
availableTags: [],
|
||||
selectedTag: 'all',
|
||||
selectedSort: 'date_added_desc',
|
||||
selectedMedia: new Set(), // For select media mode
|
||||
};
|
||||
|
||||
this.authorDataLoad = this.authorDataLoad.bind(this);
|
||||
@@ -62,6 +63,7 @@ export class ProfileSharedWithMePage extends Page {
|
||||
this.onTagSelect = this.onTagSelect.bind(this);
|
||||
this.onSortSelect = this.onSortSelect.bind(this);
|
||||
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
|
||||
this.handleMediaSelection = this.handleMediaSelection.bind(this);
|
||||
|
||||
ProfilePageStore.on('load-author-data', this.authorDataLoad);
|
||||
}
|
||||
@@ -341,10 +343,55 @@ export class ProfileSharedWithMePage extends Page {
|
||||
}
|
||||
}
|
||||
|
||||
handleMediaSelection(mediaId, isSelected) {
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
this.setState((prevState) => {
|
||||
const newSelectedMedia = new Set();
|
||||
|
||||
// In select media mode, only allow single selection
|
||||
if (isSelectMediaMode) {
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
console.log('Selected media item:', mediaId);
|
||||
|
||||
// Send postMessage to parent window (Moodle TinyMCE plugin)
|
||||
if (window.parent !== window) {
|
||||
// Construct the embed URL
|
||||
const baseUrl = window.location.origin;
|
||||
const embedUrl = `${baseUrl}/embed?m=${mediaId}`;
|
||||
|
||||
// Send message in the format expected by the Moodle plugin
|
||||
window.parent.postMessage({
|
||||
type: 'videoSelected',
|
||||
embedUrl: embedUrl,
|
||||
videoId: mediaId
|
||||
}, '*');
|
||||
|
||||
console.log('Sent postMessage to parent:', { embedUrl, videoId: mediaId });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal mode: no selection UI in this page normally
|
||||
newSelectedMedia.clear();
|
||||
prevState.selectedMedia.forEach((id) => newSelectedMedia.add(id));
|
||||
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
} else {
|
||||
newSelectedMedia.delete(mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
return { selectedMedia: newSelectedMedia };
|
||||
});
|
||||
}
|
||||
|
||||
pageContent() {
|
||||
const authorData = ProfilePageStore.get('author-data');
|
||||
|
||||
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters =
|
||||
@@ -372,7 +419,11 @@ export class ProfileSharedWithMePage extends Page {
|
||||
) : null,
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
<MediaListWrapper title={this.state.title} className="items-list-ver">
|
||||
<MediaListWrapper
|
||||
title={inEmbeddedApp() ? undefined : this.state.title}
|
||||
className="items-list-ver"
|
||||
style={inEmbeddedApp() ? { marginTop: '24px' } : undefined}
|
||||
>
|
||||
<ProfileMediaFilters
|
||||
hidden={this.state.hiddenFilters}
|
||||
tags={this.state.availableTags}
|
||||
@@ -393,6 +444,10 @@ export class ProfileSharedWithMePage extends Page {
|
||||
hideDate={!PageStore.get('config-media-item').displayPublishDate}
|
||||
canEdit={false}
|
||||
onResponseDataLoaded={this.onResponseDataLoaded}
|
||||
showSelection={isSelectMediaMode}
|
||||
hasAnySelection={this.state.selectedMedia.size > 0}
|
||||
selectedMedia={this.state.selectedMedia}
|
||||
onMediaSelection={this.handleMediaSelection}
|
||||
/>
|
||||
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
|
||||
<EmptySharedWithMe name={this.state.author.name} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListA
|
||||
import { SearchMediaFiltersRow } from '../components/search-filters/SearchMediaFiltersRow';
|
||||
import { SearchResultsFilters } from '../components/search-filters/SearchResultsFilters';
|
||||
import { Page } from './_Page';
|
||||
import { translateString } from '../utils/helpers/';
|
||||
import { translateString, inEmbeddedApp } from '../utils/helpers/';
|
||||
|
||||
export class SearchPage extends Page {
|
||||
constructor(props) {
|
||||
@@ -115,7 +115,7 @@ export class SearchPage extends Page {
|
||||
} else {
|
||||
if (this.state.searchCategories) {
|
||||
title = null === this.state.resultsCount || 0 === this.state.resultsCount ? 'No' : this.state.resultsCount;
|
||||
title += ' ' + translateString('media in category') + ' "' + this.state.searchCategories + '"';
|
||||
title += ' ' + translateString(inEmbeddedApp() ? 'media in course' : 'media in category') + ' "' + this.state.searchCategories + '"';
|
||||
} else if (this.state.searchTags) {
|
||||
title = null === this.state.resultsCount || 0 === this.state.resultsCount ? 'No' : this.state.resultsCount;
|
||||
title += ' ' + translateString('media in tag') + ' "' + this.state.searchTags + '"';
|
||||
|
||||
@@ -3,18 +3,49 @@ export function inEmbeddedApp() {
|
||||
const params = new URL(globalThis.location.href).searchParams;
|
||||
const mode = params.get('mode');
|
||||
|
||||
if (mode === 'embed_mode') {
|
||||
sessionStorage.setItem('media_cms_embed_mode', 'true');
|
||||
if (mode === 'lms_embed_mode') {
|
||||
sessionStorage.setItem('lms_embed_mode', 'true');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (mode === 'standard') {
|
||||
sessionStorage.removeItem('media_cms_embed_mode');
|
||||
sessionStorage.removeItem('lms_embed_mode');
|
||||
return false;
|
||||
}
|
||||
|
||||
return sessionStorage.getItem('media_cms_embed_mode') === 'true';
|
||||
return sessionStorage.getItem('lms_embed_mode') === 'true';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSelectMediaMode() {
|
||||
try {
|
||||
const params = new URL(globalThis.location.href).searchParams;
|
||||
const action = params.get('action');
|
||||
|
||||
return action === 'select_media';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function inSelectMediaEmbedMode() {
|
||||
return inEmbeddedApp() && isSelectMediaMode();
|
||||
}
|
||||
|
||||
export function getLtiContextId(): string | null {
|
||||
try {
|
||||
const params = new URL(globalThis.location.href).searchParams;
|
||||
const contextId = params.get('lti_context_id');
|
||||
|
||||
if (contextId) {
|
||||
sessionStorage.setItem('lti_context_id', contextId);
|
||||
return contextId;
|
||||
}
|
||||
|
||||
return sessionStorage.getItem('lti_context_id');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,4 @@ export * from './quickSort';
|
||||
export * from './requests';
|
||||
export { translateString } from './translate';
|
||||
export { replaceString } from './replacementStrings';
|
||||
export * from './embeddedApp';
|
||||
export { inEmbeddedApp, inSelectMediaEmbedMode, isSelectMediaMode } from './embeddedApp';
|
||||
|
||||
@@ -195,13 +195,18 @@ class MediaPageStore extends EventEmitter {
|
||||
this.emit('loaded_media_data');
|
||||
}
|
||||
|
||||
this.loadPlaylists();
|
||||
if (MediaCMS.features.media.actions.comment_mention === true) {
|
||||
this.loadUsers();
|
||||
}
|
||||
// Skip loading playlists and comments when in embed mode (to reduce API calls)
|
||||
const isEmbedMode = window.location.pathname.startsWith('/embed');
|
||||
|
||||
if (this.mediacms_config.member.can.readComment) {
|
||||
this.loadComments();
|
||||
if (!isEmbedMode) {
|
||||
this.loadPlaylists();
|
||||
if (MediaCMS.features.media.actions.comment_mention === true) {
|
||||
this.loadUsers();
|
||||
}
|
||||
|
||||
if (this.mediacms_config.member.can.readComment) {
|
||||
this.loadComments();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1745
frontend/yarn.lock
1745
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
302
install-rhel.sh
302
install-rhel.sh
@@ -1,302 +0,0 @@
|
||||
#!/bin/bash
|
||||
# should be run as root on a rhel8-like system
|
||||
|
||||
function update_permissions
|
||||
{
|
||||
# fix permissions of /srv/mediacms directory
|
||||
chown -R nginx:root $1
|
||||
}
|
||||
|
||||
echo "Welcome to the MediacMS installation!";
|
||||
|
||||
if [ `id -u` -ne 0 ]; then
|
||||
echo "Please run as root user"
|
||||
exit
|
||||
fi
|
||||
|
||||
|
||||
while true; do
|
||||
read -p "
|
||||
This script will attempt to perform a system update, install required dependencies, and configure PostgreSQL, NGINX, Redis and a few other utilities.
|
||||
It is expected to run on a new system **with no running instances of any these services**. Make sure you check the script before you continue. Then enter y or n
|
||||
" yn
|
||||
case $yn in
|
||||
[Yy]* ) echo "OK!"; break;;
|
||||
[Nn]* ) echo "Have a great day"; exit;;
|
||||
* ) echo "Please answer y or n.";;
|
||||
esac
|
||||
done
|
||||
|
||||
# update configuration files
|
||||
|
||||
sed -i 's/\/home\/mediacms\.io\/mediacms\/Bento4-SDK-1-6-0-637\.x86_64-unknown-linux\/bin\/mp4hls/\/srv\/mediacms\/bento4\/bin\/mp4hls/g' cms/settings.py
|
||||
sed -i 's/www-data/nginx/g;s/\/home\/mediacms\.io\/mediacms\/logs/\/var\/log\/mediacms/g;s/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g;s/\/home\/mediacms\.io\/bin/\/srv\/mediacms\/virtualenv\/bin/g' deploy/local_install/celery_*.service
|
||||
sed -i 's/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g' deploy/local_install/mediacms.io
|
||||
sed -i 's/\/home\/mediacms\.io\/bin/\/srv\/mediacms\/virtualenv\/bin/g;s/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g' deploy/local_install/mediacms.service
|
||||
sed -i 's/\/home\/mediacms\.io\/mediacms/\/var\/log\/mediacms/g' deploy/local_install/mediacms_logrorate
|
||||
sed -i 's/www-data/nginx/g' deploy/local_install/nginx.conf
|
||||
sed -i 's/www-data/nginx/g;s/\/home\/mediacms\.io\/mediacms\/logs/\/var\/log\/mediacms/g;s/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g;s/\/home\/mediacms\.io/\/srv\/mediacms\/virtualenv/g' deploy/local_install/uwsgi.ini
|
||||
|
||||
osVersion=
|
||||
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
osVersion=$(grep ^ID /etc/os-release)
|
||||
fi
|
||||
|
||||
if [[ $osVersion == *"fedora"* ]] || [[ $osVersion == *"rhel"* ]] || [[ $osVersion == *"centos"* ]] || [[ *"rocky"* ]]; then
|
||||
dnf install -y epel-release https://mirrors.rpmfusion.org/free/el/rpmfusion-free-release-8.noarch.rpm yum-utils
|
||||
yum-config-manager --enable powertools
|
||||
dnf install -y python3-virtualenv python39-devel redis postgresql postgresql-server nginx git gcc vim unzip ImageMagick python3-certbot-nginx certbot wget xz ffmpeg policycoreutils-devel cmake gcc gcc-c++ wget git bsdtar
|
||||
else
|
||||
echo "unsupported or unknown os"
|
||||
exit -1
|
||||
fi
|
||||
|
||||
# fix permissions of /srv/mediacms directory
|
||||
update_permissions /srv/mediacms/
|
||||
|
||||
read -p "Enter portal URL, or press enter for localhost : " FRONTEND_HOST
|
||||
read -p "Enter portal name, or press enter for 'MediaCMS : " PORTAL_NAME
|
||||
|
||||
[ -z "$PORTAL_NAME" ] && PORTAL_NAME='MediaCMS'
|
||||
[ -z "$FRONTEND_HOST" ] && FRONTEND_HOST='localhost'
|
||||
|
||||
echo "Configuring postgres"
|
||||
if [ ! command -v postgresql-setup > /dev/null 2>&1 ]; then
|
||||
echo "Something went wrong, the command 'postgresql-setup' was not found in the system path."
|
||||
exit -1
|
||||
fi
|
||||
|
||||
postgresql-setup --initdb
|
||||
|
||||
# set authentication method for mediacms user to scram-sha-256
|
||||
sed -i 's/.*password_encryption.*/password_encryption = scram-sha-256/' /var/lib/pgsql/data/postgresql.conf
|
||||
sed -i '/# IPv4 local connections:/a host\tmediacms\tmediacms\t127.0.0.1/32\tscram-sha-256' /var/lib/pgsql/data/pg_hba.conf
|
||||
|
||||
systemctl enable postgresql.service --now
|
||||
|
||||
su -c "psql -c \"CREATE DATABASE mediacms\"" postgres
|
||||
su -c "psql -c \"CREATE USER mediacms WITH ENCRYPTED PASSWORD 'mediacms'\"" postgres
|
||||
su -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE mediacms TO mediacms\"" postgres
|
||||
|
||||
echo 'Creating python virtualenv on /srv/mediacms/virtualenv/'
|
||||
|
||||
mkdir /srv/mediacms/virtualenv/
|
||||
cd /srv/mediacms/virtualenv/
|
||||
virtualenv . --python=python3
|
||||
source /srv/mediacms/virtualenv/bin/activate
|
||||
cd /srv/mediacms/
|
||||
pip install -r requirements.txt
|
||||
|
||||
systemctl enable redis.service --now
|
||||
|
||||
SECRET_KEY=`python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'`
|
||||
|
||||
# remove http or https prefix
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/http:\/\///g'`
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/https:\/\///g'`
|
||||
|
||||
FRONTEND_HOST_HTTP_PREFIX='http://'$FRONTEND_HOST
|
||||
|
||||
echo 'FRONTEND_HOST='\'"$FRONTEND_HOST_HTTP_PREFIX"\' >> cms/local_settings.py
|
||||
echo 'PORTAL_NAME='\'"$PORTAL_NAME"\' >> cms/local_settings.py
|
||||
echo "SSL_FRONTEND_HOST = FRONTEND_HOST.replace('http', 'https')" >> cms/local_settings.py
|
||||
|
||||
echo 'SECRET_KEY='\'"$SECRET_KEY"\' >> cms/local_settings.py
|
||||
echo "LOCAL_INSTALL = True" >> cms/local_settings.py
|
||||
|
||||
mkdir /var/log/mediacms/
|
||||
mkdir pids
|
||||
|
||||
update_permissions /var/log/mediacms/
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py loaddata fixtures/encoding_profiles.json
|
||||
python manage.py loaddata fixtures/categories.json
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
ADMIN_PASS=`python -c "import secrets;chars = 'abcdefghijklmnopqrstuvwxyz0123456789';print(''.join(secrets.choice(chars) for i in range(10)))"`
|
||||
echo "from users.models import User; User.objects.create_superuser('admin', 'admin@example.com', '$ADMIN_PASS')" | python manage.py shell
|
||||
|
||||
echo "from django.contrib.sites.models import Site; Site.objects.update(name='$FRONTEND_HOST', domain='$FRONTEND_HOST')" | python manage.py shell
|
||||
|
||||
update_permissions /srv/mediacms/
|
||||
|
||||
cp deploy/local_install/celery_long.service /etc/systemd/system/celery_long.service
|
||||
cp deploy/local_install/celery_short.service /etc/systemd/system/celery_short.service
|
||||
cp deploy/local_install/celery_beat.service /etc/systemd/system/celery_beat.service
|
||||
cp deploy/local_install/mediacms.service /etc/systemd/system/mediacms.service
|
||||
|
||||
mkdir -p /etc/letsencrypt/live/$FRONTEND_HOST
|
||||
mkdir -p /etc/nginx/sites-enabled
|
||||
mkdir -p /etc/nginx/sites-available
|
||||
mkdir -p /etc/nginx/dhparams/
|
||||
rm -rf /etc/nginx/conf.d/default.conf
|
||||
rm -rf /etc/nginx/sites-enabled/default
|
||||
cp deploy/local_install/mediacms.io_fullchain.pem /etc/letsencrypt/live/$FRONTEND_HOST/fullchain.pem
|
||||
cp deploy/local_install/mediacms.io_privkey.pem /etc/letsencrypt/live/$FRONTEND_HOST/privkey.pem
|
||||
cp deploy/local_install/mediacms.io /etc/nginx/sites-available/mediacms.io
|
||||
ln -s /etc/nginx/sites-available/mediacms.io /etc/nginx/sites-enabled/mediacms.io
|
||||
cp deploy/local_install/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
|
||||
cp deploy/local_install/nginx.conf /etc/nginx/
|
||||
|
||||
# attempt to get a valid certificate for specified domain
|
||||
while true ; do
|
||||
echo "Would you like to run [c]ertbot, or [s]kip?"
|
||||
read -p " : " certbotConfig
|
||||
|
||||
case $certbotConfig in
|
||||
[cC*] )
|
||||
if [ "$FRONTEND_HOST" != "localhost" ]; then
|
||||
systemctl start
|
||||
echo 'attempt to get a valid certificate for specified url $FRONTEND_HOST'
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
# unfortunately for some reason it needs to be run two times in order to create the entries
|
||||
# and directory structure!!!
|
||||
systemctl stop nginx
|
||||
|
||||
# Generate individual DH params
|
||||
openssl dhparam -out /etc/nginx/dhparams/dhparams.pem 4096
|
||||
fi
|
||||
|
||||
break
|
||||
;;
|
||||
[sS*] )
|
||||
echo "will not call certbot utility to update ssl certificate for url 'localhost', using default ssl certificate"
|
||||
cp deploy/local_install/dhparams.pem /etc/nginx/dhparams/dhparams.pem
|
||||
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $certbotConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# configure bento4 utility installation, for HLS
|
||||
while true ; do
|
||||
echo "Configuring Bento4"
|
||||
echo "Would you like to [d]ownload a pre-compiled bento4 binary, or [b]uild it now?"
|
||||
read -p "b/d : " bentoConfig
|
||||
|
||||
case $bentoConfig in
|
||||
[bB*] )
|
||||
echo "Building bento4 from source"
|
||||
git clone -b v1.6.0-640 https://github.com/axiomatic-systems/Bento4 /srv/mediacms/bento4
|
||||
cd /srv/mediacms/bento4/
|
||||
mkdir bin
|
||||
cd /srv/mediacms/bento4/bin/
|
||||
cmake -DCMAKE_BUILD_TYPE=Release ..
|
||||
make -j$(nproc)
|
||||
|
||||
chmod +x ../Source/Python/utils/mp4-hls.py
|
||||
|
||||
echo -e '#!/bin/bash' >> mp4hls
|
||||
echo -e 'BASEDIR=$(pwd)' >> mp4hls
|
||||
echo -e 'exec python3 "$BASEDIR/../Source/Python/utils/mp4-hls.py"' >> mp4hls
|
||||
|
||||
chmod +x mp4hls
|
||||
|
||||
break
|
||||
;;
|
||||
[dD*] )
|
||||
cd /srv/mediacms/
|
||||
wget http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
bsdtar -xf Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip -s '/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/bento4/'
|
||||
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $bentoConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir /srv/mediacms/media_files/hls
|
||||
|
||||
# update permissions
|
||||
|
||||
update_permissions /srv/mediacms/
|
||||
|
||||
# configure selinux
|
||||
|
||||
while true ; do
|
||||
echo "Configuring SELinux"
|
||||
echo "Would you like to [d]isable SELinux until next reboot, [c]onfigure our SELinux module, or [s]kip and not do any SELinux confgiguration?"
|
||||
read -p "d/c/s : " seConfig
|
||||
|
||||
case $seConfig in
|
||||
[Dd]* )
|
||||
echo "Disabling SELinux until next reboot"
|
||||
break
|
||||
;;
|
||||
[Cc]* )
|
||||
echo "Configuring custom mediacms selinux module"
|
||||
|
||||
semanage fcontext -a -t bin_t /srv/mediacms/virtualenv/bin/
|
||||
semanage fcontext -a -t httpd_sys_content_t "/srv/mediacms(/.*)?"
|
||||
restorecon -FRv /srv/mediacms/
|
||||
|
||||
sebools=(httpd_can_network_connect httpd_graceful_shutdown httpd_can_network_relay nis_enabled httpd_setrlimit domain_can_mmap_files)
|
||||
|
||||
for bool in "${sebools[@]}"
|
||||
do
|
||||
setsebool -P $bool 1
|
||||
done
|
||||
|
||||
cd /srv/mediacms/deploy/local_install/
|
||||
make -f /usr/share/selinux/devel/Makefile selinux-mediacms.pp
|
||||
semodule -i selinux-mediacms.pp
|
||||
|
||||
break
|
||||
;;
|
||||
[Ss]* )
|
||||
echo "Skipping SELinux configuration"
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $seConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# configure firewall
|
||||
if command -v firewall-cmd > /dev/null 2>&1 ; then
|
||||
while true ; do
|
||||
echo "Configuring firewall"
|
||||
echo "Would you like to configure http, https, or skip and not do any firewall configuration?"
|
||||
read -p "http/https/skip : " fwConfig
|
||||
|
||||
case $fwConfig in
|
||||
http )
|
||||
echo "Opening port 80 until next reboot"
|
||||
firewall-cmd --add-port=80/tcp
|
||||
break
|
||||
;;
|
||||
https )
|
||||
echo "Opening port 443 permanently"
|
||||
firewall-cmd --add-port=443/tcp --permanent
|
||||
firewall-cmd --reload
|
||||
break
|
||||
;;
|
||||
skip )
|
||||
echo "Skipping firewall configuration"
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $fwConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl start celery_long.service
|
||||
systemctl start celery_short.service
|
||||
systemctl start celery_beat.service
|
||||
systemctl start mediacms.service
|
||||
systemctl start nginx.service
|
||||
|
||||
echo 'MediaCMS installation completed, open browser on http://'"$FRONTEND_HOST"' and login with user admin and password '"$ADMIN_PASS"''
|
||||
140
install.sh
140
install.sh
@@ -1,140 +0,0 @@
|
||||
#!/bin/bash
|
||||
# should be run as root and only on Ubuntu 20/22, Debian 10/11 (Buster/Bullseye) versions!
|
||||
echo "Welcome to the MediacMS installation!";
|
||||
|
||||
if [ `id -u` -ne 0 ]
|
||||
then echo "Please run as root"
|
||||
exit
|
||||
fi
|
||||
|
||||
|
||||
while true; do
|
||||
read -p "
|
||||
This script will attempt to perform a system update and install services including PostgreSQL, nginx and Django.
|
||||
It is expected to run on a new system **with no running instances of any these services**.
|
||||
This has been tested only in Ubuntu Linux 22 and 24. Make sure you check the script before you continue. Then enter yes or no
|
||||
" yn
|
||||
case $yn in
|
||||
[Yy]* ) echo "OK!"; break;;
|
||||
[Nn]* ) echo "Have a great day"; exit;;
|
||||
* ) echo "Please answer yes or no.";;
|
||||
esac
|
||||
done
|
||||
|
||||
apt-get update && apt-get -y upgrade && apt-get install pkg-config python3-venv python3-dev virtualenv redis-server postgresql nginx git gcc vim unzip imagemagick procps libxml2-dev libxmlsec1-dev libxmlsec1-openssl python3-certbot-nginx certbot wget xz-utils -y
|
||||
|
||||
# install ffmpeg
|
||||
echo "Downloading and installing ffmpeg"
|
||||
wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
||||
mkdir -p tmp
|
||||
tar -xf ffmpeg-release-amd64-static.tar.xz --strip-components 1 -C tmp
|
||||
cp -v tmp/{ffmpeg,ffprobe,qt-faststart} /usr/local/bin
|
||||
rm -rf tmp ffmpeg-release-amd64-static.tar.xz
|
||||
echo "ffmpeg installed to /usr/local/bin"
|
||||
|
||||
read -p "Enter portal URL, or press enter for localhost : " FRONTEND_HOST
|
||||
read -p "Enter portal name, or press enter for 'MediaCMS : " PORTAL_NAME
|
||||
|
||||
[ -z "$PORTAL_NAME" ] && PORTAL_NAME='MediaCMS'
|
||||
[ -z "$FRONTEND_HOST" ] && FRONTEND_HOST='localhost'
|
||||
|
||||
echo 'Creating database to be used in MediaCMS'
|
||||
|
||||
su -c "psql -c \"CREATE DATABASE mediacms\"" postgres
|
||||
su -c "psql -c \"CREATE USER mediacms WITH ENCRYPTED PASSWORD 'mediacms'\"" postgres
|
||||
su -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE mediacms TO mediacms\"" postgres
|
||||
su -c "psql -d mediacms -c \"GRANT CREATE, USAGE ON SCHEMA public TO mediacms\"" postgres
|
||||
|
||||
echo 'Creating python virtualenv on /home/mediacms.io'
|
||||
|
||||
cd /home/mediacms.io
|
||||
virtualenv . --python=python3
|
||||
source /home/mediacms.io/bin/activate
|
||||
cd mediacms
|
||||
pip install --no-binary lxml,xmlsec -r requirements.txt
|
||||
|
||||
SECRET_KEY=`python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'`
|
||||
|
||||
# remove http or https prefix
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/http:\/\///g'`
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/https:\/\///g'`
|
||||
|
||||
sed -i s/localhost/$FRONTEND_HOST/g deploy/local_install/mediacms.io
|
||||
|
||||
FRONTEND_HOST_HTTP_PREFIX='http://'$FRONTEND_HOST
|
||||
|
||||
echo 'FRONTEND_HOST='\'"$FRONTEND_HOST_HTTP_PREFIX"\' >> cms/local_settings.py
|
||||
echo 'PORTAL_NAME='\'"$PORTAL_NAME"\' >> cms/local_settings.py
|
||||
echo "SSL_FRONTEND_HOST = FRONTEND_HOST.replace('http', 'https')" >> cms/local_settings.py
|
||||
|
||||
echo 'SECRET_KEY='\'"$SECRET_KEY"\' >> cms/local_settings.py
|
||||
echo "LOCAL_INSTALL = True" >> cms/local_settings.py
|
||||
|
||||
mkdir logs
|
||||
mkdir pids
|
||||
python manage.py migrate
|
||||
python manage.py loaddata fixtures/encoding_profiles.json
|
||||
python manage.py loaddata fixtures/categories.json
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
ADMIN_PASS=`python -c "import secrets;chars = 'abcdefghijklmnopqrstuvwxyz0123456789';print(''.join(secrets.choice(chars) for i in range(10)))"`
|
||||
echo "from users.models import User; User.objects.create_superuser('admin', 'admin@example.com', '$ADMIN_PASS')" | python manage.py shell
|
||||
|
||||
echo "from django.contrib.sites.models import Site; Site.objects.update(name='$FRONTEND_HOST', domain='$FRONTEND_HOST')" | python manage.py shell
|
||||
|
||||
chown -R www-data. /home/mediacms.io/
|
||||
cp deploy/local_install/celery_long.service /etc/systemd/system/celery_long.service && systemctl enable celery_long && systemctl start celery_long
|
||||
cp deploy/local_install/celery_short.service /etc/systemd/system/celery_short.service && systemctl enable celery_short && systemctl start celery_short
|
||||
cp deploy/local_install/celery_beat.service /etc/systemd/system/celery_beat.service && systemctl enable celery_beat &&systemctl start celery_beat
|
||||
cp deploy/local_install/mediacms.service /etc/systemd/system/mediacms.service && systemctl enable mediacms.service && systemctl start mediacms.service
|
||||
|
||||
mkdir -p /etc/letsencrypt/live/mediacms.io/
|
||||
mkdir -p /etc/letsencrypt/live/$FRONTEND_HOST
|
||||
mkdir -p /etc/nginx/sites-enabled
|
||||
mkdir -p /etc/nginx/sites-available
|
||||
mkdir -p /etc/nginx/dhparams/
|
||||
rm -rf /etc/nginx/conf.d/default.conf
|
||||
rm -rf /etc/nginx/sites-enabled/default
|
||||
cp deploy/local_install/mediacms.io_fullchain.pem /etc/letsencrypt/live/$FRONTEND_HOST/fullchain.pem
|
||||
cp deploy/local_install/mediacms.io_privkey.pem /etc/letsencrypt/live/$FRONTEND_HOST/privkey.pem
|
||||
cp deploy/local_install/dhparams.pem /etc/nginx/dhparams/dhparams.pem
|
||||
cp deploy/local_install/mediacms.io /etc/nginx/sites-available/mediacms.io
|
||||
ln -s /etc/nginx/sites-available/mediacms.io /etc/nginx/sites-enabled/mediacms.io
|
||||
cp deploy/local_install/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
|
||||
cp deploy/local_install/nginx.conf /etc/nginx/
|
||||
systemctl stop nginx
|
||||
systemctl start nginx
|
||||
|
||||
# attempt to get a valid certificate for specified domain
|
||||
|
||||
if [ "$FRONTEND_HOST" != "localhost" ]; then
|
||||
echo 'attempt to get a valid certificate for specified url $FRONTEND_HOST'
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
# unfortunately for some reason it needs to be run two times in order to create the entries
|
||||
# and directory structure!!!
|
||||
systemctl restart nginx
|
||||
else
|
||||
echo "will not call certbot utility to update ssl certificate for url 'localhost', using default ssl certificate"
|
||||
fi
|
||||
|
||||
# Generate individual DH params
|
||||
if [ "$FRONTEND_HOST" != "localhost" ]; then
|
||||
# Only generate new DH params when using "real" certificates.
|
||||
openssl dhparam -out /etc/nginx/dhparams/dhparams.pem 4096
|
||||
systemctl restart nginx
|
||||
else
|
||||
echo "will not generate new DH params for url 'localhost', using default DH params"
|
||||
fi
|
||||
|
||||
# Bento4 utility installation, for HLS
|
||||
|
||||
cd /home/mediacms.io/mediacms
|
||||
wget http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
unzip Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
mkdir /home/mediacms.io/mediacms/media_files/hls
|
||||
|
||||
# last, set default owner
|
||||
chown -R www-data. /home/mediacms.io/
|
||||
|
||||
echo 'MediaCMS installation completed, open browser on http://'"$FRONTEND_HOST"' and login with user admin and password '"$ADMIN_PASS"''
|
||||
49
lms-plugins/mediacms-moodle/Gruntfile.js
Normal file
49
lms-plugins/mediacms-moodle/Gruntfile.js
Normal file
@@ -0,0 +1,49 @@
|
||||
module.exports = function(grunt) {
|
||||
var path = require('path');
|
||||
|
||||
grunt.initConfig({
|
||||
babel: {
|
||||
options: {
|
||||
sourceMap: true,
|
||||
presets: ['@babel/preset-env'],
|
||||
plugins: ['@babel/plugin-transform-modules-amd'],
|
||||
moduleIds: true,
|
||||
getModuleId: function(moduleName) {
|
||||
// moduleName is the absolute or relative path to the file.
|
||||
// We need to convert 'tiny/mediacms/amd/src/filename' to 'tiny_mediacms/filename'
|
||||
var filename = path.basename(moduleName);
|
||||
return 'tiny_mediacms/' + filename;
|
||||
}
|
||||
},
|
||||
dist: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: 'tiny/mediacms/amd/src',
|
||||
src: ['*.js'],
|
||||
dest: 'tiny/mediacms/amd/build',
|
||||
ext: '.js'
|
||||
}]
|
||||
}
|
||||
},
|
||||
uglify: {
|
||||
options: {
|
||||
sourceMap: true,
|
||||
output: { comments: false }
|
||||
},
|
||||
dist: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: 'tiny/mediacms/amd/build',
|
||||
src: ['*.js', '!*.min.js'],
|
||||
dest: 'tiny/mediacms/amd/build',
|
||||
ext: '.min.js'
|
||||
}]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
grunt.loadNpmTasks('grunt-babel');
|
||||
grunt.loadNpmTasks('grunt-contrib-uglify');
|
||||
|
||||
grunt.registerTask('default', ['babel', 'uglify']);
|
||||
};
|
||||
72
lms-plugins/mediacms-moodle/INSTALL.txt
Normal file
72
lms-plugins/mediacms-moodle/INSTALL.txt
Normal file
@@ -0,0 +1,72 @@
|
||||
================================================================================
|
||||
MediaCMS Moodle Plugin Suite v1.0.0
|
||||
Installation Guide
|
||||
================================================================================
|
||||
|
||||
Requirements
|
||||
------------
|
||||
- Moodle 4.5 or later
|
||||
- MediaCMS instance
|
||||
- MediaCMS set as External Tool
|
||||
|
||||
|
||||
Installation
|
||||
------------------
|
||||
|
||||
1. Extract zip file to Moodle root public directory:
|
||||
|
||||
cd /var/www/moodle/public
|
||||
unzip mediacms-moodle-v1.0.0.zip
|
||||
|
||||
This will place files in:
|
||||
- filter/mediacms/
|
||||
- lib/editor/tiny/plugins/mediacms/
|
||||
|
||||
2. Set permissions
|
||||
chown -R www-data:www-data filter/mediacms
|
||||
chown -R www-data:www-data lib/editor/tiny/plugins/mediacms
|
||||
|
||||
3. Install through Moodle
|
||||
- Log in as Administrator
|
||||
- Go to: Site Administration → Notifications
|
||||
- Click "Upgrade Moodle database now"
|
||||
- Both plugins will be installed automatically
|
||||
- Set the MediaCMS tool under the LTI Tool
|
||||
|
||||
4. Make sure Filter is enabled
|
||||
- As Administrator, visit Plugins, 'Manage Filters', find MediaCMS filter and enable it.
|
||||
Then place it at the top of the filter. This is important, otherwise embeds won't load.
|
||||
|
||||
5. Enter 'My Media' on top navigation.
|
||||
- Log in as Administrator
|
||||
- Go to: Site Administration → Appearance → Advanced Theme settings -> Custom menu items:
|
||||
add: My Media|/filter/mediacms/my_media.php
|
||||
Notes: User needs to be enrolled on one course at least, otherwise visiting My Media won't show anything.
|
||||
The plugin needs to use the ID of an existing course to the 'My Media' page and thus it is going to use it
|
||||
and create a 'my media' page on the course, if it doesn't already exist. This is happening automatically the
|
||||
first time a user visits the global 'my media' link, if a 'my media' page activity does not exist in the course, the
|
||||
plugin will create it. If you don't want the 'my media' page to show up on the course, you can place it to a hidden part
|
||||
Create a part (eg 'Hidden Links'), select the Edit options (three vertical dots) and Hide it.
|
||||
Then place the 'My Media' activity there, select the three dots for the activity, Availability, and set "Make available but don't show on course page: Available to students if you provide a link."
|
||||
This option won't appear if the part is not hidden, so first you want to create the part, hide it, and then edit the setting for 'my media'
|
||||
|
||||
To summarize, for the 'my media' global link on top, a 'my media' is also required in each course, and you can either
|
||||
create it (as MediaCMS activity with any name) or let the first user that visits the global 'my media' have it created automatically.
|
||||
If you don't want this activity to be visible in the course, place it under a hidden part and set it's availability as "Make available but don't show on course page", which is only possible for items inside hidden parts.
|
||||
|
||||
|
||||
|
||||
What to expect
|
||||
-------
|
||||
|
||||
1. Create a test course
|
||||
2. Add a page or label
|
||||
3. Click MediaCMS button in TinyMCE editor
|
||||
4. Try inserting from video library or pasting a URL
|
||||
|
||||
SUPPORT
|
||||
-------
|
||||
Issues: https://github.com/mediacms-io/mediacms/issues
|
||||
Docs: https://docs.mediacms.io
|
||||
|
||||
================================================================================
|
||||
90
lms-plugins/mediacms-moodle/README.md
Normal file
90
lms-plugins/mediacms-moodle/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# MediaCMS for Moodle
|
||||
|
||||
**Version:** 1.0.0 | **Release Date:** 2026-02-12 | **Moodle:** 4.5+
|
||||
|
||||
This package provides complete MediaCMS integration for Moodle, consisting of two plugins that work together with **unified settings**:
|
||||
|
||||
1. **Filter Plugin (filter_mediacms):**
|
||||
* Handles LTI 1.3 authentication and secure video launches
|
||||
* Auto-converts MediaCMS URLs to embedded players
|
||||
* **Provides core settings** (MediaCMS URL, LTI Tool ID) used by both plugins
|
||||
* **Location:** `filter/mediacms`
|
||||
|
||||
2. **Editor Plugin (tiny_mediacms):**
|
||||
* Adds MediaCMS button to TinyMCE editor
|
||||
* Browse authenticated video library via LTI Deep Linking
|
||||
* Configure embed options (dimensions, display, start time)
|
||||
* **Reads core settings** from filter plugin
|
||||
* **Location:** `lib/editor/tiny/plugins/mediacms`
|
||||
|
||||
## Installation
|
||||
|
||||
This package is distributed as a single repository but contains two distinct Moodle plugins that must be installed in their respective directories.
|
||||
|
||||
### 1. Copy Files
|
||||
|
||||
Copy the directories into your Moodle installation as follows (example assuming Moodle is at `/var/www/moodle/public`):
|
||||
|
||||
* Copy `filter/mediacms` to `/var/www/moodle/public/filter/mediacms`.
|
||||
* Copy `tiny/mediacms` to `/var/www/moodle/public/lib/editor/tiny/plugins/mediacms`.
|
||||
|
||||
### 2. Set Permissions
|
||||
|
||||
Ensure the web server user (typically `www-data`) has ownership of the new directories:
|
||||
|
||||
```bash
|
||||
# Example for Ubuntu/Debian systems
|
||||
chown -R www-data:www-data /var/www/moodle/public/filter/mediacms
|
||||
chown -R www-data:www-data /var/www/moodle/public/lib/editor/tiny/plugins/mediacms
|
||||
chmod -R 755 /var/www/moodle/public/filter/mediacms
|
||||
chmod -R 755 /var/www/moodle/public/lib/editor/tiny/plugins/mediacms
|
||||
```
|
||||
|
||||
### 3. Install Plugins
|
||||
|
||||
1. Log in to Moodle as an Administrator.
|
||||
2. Go to **Site administration > Notifications**.
|
||||
3. Follow the prompts to upgrade the database and install the new plugins.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Step 1: Core Settings (Required) - Configure Once
|
||||
|
||||
Go to **Site administration > Plugins > Filters > MediaCMS** (Settings)
|
||||
|
||||
* **MediaCMS URL:** Enter your MediaCMS instance URL (e.g., `https://lti.mediacms.io`)
|
||||
* **LTI Tool:** Select the External Tool configuration for MediaCMS
|
||||
* *First create an LTI 1.3 tool at: Site administration > Plugins > Activity modules > External tool > Manage tools*
|
||||
|
||||
> **✨ Note:** These core settings are automatically used by **both** the filter and TinyMCE editor plugin.
|
||||
|
||||
### Step 2: Enable Filter
|
||||
|
||||
1. Go to **Site administration > Plugins > Filters > Manage filters**
|
||||
2. Set **MediaCMS** to "On"
|
||||
|
||||
### Step 3: Configure Auto-convert Defaults (Optional)
|
||||
|
||||
Go to **Site administration > Plugins > Text editors > TinyMCE editor > MediaCMS settings**
|
||||
|
||||
Configure default display options for auto-converted URLs:
|
||||
* Show video title
|
||||
* Link video title
|
||||
* Show related videos
|
||||
* Show user avatar
|
||||
|
||||
> **Note:** The core settings (URL, LTI Tool) are managed in the filter plugin settings.
|
||||
|
||||
## Usage
|
||||
|
||||
### For Teachers (Editor)
|
||||
|
||||
1. In any text editor (TinyMCE), click the **MediaCMS** icon (or "Insert MediaCMS Media" from the Insert menu).
|
||||
2. You can:
|
||||
* **Paste a URL:** Paste a View or Embed URL.
|
||||
* **Video Library:** Click the "Video Library" tab to browse and select videos (requires LTI Deep Linking configuration).
|
||||
3. The video will appear as a placeholder or iframe in the editor.
|
||||
|
||||
### For Students (Display)
|
||||
|
||||
When content is viewed, the Filter will ensure the video is loaded securely via LTI 1.3, authenticating the user with MediaCMS automatically.
|
||||
89
lms-plugins/mediacms-moodle/build.sh
Executable file
89
lms-plugins/mediacms-moodle/build.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
|
||||
# MediaCMS Moodle Plugin Suite - Build Script
|
||||
# Creates distributable ZIP package
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}======================================${NC}"
|
||||
echo -e "${GREEN}MediaCMS Moodle Plugin Suite Builder${NC}"
|
||||
echo -e "${GREEN}======================================${NC}"
|
||||
echo
|
||||
|
||||
# Configuration
|
||||
VERSION="1.0.0"
|
||||
BUILD_DATE=$(date +%Y%m%d)
|
||||
PACKAGE_NAME="mediacms-moodle-v${VERSION}"
|
||||
DIST_DIR="dist"
|
||||
BUILD_DIR="${DIST_DIR}/${PACKAGE_NAME}"
|
||||
|
||||
# Create clean dist directory
|
||||
echo -e "${YELLOW}→${NC} Cleaning dist directory..."
|
||||
rm -rf "${DIST_DIR}"
|
||||
mkdir -p "${BUILD_DIR}"
|
||||
|
||||
# Copy filter plugin
|
||||
echo -e "${YELLOW}→${NC} Copying filter plugin..."
|
||||
mkdir -p "${BUILD_DIR}/filter"
|
||||
cp -r filter/mediacms "${BUILD_DIR}/filter/"
|
||||
|
||||
# Copy TinyMCE plugin
|
||||
echo -e "${YELLOW}→${NC} Copying TinyMCE plugin..."
|
||||
mkdir -p "${BUILD_DIR}/lib/editor/tiny/plugins"
|
||||
cp -r tiny/mediacms "${BUILD_DIR}/lib/editor/tiny/plugins/"
|
||||
|
||||
# Copy documentation
|
||||
echo -e "${YELLOW}→${NC} Copying documentation..."
|
||||
cp README.md "${BUILD_DIR}/filter/mediacms/"
|
||||
cp INSTALL.txt "${BUILD_DIR}/filter/mediacms/"
|
||||
|
||||
# Clean up development files
|
||||
echo -e "${YELLOW}→${NC} Removing development files..."
|
||||
find "${BUILD_DIR}" -type d -name "node_modules" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "${BUILD_DIR}" -type f -name ".DS_Store" -delete 2>/dev/null || true
|
||||
find "${BUILD_DIR}" -type f -name "*.log" -delete 2>/dev/null || true
|
||||
find "${BUILD_DIR}" -type d -name ".git" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "${BUILD_DIR}" -type f -name ".gitignore" -delete 2>/dev/null || true
|
||||
|
||||
# Remove AMD source files (keep only built versions)
|
||||
echo -e "${YELLOW}→${NC} Cleaning AMD source files..."
|
||||
find "${BUILD_DIR}/lib/editor/tiny/plugins/mediacms/amd" -type f -name "*.js" ! -name "*-lazy.js" ! -path "*/build/*" -delete 2>/dev/null || true
|
||||
|
||||
# Create ZIP archive
|
||||
echo -e "${YELLOW}→${NC} Creating ZIP archive..."
|
||||
cd "${BUILD_DIR}"
|
||||
zip -r "../${PACKAGE_NAME}.zip" . -q
|
||||
cd ../..
|
||||
|
||||
# Create checksum
|
||||
echo -e "${YELLOW}→${NC} Generating checksum..."
|
||||
cd "${DIST_DIR}"
|
||||
sha256sum "${PACKAGE_NAME}.zip" > "${PACKAGE_NAME}.zip.sha256"
|
||||
cd ..
|
||||
|
||||
# Display results
|
||||
ZIP_SIZE=$(du -h "${DIST_DIR}/${PACKAGE_NAME}.zip" | cut -f1)
|
||||
echo
|
||||
echo -e "${GREEN}✓ Build complete!${NC}"
|
||||
echo
|
||||
echo "Package: ${DIST_DIR}/${PACKAGE_NAME}.zip"
|
||||
echo "Size: ${ZIP_SIZE}"
|
||||
echo "Checksum: ${DIST_DIR}/${PACKAGE_NAME}.zip.sha256"
|
||||
echo
|
||||
echo -e "${YELLOW}Contents:${NC}"
|
||||
echo " - filter/mediacms/ (includes docs)"
|
||||
echo " - lib/editor/tiny/plugins/mediacms/"
|
||||
echo
|
||||
echo -e "${GREEN}Ready for distribution!${NC}"
|
||||
echo
|
||||
|
||||
# Show checksum
|
||||
echo -e "${YELLOW}SHA256 Checksum:${NC}"
|
||||
cat "${DIST_DIR}/${PACKAGE_NAME}.zip.sha256"
|
||||
echo
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/**
|
||||
* Hook listener for filter_mediacms navigation hooks (Moodle 4.3+)
|
||||
*
|
||||
* @package filter_mediacms
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace filter_mediacms;
|
||||
|
||||
/**
|
||||
* Extends the primary (top) navigation bar with a My Media link.
|
||||
*/
|
||||
class hook_listener {
|
||||
|
||||
/**
|
||||
* Called by the \core\hook\navigation\primary_extend hook.
|
||||
* Adds the My Media link to the primary nav bar when placement = 'top'.
|
||||
*/
|
||||
public static function extend_primary_navigation(
|
||||
\core\hook\navigation\primary_extend $hook
|
||||
): void {
|
||||
$placement = get_config('filter_mediacms', 'mymedia_placement');
|
||||
if ($placement !== 'top') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isloggedin() || isguestuser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = new \moodle_url('/filter/mediacms/my_media.php');
|
||||
$node = \navigation_node::create(
|
||||
get_string('mymedia', 'filter_mediacms'),
|
||||
$url,
|
||||
\navigation_node::TYPE_CUSTOM,
|
||||
null,
|
||||
'mediacms_mymedia',
|
||||
new \pix_icon('i/media', '')
|
||||
);
|
||||
|
||||
$primarynav = $hook->get_primarynav();
|
||||
if ($primarynav === null) {
|
||||
return;
|
||||
}
|
||||
$primarynav->add_node($node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace filter_mediacms\privacy;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
class provider implements \core_privacy\local\metadata\null_provider {
|
||||
public static function get_reason(): string {
|
||||
return 'privacy:metadata';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace filter_mediacms;
|
||||
|
||||
use moodle_url;
|
||||
use html_writer;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* MediaCMS text filter.
|
||||
*
|
||||
* @package filter_mediacms
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class text_filter extends \core_filters\text_filter {
|
||||
|
||||
/**
|
||||
* Filter method.
|
||||
*
|
||||
* @param string $text The text to filter.
|
||||
* @param array $options Filter options.
|
||||
* @return string The filtered text.
|
||||
*/
|
||||
public function filter($text, array $options = array()) {
|
||||
if (!is_string($text) or empty($text)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
$mediacmsurl = get_config('filter_mediacms', 'mediacmsurl');
|
||||
if (empty($mediacmsurl)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
$newtext = $text;
|
||||
|
||||
// 1. Handle [mediacms:TOKEN] tag
|
||||
$pattern_tag = '/\[mediacms:([a-zA-Z0-9]+)\]/';
|
||||
$newtext = preg_replace_callback($pattern_tag, [$this, 'callback_tag'], $newtext);
|
||||
|
||||
// 2. Auto-convert MediaCMS URLs to embedded players
|
||||
// First, protect text-only links from being converted
|
||||
// by temporarily replacing them with placeholders
|
||||
$textlink_placeholders = [];
|
||||
$textlink_pattern = '/<a\s+[^>]*data-mediacms-textlink=["\']true["\'][^>]*>.*?<\/a>/is';
|
||||
|
||||
$newtext = preg_replace_callback($textlink_pattern, function($matches) use (&$textlink_placeholders) {
|
||||
$placeholder = '###MEDIACMS_TEXTLINK_' . count($textlink_placeholders) . '###';
|
||||
$textlink_placeholders[$placeholder] = $matches[0];
|
||||
return $placeholder;
|
||||
}, $newtext);
|
||||
|
||||
// Regex for MediaCMS view URLs: https://domain/view?m=TOKEN
|
||||
// We need to be careful to match the configured domain
|
||||
$parsed_url = parse_url($mediacmsurl);
|
||||
$host = preg_quote($parsed_url['host'] ?? '', '/');
|
||||
$scheme = preg_quote($parsed_url['scheme'] ?? 'https', '/');
|
||||
|
||||
// Allow http or https, and optional path prefix
|
||||
$path_prefix = preg_quote(rtrim($parsed_url['path'] ?? '', '/'), '/');
|
||||
|
||||
// Pattern: https://HOST/PREFIX/view?m=TOKEN
|
||||
// Also handle /embed?m=TOKEN
|
||||
$pattern_url = '/(' . $scheme . ':\/\/' . $host . $path_prefix . '\/(view|embed)\?m=([a-zA-Z0-9]+)(?:&[^\s<]*)?)/';
|
||||
|
||||
$newtext = preg_replace_callback($pattern_url, [$this, 'callback_url'], $newtext);
|
||||
|
||||
// Restore protected text-only links as modal launchers
|
||||
foreach ($textlink_placeholders as $placeholder => $original) {
|
||||
$newtext = str_replace($placeholder, $this->transform_textlink($original), $newtext);
|
||||
}
|
||||
|
||||
return $newtext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for [mediacms:TOKEN]
|
||||
*/
|
||||
public function callback_tag($matches) {
|
||||
return $this->generate_iframe($matches[1], []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for URLs
|
||||
*/
|
||||
public function callback_url($matches) {
|
||||
// matches[0] is the full matched string
|
||||
// matches[1] is full URL, matches[3] is token
|
||||
|
||||
// Check if this URL is inside a text-only link
|
||||
// by looking at the context around the match
|
||||
$fullmatch = $matches[0];
|
||||
|
||||
// If this is already inside an <a> tag with data-mediacms-textlink="true",
|
||||
// return the original URL unchanged
|
||||
// We'll check this in the main filter method instead
|
||||
|
||||
$token = $matches[3];
|
||||
|
||||
// Extract additional embed parameters from the URL
|
||||
$embed_params = [];
|
||||
$full_url = $matches[1];
|
||||
|
||||
// Decode HTML entities (& -> &) before parsing
|
||||
$full_url = html_entity_decode($full_url, ENT_QUOTES | ENT_HTML5);
|
||||
|
||||
$parsed_url = parse_url($full_url);
|
||||
|
||||
if (isset($parsed_url['query'])) {
|
||||
parse_str($parsed_url['query'], $query_params);
|
||||
|
||||
// Extract embed-related parameters
|
||||
$supported_params = ['showTitle', 'showRelated', 'showUserAvatar', 'linkTitle', 't', 'width', 'height'];
|
||||
foreach ($supported_params as $param) {
|
||||
if (isset($query_params[$param])) {
|
||||
$embed_params[$param] = $query_params[$param];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->generate_iframe($token, $embed_params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the Iframe pointing to launch.php
|
||||
*/
|
||||
private function generate_iframe($token, $embed_params = []) {
|
||||
global $CFG, $COURSE;
|
||||
|
||||
// Use width/height from embed params if provided, no defaults
|
||||
$width = isset($embed_params['width']) ? $embed_params['width'] : null;
|
||||
$height = isset($embed_params['height']) ? $embed_params['height'] : null;
|
||||
$courseid = $COURSE->id ?? 0;
|
||||
|
||||
// Build launch URL parameters
|
||||
$launch_params = [
|
||||
'token' => $token,
|
||||
'courseid' => $courseid
|
||||
];
|
||||
|
||||
// Add width/height only if provided
|
||||
if ($width !== null) {
|
||||
$launch_params['width'] = $width;
|
||||
}
|
||||
if ($height !== null) {
|
||||
$launch_params['height'] = $height;
|
||||
}
|
||||
|
||||
// Add other embed parameters if provided (excluding width/height as they're already handled)
|
||||
foreach ($embed_params as $key => $value) {
|
||||
if ($key !== 'width' && $key !== 'height') {
|
||||
$launch_params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$launchurl = new moodle_url('/filter/mediacms/launch.php', $launch_params);
|
||||
|
||||
// Build iframe attributes
|
||||
$iframe_attrs = [
|
||||
'src' => $launchurl->out(false),
|
||||
'frameborder' => 0,
|
||||
'allowfullscreen' => 'allowfullscreen',
|
||||
'class' => 'mediacms-embed',
|
||||
'title' => 'MediaCMS Video'
|
||||
];
|
||||
|
||||
// Add width/height attributes only if provided
|
||||
if ($width !== null) {
|
||||
$iframe_attrs['width'] = $width;
|
||||
}
|
||||
if ($height !== null) {
|
||||
$iframe_attrs['height'] = $height;
|
||||
}
|
||||
|
||||
$iframe = html_writer::tag('iframe', '', $iframe_attrs);
|
||||
|
||||
return $iframe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a text-only link into a link that replaces itself with an inline iframe on click.
|
||||
*
|
||||
* @param string $anchor_html Original <a ...>...</a> HTML
|
||||
* @return string Transformed HTML (or original if token cannot be extracted)
|
||||
*/
|
||||
private function transform_textlink($anchor_html) {
|
||||
global $COURSE, $PAGE;
|
||||
|
||||
// Extract href.
|
||||
if (!preg_match('/href=["\']([^"\']+)["\']/', $anchor_html, $href_matches)) {
|
||||
return $anchor_html;
|
||||
}
|
||||
$href = html_entity_decode($href_matches[1], ENT_QUOTES | ENT_HTML5);
|
||||
|
||||
// Extract ?m=TOKEN.
|
||||
parse_str(parse_url($href, PHP_URL_QUERY) ?? '', $query_params);
|
||||
$token = $query_params['m'] ?? null;
|
||||
if (!$token || !preg_match('/^[a-zA-Z0-9]+$/', $token)) {
|
||||
return $anchor_html;
|
||||
}
|
||||
|
||||
// Extract inner link text.
|
||||
if (!preg_match('/<a[^>]*>(.*?)<\/a>/is', $anchor_html, $text_matches)) {
|
||||
return $anchor_html;
|
||||
}
|
||||
|
||||
$launch_url = new moodle_url('/filter/mediacms/launch.php', [
|
||||
'token' => $token,
|
||||
'courseid' => isset($COURSE->id) ? (int)$COURSE->id : 0,
|
||||
'show_media_page' => 'true',
|
||||
]);
|
||||
|
||||
if (!self::$textlink_js_added) {
|
||||
self::$textlink_js_added = true;
|
||||
$PAGE->requires->js_init_code(
|
||||
'document.addEventListener("click",function(e){'
|
||||
. 'var a=e.target.closest("a.mediacms-textlink-launch");if(!a)return;'
|
||||
. 'e.preventDefault();'
|
||||
. 'var f=document.createElement("iframe");'
|
||||
. 'f.src=a.dataset.launchUrl;'
|
||||
. 'f.style.cssText="width:100%;height:480px;border:none;display:block;";'
|
||||
. 'f.allowFullscreen=true;'
|
||||
. 'f.setAttribute("allow","autoplay *; fullscreen *; encrypted-media *;");'
|
||||
. 'a.parentNode.replaceChild(f,a);'
|
||||
. '});',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
return html_writer::tag('a', $text_matches[1], [
|
||||
'href' => '#',
|
||||
'class' => 'mediacms-textlink-launch',
|
||||
'data-launch-url' => $launch_url->out(false),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var bool Whether the inline-iframe JS has already been added to the page. */
|
||||
private static $textlink_js_added = false;
|
||||
}
|
||||
13
lms-plugins/mediacms-moodle/filter/mediacms/db/hooks.php
Normal file
13
lms-plugins/mediacms-moodle/filter/mediacms/db/hooks.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
/**
|
||||
* Hook registrations for filter_mediacms.
|
||||
* Primary navigation is handled via extend_navigation() in lib.php instead.
|
||||
*
|
||||
* @package filter_mediacms
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$callbacks = [];
|
||||
45
lms-plugins/mediacms-moodle/filter/mediacms/db/install.php
Normal file
45
lms-plugins/mediacms-moodle/filter/mediacms/db/install.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Post-installation hook.
|
||||
*/
|
||||
function xmldb_filter_mediacms_install() {
|
||||
global $CFG, $DB;
|
||||
require_once($CFG->libdir . '/filterlib.php');
|
||||
|
||||
// Enable the filter globally.
|
||||
filter_set_global_state('filter_mediacms', TEXTFILTER_ON);
|
||||
|
||||
// Move to top priority (lowest sortorder).
|
||||
$syscontextid = context_system::instance()->id;
|
||||
$filters = $DB->get_records('filter_active', ['contextid' => $syscontextid], 'sortorder ASC');
|
||||
|
||||
if (empty($filters)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Separate mediacms from other filters by inspecting the record property,
|
||||
// not the array key (get_records indexes by id, not by filter name).
|
||||
$mediacmsrecord = null;
|
||||
$otherrecords = [];
|
||||
foreach ($filters as $record) {
|
||||
if ($record->filter === 'filter_mediacms') {
|
||||
$mediacmsrecord = $record;
|
||||
} else {
|
||||
$otherrecords[] = $record;
|
||||
}
|
||||
}
|
||||
|
||||
// Reassign sortorders: mediacms first, then everyone else.
|
||||
$sortorder = 1;
|
||||
if ($mediacmsrecord) {
|
||||
$mediacmsrecord->sortorder = $sortorder++;
|
||||
$DB->update_record('filter_active', $mediacmsrecord);
|
||||
}
|
||||
foreach ($otherrecords as $record) {
|
||||
$record->sortorder = $sortorder++;
|
||||
$DB->update_record('filter_active', $record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$string['filtername'] = 'MediaCMS';
|
||||
$string['pluginname'] = 'MediaCMS';
|
||||
$string['coresettings'] = 'Core MediaCMS Settings';
|
||||
$string['coresettings_desc'] = 'These settings are shared with the TinyMCE MediaCMS editor plugin.';
|
||||
$string['mediacmsurl'] = 'MediaCMS URL';
|
||||
$string['mediacmsurl_desc'] = 'The base URL of your MediaCMS instance (e.g., https://lti.mediacms.io). This setting is used by both the filter and the TinyMCE editor plugin.';
|
||||
$string['ltitoolid'] = 'LTI Tool';
|
||||
$string['ltitoolid_desc'] = 'Select the External Tool configuration for MediaCMS. This enables the video library in the TinyMCE editor and LTI authentication. To set up an LTI tool, go to Site Administration > Plugins > Activity modules > External tool > Manage tools.';
|
||||
$string['noltitoolsfound'] = 'No LTI tools found';
|
||||
$string['iframewidth'] = 'Default Width';
|
||||
$string['iframewidth_desc'] = 'Default width for embedded videos (pixels).';
|
||||
$string['iframeheight'] = 'Default Height';
|
||||
$string['iframeheight_desc'] = 'Default height for embedded videos (pixels).';
|
||||
$string['enableautoconvert'] = 'Auto-convert URLs';
|
||||
$string['enableautoconvert_desc'] = 'Automatically convert MediaCMS URLs (e.g., /view?m=xyz) in text to embedded players.';
|
||||
$string['privacy:metadata'] = 'The MediaCMS filter does not store any personal data.';
|
||||
|
||||
// My Media page.
|
||||
$string['mymedia'] = 'My Media';
|
||||
$string['mymedia_placement'] = 'My Media link placement';
|
||||
$string['mymedia_placement_desc'] = 'Where to display the My Media link in the Moodle interface.';
|
||||
$string['mymedia_placement_top'] = 'Top navigation';
|
||||
$string['mymedia_placement_user'] = 'User navigation';
|
||||
$string['notconfigured'] = 'MediaCMS is not fully configured. Please set the MediaCMS URL and LTI Tool in Site Administration → Plugins → Filters → MediaCMS.';
|
||||
$string['ltitoolnotfound'] = 'The configured LTI tool could not be found. Please check the MediaCMS filter settings.';
|
||||
$string['cannotcreatedummyactivity'] = 'Could not create the MediaCMS launcher activity. Please check course permissions.';
|
||||
173
lms-plugins/mediacms-moodle/filter/mediacms/launch.php
Normal file
173
lms-plugins/mediacms-moodle/filter/mediacms/launch.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
/**
|
||||
* LTI Launch for MediaCMS Filter
|
||||
*
|
||||
* @package filter_mediacms
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
require_once(__DIR__ . '/../../config.php');
|
||||
require_once($CFG->dirroot . '/mod/lti/lib.php');
|
||||
require_once($CFG->dirroot . '/mod/lti/locallib.php');
|
||||
require_once($CFG->dirroot . '/course/modlib.php');
|
||||
require_once(__DIR__ . '/locallib.php');
|
||||
|
||||
global $SITE, $DB, $PAGE, $OUTPUT, $CFG;
|
||||
|
||||
require_login();
|
||||
|
||||
$mediatoken = required_param('token', PARAM_ALPHANUMEXT);
|
||||
$courseid = optional_param('courseid', 0, PARAM_INT);
|
||||
$height = optional_param('height', 0, PARAM_INT);
|
||||
$width = optional_param('width', 0, PARAM_INT);
|
||||
|
||||
// Extract embed parameters
|
||||
$showTitle = optional_param('showTitle', '', PARAM_TEXT);
|
||||
$showRelated = optional_param('showRelated', '', PARAM_TEXT);
|
||||
$showUserAvatar = optional_param('showUserAvatar', '', PARAM_TEXT);
|
||||
$linkTitle = optional_param('linkTitle', '', PARAM_TEXT);
|
||||
$startTime = optional_param('t', '', PARAM_TEXT);
|
||||
$show_media_page = optional_param('show_media_page', '', PARAM_TEXT);
|
||||
|
||||
// Get configuration
|
||||
$mediacmsurl = get_config('filter_mediacms', 'mediacmsurl');
|
||||
$ltitoolid = get_config('filter_mediacms', 'ltitoolid');
|
||||
|
||||
// No default dimensions - use what's provided or nothing
|
||||
|
||||
if (empty($mediacmsurl)) {
|
||||
die('MediaCMS URL not configured');
|
||||
}
|
||||
|
||||
// Tool Selection Logic
|
||||
$type = false;
|
||||
if (!empty($ltitoolid)) {
|
||||
$type = $DB->get_record('lti_types', ['id' => $ltitoolid]);
|
||||
}
|
||||
|
||||
if (!$type) {
|
||||
die('LTI tool not found or not configured.');
|
||||
}
|
||||
|
||||
// Set up context
|
||||
if ($courseid && $courseid != SITEID) {
|
||||
$context = context_course::instance($courseid);
|
||||
$course = get_course($courseid);
|
||||
} else {
|
||||
$context = context_system::instance();
|
||||
$course = $SITE;
|
||||
}
|
||||
|
||||
// Get or create the dummy activity (visible, non-stealth).
|
||||
try {
|
||||
$dummy_cmid = filter_mediacms_get_dummy_activity($courseid, $type->id);
|
||||
} catch (Exception $e) {
|
||||
if (!has_capability('moodle/course:manageactivities', $context)) {
|
||||
die('No MediaCMS activity found. Please ask a teacher to add one first.');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$cm = get_coursemodule_from_id('lti', $dummy_cmid, 0, false, MUST_EXIST);
|
||||
$instance = $DB->get_record('lti', ['id' => $cm->instance], '*', MUST_EXIST);
|
||||
|
||||
$custom_params = ["media_friendly_token=" . $mediatoken];
|
||||
|
||||
// Add embed parameters if provided (check !== '' instead of !empty() because '0' is a valid value)
|
||||
if ($showTitle !== '') {
|
||||
$custom_params[] = "embed_show_title=" . $showTitle;
|
||||
}
|
||||
if ($showRelated !== '') {
|
||||
$custom_params[] = "embed_show_related=" . $showRelated;
|
||||
}
|
||||
if ($showUserAvatar !== '') {
|
||||
$custom_params[] = "embed_show_user_avatar=" . $showUserAvatar;
|
||||
}
|
||||
if ($linkTitle !== '') {
|
||||
$custom_params[] = "embed_link_title=" . $linkTitle;
|
||||
}
|
||||
if ($startTime !== '') {
|
||||
$custom_params[] = "embed_start_time=" . $startTime;
|
||||
}
|
||||
if ($show_media_page === 'true') {
|
||||
$custom_params[] = "show_media_page=true";
|
||||
}
|
||||
|
||||
$instance->instructorcustomparameters = implode("\n", $custom_params);
|
||||
$instance->name = 'MediaCMS Video: ' . $mediatoken;
|
||||
|
||||
// Set up page
|
||||
$page_params = [
|
||||
'token' => $mediatoken,
|
||||
'courseid' => $courseid,
|
||||
'width' => $width,
|
||||
'height' => $height
|
||||
];
|
||||
|
||||
// Add embed parameters to page URL if provided (check !== '' because '0' is valid)
|
||||
if ($showTitle !== '') {
|
||||
$page_params['showTitle'] = $showTitle;
|
||||
}
|
||||
if ($showRelated !== '') {
|
||||
$page_params['showRelated'] = $showRelated;
|
||||
}
|
||||
if ($showUserAvatar !== '') {
|
||||
$page_params['showUserAvatar'] = $showUserAvatar;
|
||||
}
|
||||
if ($linkTitle !== '') {
|
||||
$page_params['linkTitle'] = $linkTitle;
|
||||
}
|
||||
if ($startTime !== '') {
|
||||
$page_params['t'] = $startTime;
|
||||
}
|
||||
if ($show_media_page === 'true') {
|
||||
$page_params['show_media_page'] = 'true';
|
||||
}
|
||||
|
||||
$PAGE->set_url(new moodle_url('/filter/mediacms/launch.php', $page_params));
|
||||
$PAGE->set_context($context);
|
||||
$PAGE->set_pagelayout('embedded');
|
||||
$PAGE->set_title('MediaCMS');
|
||||
|
||||
// Get type config
|
||||
$typeconfig = lti_get_type_type_config($type->id);
|
||||
|
||||
// Initiate LTI Login with proper cmid (for permissions) and custom token
|
||||
$content = lti_initiate_login($course->id, $dummy_cmid, $instance, $typeconfig, null, $instance->name);
|
||||
|
||||
// CRITICAL: Inject media_token and embed parameters as hidden fields in OIDC form
|
||||
// MediaCMS will encode them in state and inject into custom claims (fallback mechanism)
|
||||
$hidden_fields = '<input type="hidden" name="media_token" value="' . htmlspecialchars($mediatoken, ENT_QUOTES) . '" />';
|
||||
|
||||
// Add embed parameters as hidden fields
|
||||
if ($showTitle !== '') {
|
||||
$hidden_fields .= '<input type="hidden" name="embed_show_title" value="' . htmlspecialchars($showTitle, ENT_QUOTES) . '" />';
|
||||
}
|
||||
if ($showRelated !== '') {
|
||||
$hidden_fields .= '<input type="hidden" name="embed_show_related" value="' . htmlspecialchars($showRelated, ENT_QUOTES) . '" />';
|
||||
}
|
||||
if ($showUserAvatar !== '') {
|
||||
$hidden_fields .= '<input type="hidden" name="embed_show_user_avatar" value="' . htmlspecialchars($showUserAvatar, ENT_QUOTES) . '" />';
|
||||
}
|
||||
if ($linkTitle !== '') {
|
||||
$hidden_fields .= '<input type="hidden" name="embed_link_title" value="' . htmlspecialchars($linkTitle, ENT_QUOTES) . '" />';
|
||||
}
|
||||
if ($startTime !== '') {
|
||||
$hidden_fields .= '<input type="hidden" name="embed_start_time" value="' . htmlspecialchars($startTime, ENT_QUOTES) . '" />';
|
||||
}
|
||||
if ($show_media_page === 'true') {
|
||||
$hidden_fields .= '<input type="hidden" name="show_media_page" value="true" />';
|
||||
}
|
||||
if ($width) {
|
||||
$hidden_fields .= '<input type="hidden" name="embed_width" value="' . htmlspecialchars($width, ENT_QUOTES) . '" />';
|
||||
}
|
||||
if ($height) {
|
||||
$hidden_fields .= '<input type="hidden" name="embed_height" value="' . htmlspecialchars($height, ENT_QUOTES) . '" />';
|
||||
}
|
||||
|
||||
$content = str_replace('</form>', $hidden_fields . '</form>', $content);
|
||||
|
||||
echo $OUTPUT->header();
|
||||
echo $content;
|
||||
echo $OUTPUT->footer();
|
||||
72
lms-plugins/mediacms-moodle/filter/mediacms/lib.php
Normal file
72
lms-plugins/mediacms-moodle/filter/mediacms/lib.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
/**
|
||||
* Navigation callbacks for filter_mediacms
|
||||
*
|
||||
* @package filter_mediacms
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Add My Media to the global / flat navigation (nav drawer).
|
||||
* Fires on every page when placement is set to 'top'.
|
||||
* In Moodle 4.x Boost the nav drawer is opened via the hamburger icon
|
||||
* and the node appears as a top-level item alongside Home / My courses.
|
||||
*/
|
||||
function filter_mediacms_extend_navigation(global_navigation $navigation): void {
|
||||
$placement = get_config('filter_mediacms', 'mymedia_placement');
|
||||
if ($placement !== 'top') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isloggedin() || isguestuser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = new moodle_url('/filter/mediacms/my_media.php');
|
||||
$node = navigation_node::create(
|
||||
get_string('mymedia', 'filter_mediacms'),
|
||||
$url,
|
||||
navigation_node::TYPE_CUSTOM,
|
||||
null,
|
||||
'mediacms_mymedia',
|
||||
new pix_icon('i/media', '')
|
||||
);
|
||||
// showinflatnavigation = true makes it visible in the Boost nav drawer.
|
||||
$node->showinflatnavigation = true;
|
||||
$navigation->add_node($node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add My Media to the user account / settings navigation.
|
||||
* Fires when placement is set to 'user'.
|
||||
* In Moodle 4.x Boost this section is reachable via avatar → Preferences.
|
||||
*/
|
||||
function filter_mediacms_extend_navigation_user_settings(
|
||||
navigation_node $navigation,
|
||||
stdClass $user,
|
||||
context_user $usercontext,
|
||||
stdClass $course,
|
||||
context_course $coursecontext
|
||||
): void {
|
||||
$placement = get_config('filter_mediacms', 'mymedia_placement');
|
||||
if ($placement !== 'user') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isloggedin() || isguestuser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = new moodle_url('/filter/mediacms/my_media.php');
|
||||
$navigation->add(
|
||||
get_string('mymedia', 'filter_mediacms'),
|
||||
$url,
|
||||
navigation_node::TYPE_SETTING,
|
||||
null,
|
||||
'mediacms_mymedia',
|
||||
new pix_icon('i/media', '')
|
||||
);
|
||||
}
|
||||
64
lms-plugins/mediacms-moodle/filter/mediacms/locallib.php
Normal file
64
lms-plugins/mediacms-moodle/filter/mediacms/locallib.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* Local helper functions for filter_mediacms
|
||||
*
|
||||
* @package filter_mediacms
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Find the first LTI activity for the MediaCMS tool in a course, or create a
|
||||
* visible dummy one if none exists. Repairs any existing stealth/hidden activity.
|
||||
*
|
||||
* @param int $courseid
|
||||
* @param int $typeid LTI tool type ID
|
||||
* @return int course-module ID
|
||||
*/
|
||||
function filter_mediacms_get_dummy_activity($courseid, $typeid) {
|
||||
global $DB;
|
||||
|
||||
$sql = "SELECT cm.id
|
||||
FROM {course_modules} cm
|
||||
JOIN {modules} m ON m.id = cm.module
|
||||
JOIN {lti} lti ON lti.id = cm.instance
|
||||
WHERE cm.course = :courseid
|
||||
AND m.name = 'lti'
|
||||
AND lti.typeid = :typeid
|
||||
AND cm.deletioninprogress = 0
|
||||
ORDER BY cm.visible DESC, cm.visibleoncoursepage DESC
|
||||
LIMIT 1";
|
||||
|
||||
$existing = $DB->get_record_sql($sql, ['courseid' => $courseid, 'typeid' => $typeid]);
|
||||
if ($existing) {
|
||||
return $existing->id;
|
||||
}
|
||||
|
||||
// Create the dummy activity then immediately force visibleoncoursepage=0,
|
||||
// because add_moduleinfo() always defaults it to 1.
|
||||
$moduleinfo = new stdClass();
|
||||
$moduleinfo->course = $courseid;
|
||||
$moduleinfo->module = $DB->get_field('modules', 'id', ['name' => 'lti']);
|
||||
$moduleinfo->modulename = 'lti';
|
||||
$moduleinfo->section = 0;
|
||||
$moduleinfo->visible = 1;
|
||||
$moduleinfo->visibleoncoursepage = 1;
|
||||
$moduleinfo->availability = null;
|
||||
$moduleinfo->showdescription = 0;
|
||||
$moduleinfo->name = 'My Media';
|
||||
$moduleinfo->intro = '';
|
||||
$moduleinfo->introformat = FORMAT_HTML;
|
||||
$moduleinfo->typeid = $typeid;
|
||||
$moduleinfo->instructorchoiceacceptgrades = 0;
|
||||
$moduleinfo->grade = 0;
|
||||
$moduleinfo->instructorchoicesendname = 1;
|
||||
$moduleinfo->instructorchoicesendemailaddr = 1;
|
||||
$moduleinfo->launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
|
||||
$moduleinfo->instructorcustomparameters = '';
|
||||
|
||||
$result = add_moduleinfo($moduleinfo, get_course($courseid));
|
||||
|
||||
return $result->coursemodule;
|
||||
}
|
||||
97
lms-plugins/mediacms-moodle/filter/mediacms/lti_launch.php
Normal file
97
lms-plugins/mediacms-moodle/filter/mediacms/lti_launch.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
/**
|
||||
* My Media LTI launch page — runs inside the iframe from my_media.php.
|
||||
*
|
||||
* Builds custom_publishdata (enrolled courses + roles) and initiates
|
||||
* the LTI 1.3 OIDC login flow, outputting the auto-submit form directly.
|
||||
*
|
||||
* @package filter_mediacms
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
require_once(__DIR__ . '/../../config.php');
|
||||
require_once($CFG->dirroot . '/mod/lti/lib.php');
|
||||
require_once($CFG->dirroot . '/mod/lti/locallib.php');
|
||||
require_once($CFG->dirroot . '/course/modlib.php');
|
||||
require_once(__DIR__ . '/locallib.php');
|
||||
|
||||
global $SITE, $DB, $CFG, $USER;
|
||||
|
||||
require_login();
|
||||
|
||||
$mediacmsurl = get_config('filter_mediacms', 'mediacmsurl');
|
||||
$ltitoolid = get_config('filter_mediacms', 'ltitoolid');
|
||||
|
||||
if (empty($mediacmsurl) || empty($ltitoolid)) {
|
||||
throw new moodle_exception('notconfigured', 'filter_mediacms');
|
||||
}
|
||||
|
||||
$type = $DB->get_record('lti_types', ['id' => $ltitoolid]);
|
||||
if (!$type) {
|
||||
throw new moodle_exception('ltitoolnotfound', 'filter_mediacms');
|
||||
}
|
||||
|
||||
// Build custom_publishdata: all courses the user is enrolled in + role.
|
||||
$enrolled_courses = enrol_get_users_courses($USER->id, true, ['id', 'shortname', 'fullname']);
|
||||
|
||||
$publish_data = [];
|
||||
foreach ($enrolled_courses as $enrolled_course) {
|
||||
if ((int)$enrolled_course->id === SITEID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$course_context = context_course::instance($enrolled_course->id);
|
||||
$roles = get_user_roles($course_context, $USER->id, false);
|
||||
|
||||
$role_shortname = 'student';
|
||||
if (!empty($roles)) {
|
||||
$role = reset($roles);
|
||||
$role_shortname = $role->shortname;
|
||||
}
|
||||
|
||||
$publish_data[] = [
|
||||
'id' => (int)$enrolled_course->id,
|
||||
'shortname' => $enrolled_course->shortname,
|
||||
'fullname' => $enrolled_course->fullname,
|
||||
'role' => $role_shortname,
|
||||
];
|
||||
}
|
||||
|
||||
$publishdata_b64 = base64_encode(json_encode($publish_data));
|
||||
|
||||
// Use a course the user is actually enrolled in so they have mod/lti:view during
|
||||
// the OIDC flow. Fall back to SITEID only for admins with no course enrolments.
|
||||
$launch_courseid = SITEID;
|
||||
$launch_course = $SITE;
|
||||
foreach ($enrolled_courses as $ec) {
|
||||
if ((int)$ec->id !== SITEID) {
|
||||
$launch_courseid = (int)$ec->id;
|
||||
$launch_course = get_course($launch_courseid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create the dummy activity (visible, non-stealth).
|
||||
try {
|
||||
$dummy_cmid = filter_mediacms_get_dummy_activity($launch_courseid, $type->id);
|
||||
} catch (Exception $e) {
|
||||
throw new moodle_exception('cannotcreatedummyactivity', 'filter_mediacms');
|
||||
}
|
||||
|
||||
$cm = get_coursemodule_from_id('lti', $dummy_cmid, 0, false, MUST_EXIST);
|
||||
$instance = $DB->get_record('lti', ['id' => $cm->instance], '*', MUST_EXIST);
|
||||
|
||||
// DEBUG: log enrolled courses retrieved.
|
||||
error_log('MediaCMS My Media publishdata courses (' . count($publish_data) . '): ' . json_encode($publish_data));
|
||||
|
||||
// Write publishdata to DB — Moodle's auth.php re-reads the instance from DB
|
||||
// when building the LTI launch JWT, so in-memory changes are ignored.
|
||||
$DB->set_field('lti', 'instructorcustomparameters', 'publishdata=' . $publishdata_b64, ['id' => $cm->instance]);
|
||||
$instance->instructorcustomparameters = 'publishdata=' . $publishdata_b64;
|
||||
$instance->name = 'MediaCMS My Media';
|
||||
|
||||
$typeconfig = lti_get_type_type_config($type->id);
|
||||
$content = lti_initiate_login($launch_course->id, $dummy_cmid, $instance, $typeconfig, null, $instance->name);
|
||||
|
||||
echo $content;
|
||||
36
lms-plugins/mediacms-moodle/filter/mediacms/my_media.php
Normal file
36
lms-plugins/mediacms-moodle/filter/mediacms/my_media.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* My Media page — renders the Moodle shell with an LTI iframe.
|
||||
*
|
||||
* @package filter_mediacms
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
require_once(__DIR__ . '/../../config.php');
|
||||
|
||||
global $SITE, $PAGE, $OUTPUT, $USER;
|
||||
|
||||
require_login();
|
||||
|
||||
$context = context_system::instance();
|
||||
|
||||
$PAGE->set_context($context);
|
||||
$PAGE->set_url(new moodle_url('/filter/mediacms/my_media.php'));
|
||||
$PAGE->set_course($SITE);
|
||||
$PAGE->set_pagelayout('mydashboard');
|
||||
$PAGE->set_title(get_string('mymedia', 'filter_mediacms'));
|
||||
$PAGE->set_heading(get_string('mymedia', 'filter_mediacms'));
|
||||
|
||||
echo $OUTPUT->header();
|
||||
|
||||
$attr = [
|
||||
'id' => 'contentframe',
|
||||
'src' => (new moodle_url('/filter/mediacms/lti_launch.php'))->out(false),
|
||||
'allowfullscreen' => 'true',
|
||||
'allow' => 'autoplay *; fullscreen *; encrypted-media *; camera *; microphone *;',
|
||||
'style' => 'border:none;display:block;width:100%;height:calc(100vh - 120px);',
|
||||
];
|
||||
echo html_writer::tag('iframe', '', $attr);
|
||||
|
||||
echo $OUTPUT->footer();
|
||||
54
lms-plugins/mediacms-moodle/filter/mediacms/settings.php
Normal file
54
lms-plugins/mediacms-moodle/filter/mediacms/settings.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
defined('MOODLE_INTERNAL') || die;
|
||||
|
||||
if ($ADMIN->fulltree) {
|
||||
// Info heading
|
||||
$settings->add(new admin_setting_heading(
|
||||
'filter_mediacms/coresettings',
|
||||
get_string('coresettings', 'filter_mediacms'),
|
||||
get_string('coresettings_desc', 'filter_mediacms')
|
||||
));
|
||||
|
||||
// MediaCMS URL
|
||||
$settings->add(new admin_setting_configtext(
|
||||
'filter_mediacms/mediacmsurl',
|
||||
get_string('mediacmsurl', 'filter_mediacms'),
|
||||
get_string('mediacmsurl_desc', 'filter_mediacms'),
|
||||
'https://lti.mediacms.io',
|
||||
PARAM_URL
|
||||
));
|
||||
|
||||
// LTI Tool Selector
|
||||
$ltioptions = [0 => get_string('noltitoolsfound', 'filter_mediacms')];
|
||||
try {
|
||||
$tools = $DB->get_records('lti_types', null, 'name ASC', 'id, name, baseurl');
|
||||
if (!empty($tools)) {
|
||||
$ltioptions = [0 => get_string('choose')];
|
||||
foreach ($tools as $tool) {
|
||||
$ltioptions[$tool->id] = $tool->name . ' (' . $tool->baseurl . ')';
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Database might not be ready during install
|
||||
}
|
||||
|
||||
$settings->add(new admin_setting_configselect(
|
||||
'filter_mediacms/ltitoolid',
|
||||
get_string('ltitoolid', 'filter_mediacms'),
|
||||
get_string('ltitoolid_desc', 'filter_mediacms'),
|
||||
0,
|
||||
$ltioptions
|
||||
));
|
||||
|
||||
// My Media link placement.
|
||||
$settings->add(new admin_setting_configselect(
|
||||
'filter_mediacms/mymedia_placement',
|
||||
get_string('mymedia_placement', 'filter_mediacms'),
|
||||
get_string('mymedia_placement_desc', 'filter_mediacms'),
|
||||
'top',
|
||||
[
|
||||
'top' => get_string('mymedia_placement_top', 'filter_mediacms'),
|
||||
'user' => get_string('mymedia_placement_user', 'filter_mediacms'),
|
||||
]
|
||||
));
|
||||
}
|
||||
8
lms-plugins/mediacms-moodle/filter/mediacms/version.php
Normal file
8
lms-plugins/mediacms-moodle/filter/mediacms/version.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$plugin->version = 2026021200; // 2026-02-12
|
||||
$plugin->requires = 2024100700; // Requires Moodle 4.5+
|
||||
$plugin->component = 'filter_mediacms';
|
||||
$plugin->maturity = MATURITY_STABLE;
|
||||
$plugin->release = 'v1.0.0';
|
||||
2457
lms-plugins/mediacms-moodle/package-lock.json
generated
Normal file
2457
lms-plugins/mediacms-moodle/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
89
lms-plugins/mediacms-moodle/package.json
Normal file
89
lms-plugins/mediacms-moodle/package.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "mediacms-moodle",
|
||||
"version": "1.0.0",
|
||||
"description": "This package provides the integration between MediaCMS and Moodle (versions 4.x and 5.x). It consists of two components that work together to provide a seamless video experience:",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.6",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/plugin-transform-modules-amd": "^7.27.1",
|
||||
"@babel/preset-env": "^7.29.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"anymatch": "^3.1.3",
|
||||
"babel-plugin-polyfill-corejs2": "^0.4.15",
|
||||
"babel-plugin-polyfill-corejs3": "^0.14.0",
|
||||
"babel-plugin-polyfill-regenerator": "^0.6.6",
|
||||
"balanced-match": "^1.0.2",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"binary-extensions": "^2.3.0",
|
||||
"brace-expansion": "^1.1.12",
|
||||
"braces": "^3.0.3",
|
||||
"browserslist": "^4.28.1",
|
||||
"caniuse-lite": "^1.0.30001766",
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^6.2.1",
|
||||
"concat-map": "^0.0.1",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"core-js-compat": "^3.48.0",
|
||||
"debug": "^4.4.3",
|
||||
"electron-to-chromium": "^1.5.283",
|
||||
"escalade": "^3.2.0",
|
||||
"esutils": "^2.0.3",
|
||||
"fill-range": "^7.1.1",
|
||||
"fs-readdir-recursive": "^1.1.0",
|
||||
"fs.realpath": "^1.0.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
"glob": "^7.2.3",
|
||||
"glob-parent": "^5.1.2",
|
||||
"hasown": "^2.0.2",
|
||||
"inflight": "^1.0.6",
|
||||
"inherits": "^2.0.4",
|
||||
"is-binary-path": "^2.1.0",
|
||||
"is-core-module": "^2.16.1",
|
||||
"is-extglob": "^2.1.1",
|
||||
"is-glob": "^4.0.3",
|
||||
"is-number": "^7.0.0",
|
||||
"js-tokens": "^4.0.0",
|
||||
"jsesc": "^3.1.0",
|
||||
"json5": "^2.2.3",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lru-cache": "^5.1.1",
|
||||
"make-dir": "^2.1.0",
|
||||
"minimatch": "^3.1.2",
|
||||
"ms": "^2.1.3",
|
||||
"node-releases": "^2.0.27",
|
||||
"normalize-path": "^3.0.0",
|
||||
"once": "^1.4.0",
|
||||
"path-is-absolute": "^1.0.1",
|
||||
"path-parse": "^1.0.7",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^2.3.1",
|
||||
"pify": "^4.0.1",
|
||||
"readdirp": "^3.6.0",
|
||||
"regenerate": "^1.4.2",
|
||||
"regenerate-unicode-properties": "^10.2.2",
|
||||
"regexpu-core": "^6.4.0",
|
||||
"regjsgen": "^0.8.0",
|
||||
"regjsparser": "^0.13.0",
|
||||
"resolve": "^1.22.11",
|
||||
"semver": "^6.3.1",
|
||||
"slash": "^2.0.0",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0",
|
||||
"to-regex-range": "^5.0.1",
|
||||
"unicode-canonical-property-names-ecmascript": "^2.0.1",
|
||||
"unicode-match-property-ecmascript": "^2.0.0",
|
||||
"unicode-match-property-value-ecmascript": "^2.2.1",
|
||||
"unicode-property-aliases-ecmascript": "^2.2.0",
|
||||
"update-browserslist-db": "^1.2.3",
|
||||
"wrappy": "^1.0.2",
|
||||
"yallist": "^3.1.1"
|
||||
}
|
||||
}
|
||||
206
lms-plugins/mediacms-moodle/tiny/mediacms/AUTOCONVERT.md
Executable file
206
lms-plugins/mediacms-moodle/tiny/mediacms/AUTOCONVERT.md
Executable file
@@ -0,0 +1,206 @@
|
||||
# MediaCMS URL Auto-Convert Feature
|
||||
|
||||
This feature automatically converts pasted MediaCMS video URLs into embedded video players within the TinyMCE editor.
|
||||
|
||||
## Overview
|
||||
|
||||
When a user pastes a MediaCMS video URL like:
|
||||
```
|
||||
https://deic.mediacms.io/view?m=JpBd1Zvdl
|
||||
```
|
||||
|
||||
It is automatically converted to an embedded video player:
|
||||
```html
|
||||
<div class="tiny-iframe-responsive" contenteditable="false">
|
||||
<iframe
|
||||
style="width: 100%; max-width: calc(100vh * 16 / 9); aspect-ratio: 16 / 9; display: block; margin: auto; border: 0;"
|
||||
src="https://deic.mediacms.io/embed?m=JpBd1Zvdl&showTitle=1&showRelated=1&showUserAvatar=1&linkTitle=1"
|
||||
allowfullscreen="allowfullscreen">
|
||||
</iframe>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Supported URL Formats
|
||||
|
||||
The auto-convert feature recognizes MediaCMS view URLs in this format:
|
||||
- `https://[domain]/view?m=[VIDEO_ID]`
|
||||
|
||||
Examples:
|
||||
- `https://deic.mediacms.io/view?m=JpBd1Zvdl`
|
||||
- `https://your-mediacms-instance.com/view?m=abc123`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Accessing Settings
|
||||
|
||||
1. Log in to Moodle as an administrator
|
||||
2. Navigate to: **Site administration** → **Plugins** → **Text editors** → **TinyMCE editor** → **MediaCMS**
|
||||
3. Scroll to the **Auto-convert MediaCMS URLs** section
|
||||
|
||||
### Available Settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| **Enable auto-convert** | Turn the auto-convert feature on or off | Enabled |
|
||||
| **MediaCMS base URL** | Restrict auto-conversion to a specific MediaCMS domain | Empty (allow all) |
|
||||
| **Show video title** | Display the video title in the embedded player | Enabled |
|
||||
| **Link video title** | Make the video title clickable, linking to the original video page | Enabled |
|
||||
| **Show related videos** | Display related videos after the current video ends | Enabled |
|
||||
| **Show user avatar** | Display the uploader's avatar in the embedded player | Enabled |
|
||||
|
||||
### Settings Location in Moodle
|
||||
|
||||
The settings are stored in the Moodle database under the `tiny_mediacms` plugin configuration:
|
||||
|
||||
- `tiny_mediacms/autoconvertenabled` - Enable/disable auto-convert
|
||||
- `tiny_mediacms/autoconvert_baseurl` - MediaCMS base URL (e.g., https://deic.mediacms.io)
|
||||
- `tiny_mediacms/autoconvert_showtitle` - Show title option
|
||||
- `tiny_mediacms/autoconvert_linktitle` - Link title option
|
||||
- `tiny_mediacms/autoconvert_showrelated` - Show related option
|
||||
- `tiny_mediacms/autoconvert_showuseravatar` - Show user avatar option
|
||||
|
||||
### Base URL Configuration
|
||||
|
||||
The **MediaCMS base URL** setting controls which MediaCMS instances are recognized for auto-conversion:
|
||||
|
||||
- **Empty (default)**: Any MediaCMS URL will be auto-converted (e.g., URLs from any `*/view?m=*` pattern)
|
||||
- **Specific URL**: Only URLs from the specified domain will be auto-converted
|
||||
|
||||
Example configurations:
|
||||
- `https://deic.mediacms.io` - Only convert URLs from deic.mediacms.io
|
||||
- `https://media.myuniversity.edu` - Only convert URLs from your institution's MediaCMS
|
||||
|
||||
## Technical Details
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
amd/src/
|
||||
├── autoconvert.js # Main auto-convert module
|
||||
├── plugin.js # Plugin initialization (imports autoconvert)
|
||||
└── options.js # Configuration options definition
|
||||
|
||||
classes/
|
||||
└── plugininfo.php # Passes PHP settings to JavaScript
|
||||
|
||||
settings.php # Admin settings page definition
|
||||
|
||||
lang/en/
|
||||
└── tiny_mediacms.php # Language strings for settings
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Paste Detection**: The `autoconvert.js` module listens for `paste` events on the TinyMCE editor
|
||||
2. **URL Validation**: When text is pasted, it checks if it matches the MediaCMS URL pattern
|
||||
3. **HTML Generation**: If valid, it generates the responsive iframe HTML with configured options
|
||||
4. **Content Insertion**: The original URL is replaced with the embedded video
|
||||
|
||||
### JavaScript Configuration
|
||||
|
||||
The settings are passed from PHP to JavaScript via the `plugininfo.php` class:
|
||||
|
||||
```php
|
||||
protected static function get_autoconvert_configuration(): array {
|
||||
$baseurl = get_config('tiny_mediacms', 'autoconvert_baseurl');
|
||||
|
||||
return [
|
||||
'data' => [
|
||||
'autoConvertEnabled' => (bool) get_config('tiny_mediacms', 'autoconvertenabled'),
|
||||
'autoConvertBaseUrl' => !empty($baseurl) ? $baseurl : '',
|
||||
'autoConvertOptions' => [
|
||||
'showTitle' => (bool) get_config('tiny_mediacms', 'autoconvert_showtitle'),
|
||||
'linkTitle' => (bool) get_config('tiny_mediacms', 'autoconvert_linktitle'),
|
||||
'showRelated' => (bool) get_config('tiny_mediacms', 'autoconvert_showrelated'),
|
||||
'showUserAvatar' => (bool) get_config('tiny_mediacms', 'autoconvert_showuseravatar'),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Default Values (in options.js)
|
||||
|
||||
If PHP settings are not configured, the JavaScript uses these defaults:
|
||||
|
||||
```javascript
|
||||
registerOption(dataName, {
|
||||
processor: 'object',
|
||||
"default": {
|
||||
autoConvertEnabled: true,
|
||||
autoConvertBaseUrl: '', // Empty = allow all MediaCMS domains
|
||||
autoConvertOptions: {
|
||||
showTitle: true,
|
||||
linkTitle: true,
|
||||
showRelated: true,
|
||||
showUserAvatar: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
### Disabling Auto-Convert
|
||||
|
||||
To disable the feature entirely:
|
||||
1. Go to the plugin settings (see "Accessing Settings" above)
|
||||
2. Uncheck **Enable auto-convert**
|
||||
3. Save changes
|
||||
|
||||
### Programmatic Configuration
|
||||
|
||||
You can also set these values directly in the database using Moodle's `set_config()` function:
|
||||
|
||||
```php
|
||||
// Disable auto-convert
|
||||
set_config('autoconvertenabled', 0, 'tiny_mediacms');
|
||||
|
||||
// Set the MediaCMS base URL (restrict to specific domain)
|
||||
set_config('autoconvert_baseurl', 'https://deic.mediacms.io', 'tiny_mediacms');
|
||||
|
||||
// Customize embed options
|
||||
set_config('autoconvert_showtitle', 1, 'tiny_mediacms');
|
||||
set_config('autoconvert_linktitle', 0, 'tiny_mediacms');
|
||||
set_config('autoconvert_showrelated', 0, 'tiny_mediacms');
|
||||
set_config('autoconvert_showuseravatar', 1, 'tiny_mediacms');
|
||||
```
|
||||
|
||||
### CLI Configuration
|
||||
|
||||
Using Moodle CLI:
|
||||
|
||||
```bash
|
||||
# Enable auto-convert
|
||||
php admin/cli/cfg.php --component=tiny_mediacms --name=autoconvertenabled --set=1
|
||||
|
||||
# Set the MediaCMS base URL
|
||||
php admin/cli/cfg.php --component=tiny_mediacms --name=autoconvert_baseurl --set=https://deic.mediacms.io
|
||||
|
||||
# Disable showing related videos
|
||||
php admin/cli/cfg.php --component=tiny_mediacms --name=autoconvert_showrelated --set=0
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Auto-convert not working
|
||||
|
||||
1. **Check if enabled**: Verify the setting is enabled in plugin settings
|
||||
2. **Clear caches**: Purge all caches (Site administration → Development → Purge all caches)
|
||||
3. **Check URL format**: Ensure the URL matches the pattern `https://[domain]/view?m=[VIDEO_ID]`
|
||||
4. **Browser console**: Check for JavaScript errors in the browser developer console
|
||||
|
||||
### Rebuilding JavaScript
|
||||
|
||||
If you modify the source files, rebuild using:
|
||||
|
||||
```bash
|
||||
cd /path/to/moodle
|
||||
npx grunt amd --root=public/lib/editor/tiny/plugins/mediacms
|
||||
```
|
||||
|
||||
Note: Requires Node.js 22.x or compatible version as specified in Moodle's requirements.
|
||||
|
||||
## Version History
|
||||
|
||||
- **1.0.0** - Initial implementation of auto-convert feature
|
||||
20
lms-plugins/mediacms-moodle/tiny/mediacms/README.md
Normal file
20
lms-plugins/mediacms-moodle/tiny/mediacms/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# TinyMCE MediaCMS Plugin for Moodle
|
||||
|
||||
A TinyMCE editor plugin for Moodle that provides media embedding capabilities with MediaCMS/LTI integration.
|
||||
|
||||
## Build Information
|
||||
|
||||
### Preparation
|
||||
1. Get and extract Moodle 5.1
|
||||
2. cp -r lms-plugins/mediacms-moodle/tiny/mediacms/ moodle/public/lib/editor/tiny/plugins/
|
||||
3. nvm use 22 && cd moodle/public && npm install
|
||||
|
||||
### Actual build
|
||||
4. cd lib/editor/tiny/plugins/mediacms && npx grunt amd
|
||||
|
||||
### Test the output
|
||||
5. To test the output:
|
||||
cp * ../../../../../../../lms-plugins/mediacms-moodle/tiny/mediacms/ -r
|
||||
|
||||
6. Then copy to Moodle server and purge caches
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user