mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-03-13 08:23:26 -04:00
Compare commits
247 Commits
feat-docke
...
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 | ||
|
|
223e87073f | ||
|
|
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 | ||
|
|
1c15880ae3 | ||
|
|
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 | ||
|
|
ed5cfa1a84 | ||
|
|
2fe48d8522 | ||
|
|
90331f3b4a | ||
|
|
c57f528ab1 | ||
|
|
fa67ffffb4 | ||
|
|
872571350f | ||
|
|
665971856b | ||
|
|
d9b1d6cab1 | ||
|
|
aeef8284bf | ||
|
|
a90fcbf8dd | ||
|
|
1b3cdfd302 | ||
|
|
cd7dd4f72c |
@@ -1,2 +1,69 @@
|
|||||||
node_modules
|
# Node.js/JavaScript dependencies and artifacts
|
||||||
npm-debug.log
|
**/node_modules
|
||||||
|
**/npm-debug.log*
|
||||||
|
**/yarn-debug.log*
|
||||||
|
**/yarn-error.log*
|
||||||
|
**/.yarn/cache
|
||||||
|
**/.yarn/unplugged
|
||||||
|
**/package-lock.json
|
||||||
|
**/.npm
|
||||||
|
**/.cache
|
||||||
|
**/.parcel-cache
|
||||||
|
**/dist
|
||||||
|
**/build
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Python bytecode and cache
|
||||||
|
**/__pycache__
|
||||||
|
**/*.py[cod]
|
||||||
|
**/*$py.class
|
||||||
|
**/*.so
|
||||||
|
**/.Python
|
||||||
|
**/pip-log.txt
|
||||||
|
**/pip-delete-this-directory.txt
|
||||||
|
**/.pytest_cache
|
||||||
|
**/.coverage
|
||||||
|
**/htmlcov
|
||||||
|
**/.tox
|
||||||
|
**/.mypy_cache
|
||||||
|
**/.ruff_cache
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.gitattributes
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
**/.DS_Store
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/*.swp
|
||||||
|
**/*.swo
|
||||||
|
**/*~
|
||||||
|
|
||||||
|
# Logs and runtime files
|
||||||
|
**/logs
|
||||||
|
**/*.log
|
||||||
|
**/celerybeat-schedule*
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
|
||||||
|
# Media files and data directories (should not be in image)
|
||||||
|
media_files/**
|
||||||
|
postgres_data/**
|
||||||
|
pids/**
|
||||||
|
|
||||||
|
# Static files collected at runtime
|
||||||
|
static_collected/**
|
||||||
|
|
||||||
|
# Documentation and development files
|
||||||
|
**/.github
|
||||||
|
**/CHANGELOG.md
|
||||||
|
|
||||||
|
# Test files and directories
|
||||||
|
**/tests
|
||||||
|
**/test_*.py
|
||||||
|
**/*_test.py
|
||||||
|
|
||||||
|
# Frontend build artifacts (built separately)
|
||||||
|
frontend/dist/**
|
||||||
|
|||||||
42
.github/workflows/frontend-build-and-test.yml
vendored
Normal file
42
.github/workflows/frontend-build-and-test.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Frontend build and test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.head_ref || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest]
|
||||||
|
node: [20]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
name: '${{ matrix.os }} - node v${{ matrix.node }}'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build script
|
||||||
|
run: npm run dist
|
||||||
|
|
||||||
|
- name: Test script
|
||||||
|
run: npm run test
|
||||||
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 }}
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -6,8 +6,9 @@ media_files/hls/
|
|||||||
media_files/chunks/
|
media_files/chunks/
|
||||||
media_files/uploads/
|
media_files/uploads/
|
||||||
media_files/tinymce_media/
|
media_files/tinymce_media/
|
||||||
|
media_files/userlogos/
|
||||||
postgres_data/
|
postgres_data/
|
||||||
celerybeat-schedule
|
celerybeat-schedule*
|
||||||
logs/
|
logs/
|
||||||
pids/
|
pids/
|
||||||
static/admin/
|
static/admin/
|
||||||
@@ -19,8 +20,8 @@ static/drf-yasg
|
|||||||
cms/local_settings.py
|
cms/local_settings.py
|
||||||
deploy/docker/local_settings.py
|
deploy/docker/local_settings.py
|
||||||
yt.readme.md
|
yt.readme.md
|
||||||
/frontend-tools/video-editor/node_modules
|
# Node.js dependencies (covers all node_modules directories, including frontend-tools)
|
||||||
/frontend-tools/video-editor/client/node_modules
|
**/node_modules/
|
||||||
/static_collected
|
/static_collected
|
||||||
/frontend-tools/video-editor-v1
|
/frontend-tools/video-editor-v1
|
||||||
frontend-tools/.DS_Store
|
frontend-tools/.DS_Store
|
||||||
@@ -35,3 +36,4 @@ frontend-tools/video-editor/client/public/videos/sample-video.mp3
|
|||||||
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
|
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
|
||||||
static/chapters_editor/videos/sample-video.mp3
|
static/chapters_editor/videos/sample-video.mp3
|
||||||
static/video_editor/videos/sample-video.mp3
|
static/video_editor/videos/sample-video.mp3
|
||||||
|
templates/todo-MS4.md
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
/templates/cms/*
|
/templates/cms/*
|
||||||
/templates/*.html
|
/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)
|
|
||||||
@@ -69,7 +69,7 @@ Copyright Markos Gogoulos.
|
|||||||
|
|
||||||
## Support and paid services
|
## Support and paid services
|
||||||
|
|
||||||
We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Contact us at info@mediacms.io for more information.
|
We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Checkout our [services page](https://mediacms.io/#services/) for more information.
|
||||||
|
|
||||||
### Commercial Hostings
|
### Commercial Hostings
|
||||||
**Elestio**
|
**Elestio**
|
||||||
@@ -108,7 +108,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
|
|||||||
|
|
||||||
## Technology
|
## 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
|
## Who is using it
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ INSTALLED_APPS = [
|
|||||||
"actions.apps.ActionsConfig",
|
"actions.apps.ActionsConfig",
|
||||||
"rbac.apps.RbacConfig",
|
"rbac.apps.RbacConfig",
|
||||||
"identity_providers.apps.IdentityProvidersConfig",
|
"identity_providers.apps.IdentityProvidersConfig",
|
||||||
|
"lti.apps.LtiConfig",
|
||||||
"debug_toolbar",
|
"debug_toolbar",
|
||||||
"mptt",
|
"mptt",
|
||||||
"crispy_forms",
|
"crispy_forms",
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ INSTALLED_APPS = [
|
|||||||
"actions.apps.ActionsConfig",
|
"actions.apps.ActionsConfig",
|
||||||
"rbac.apps.RbacConfig",
|
"rbac.apps.RbacConfig",
|
||||||
"identity_providers.apps.IdentityProvidersConfig",
|
"identity_providers.apps.IdentityProvidersConfig",
|
||||||
|
"lti.apps.LtiConfig",
|
||||||
"debug_toolbar",
|
"debug_toolbar",
|
||||||
"mptt",
|
"mptt",
|
||||||
"crispy_forms",
|
"crispy_forms",
|
||||||
@@ -555,6 +556,7 @@ DJANGO_ADMIN_URL = "admin/"
|
|||||||
USE_SAML = False
|
USE_SAML = False
|
||||||
USE_RBAC = False
|
USE_RBAC = False
|
||||||
USE_IDENTITY_PROVIDERS = False
|
USE_IDENTITY_PROVIDERS = False
|
||||||
|
USE_LTI = False # Enable LTI 1.3 integration
|
||||||
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
|
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
|
||||||
|
|
||||||
USE_ROUNDED_CORNERS = True
|
USE_ROUNDED_CORNERS = True
|
||||||
@@ -563,7 +565,8 @@ ALLOW_VIDEO_TRIMMER = True
|
|||||||
|
|
||||||
ALLOW_CUSTOM_MEDIA_URLS = False
|
ALLOW_CUSTOM_MEDIA_URLS = False
|
||||||
|
|
||||||
# Whether to allow anonymous users to list all users
|
ALLOW_MEDIA_REPLACEMENT = False
|
||||||
|
|
||||||
ALLOW_ANONYMOUS_USER_LISTING = True
|
ALLOW_ANONYMOUS_USER_LISTING = True
|
||||||
|
|
||||||
# Who can see the members page
|
# Who can see the members page
|
||||||
@@ -649,3 +652,18 @@ if USERS_NEEDS_TO_BE_APPROVED:
|
|||||||
)
|
)
|
||||||
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
||||||
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")
|
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("files.urls")),
|
||||||
re_path(r"^", include("users.urls")),
|
re_path(r"^", include("users.urls")),
|
||||||
re_path(r"^accounts/", include("allauth.urls")),
|
re_path(r"^accounts/", include("allauth.urls")),
|
||||||
|
re_path(r"^lti/", include("lti.urls")),
|
||||||
re_path(r"^api-auth/", include("rest_framework.urls")),
|
re_path(r"^api-auth/", include("rest_framework.urls")),
|
||||||
path(settings.DJANGO_ADMIN_URL, admin.site.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'),
|
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
VERSION = "7.2.1"
|
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 {
|
server {
|
||||||
listen 80 ;
|
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-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';
|
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||||
|
|
||||||
include /etc/nginx/sites-enabled/uwsgi_params;
|
proxy_pass http://127.0.0.1:9000;
|
||||||
uwsgi_pass 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-available/default
|
||||||
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-enabled/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/
|
cp deploy/docker/nginx.conf /etc/nginx/
|
||||||
|
|
||||||
#### Supervisord Configurations #####
|
#### 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
|
cp deploy/docker/supervisord/supervisord-debian.conf /etc/supervisor/conf.d/supervisord-debian.conf
|
||||||
|
|
||||||
if [ X"$ENABLE_UWSGI" = X"yes" ] ; then
|
if [ X"$ENABLE_UWSGI" = X"yes" ] ; then
|
||||||
echo "Enabling uwsgi app server"
|
echo "Enabling gunicorn app server"
|
||||||
cp deploy/docker/supervisord/supervisord-uwsgi.conf /etc/supervisor/conf.d/supervisord-uwsgi.conf
|
cp deploy/docker/supervisord/supervisord-gunicorn.conf /etc/supervisor/conf.d/supervisord-gunicorn.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ X"$ENABLE_NGINX" = X"yes" ] ; then
|
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
|
cp deploy/docker/supervisord/supervisord-nginx.conf /etc/supervisor/conf.d/supervisord-nginx.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ else
|
|||||||
echo "There is no script $PRE_START_PATH"
|
echo "There is no script $PRE_START_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start Supervisor, with Nginx and uWSGI
|
# Start Supervisor, with Nginx and Gunicorn
|
||||||
echo "Starting server using supervisord..."
|
echo "Starting server using supervisord..."
|
||||||
|
|
||||||
exec /usr/bin/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:
|
For Django, the changes from the image produced by docker-compose.yaml are these:
|
||||||
|
|
||||||
* Django runs in debug mode, with `python manage.py runserver`
|
* 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
|
* Django runs in Debug mode, with Debug Toolbar
|
||||||
* Static files (js/css) are loaded from static/ folder
|
* Static files (js/css) are loaded from static/ folder
|
||||||
* corsheaders is installed and configured to allow all origins
|
* corsheaders is installed and configured to allow all origins
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class CategoryAdminForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Category
|
model = Category
|
||||||
|
# LTI fields will be shown as read-only when USE_LTI is enabled
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
@@ -135,7 +136,7 @@ class CategoryAdmin(admin.ModelAdmin):
|
|||||||
list_display = ["title", "user", "add_date", "media_count"]
|
list_display = ["title", "user", "add_date", "media_count"]
|
||||||
list_filter = []
|
list_filter = []
|
||||||
ordering = ("-add_date",)
|
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'
|
change_form_template = 'admin/files/category/change_form.html'
|
||||||
|
|
||||||
def get_list_filter(self, request):
|
def get_list_filter(self, request):
|
||||||
@@ -145,6 +146,8 @@ class CategoryAdmin(admin.ModelAdmin):
|
|||||||
list_filter.insert(0, "is_rbac_category")
|
list_filter.insert(0, "is_rbac_category")
|
||||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||||
list_filter.insert(-1, "identity_provider")
|
list_filter.insert(-1, "identity_provider")
|
||||||
|
if getattr(settings, 'USE_LTI', False):
|
||||||
|
list_filter.append("is_lms_course")
|
||||||
|
|
||||||
return list_filter
|
return list_filter
|
||||||
|
|
||||||
@@ -154,6 +157,8 @@ class CategoryAdmin(admin.ModelAdmin):
|
|||||||
list_display.insert(-1, "is_rbac_category")
|
list_display.insert(-1, "is_rbac_category")
|
||||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||||
list_display.insert(-1, "identity_provider")
|
list_display.insert(-1, "identity_provider")
|
||||||
|
if getattr(settings, 'USE_LTI', False):
|
||||||
|
list_display.insert(-1, "is_lms_course")
|
||||||
|
|
||||||
return list_display
|
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):
|
if getattr(settings, 'USE_RBAC', False):
|
||||||
rbac_fieldset = [
|
rbac_fieldset = [
|
||||||
('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
|
('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'}),
|
('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'}),
|
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
|
||||||
]
|
]
|
||||||
return basic_fieldset + rbac_fieldset
|
additional_fieldsets.extend(rbac_fieldset)
|
||||||
else:
|
|
||||||
return basic_fieldset
|
return basic_fieldset + additional_fieldsets
|
||||||
|
|
||||||
|
|
||||||
class TagAdmin(admin.ModelAdmin):
|
class TagAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -58,9 +58,16 @@ def stuff(request):
|
|||||||
ret["USE_RBAC"] = settings.USE_RBAC
|
ret["USE_RBAC"] = settings.USE_RBAC
|
||||||
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
|
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
|
||||||
ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS
|
ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS
|
||||||
|
ret["ALLOW_MEDIA_REPLACEMENT"] = getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False)
|
||||||
ret["VERSION"] = VERSION
|
ret["VERSION"] = VERSION
|
||||||
|
|
||||||
if request.user.is_superuser:
|
if request.user.is_superuser:
|
||||||
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL
|
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
|
return ret
|
||||||
|
|||||||
119
files/forms.py
119
files/forms.py
@@ -6,6 +6,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
from .methods import get_next_state, is_mediacms_editor
|
from .methods import get_next_state, is_mediacms_editor
|
||||||
from .models import MEDIA_STATES, Category, Media, Subtitle
|
from .models import MEDIA_STATES, Category, Media, Subtitle
|
||||||
|
from .widgets import CategoryModalWidget
|
||||||
|
|
||||||
|
|
||||||
class CustomField(Field):
|
class CustomField(Field):
|
||||||
@@ -121,13 +122,19 @@ class MediaPublishForm(forms.ModelForm):
|
|||||||
fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download")
|
fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download")
|
||||||
|
|
||||||
widgets = {
|
widgets = {
|
||||||
"category": MultipleSelect(),
|
"category": CategoryModalWidget(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, user, *args, **kwargs):
|
def __init__(self, user, *args, **kwargs):
|
||||||
self.user = user
|
self.user = user
|
||||||
|
self.request = kwargs.pop('request', None)
|
||||||
super(MediaPublishForm, self).__init__(*args, **kwargs)
|
super(MediaPublishForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.has_custom_permissions = self.instance.permissions.exists() if self.instance.pk else False
|
||||||
|
self.has_rbac_categories = self.instance.category.filter(is_rbac_category=True).exists() if self.instance.pk else False
|
||||||
|
self.is_shared = self.has_custom_permissions or self.has_rbac_categories
|
||||||
|
self.actual_state = self.instance.state if self.instance.pk else None
|
||||||
|
|
||||||
if not is_mediacms_editor(user):
|
if not is_mediacms_editor(user):
|
||||||
for field in ["featured", "reported_times", "is_reviewed"]:
|
for field in ["featured", "reported_times", "is_reviewed"]:
|
||||||
self.fields[field].disabled = True
|
self.fields[field].disabled = True
|
||||||
@@ -140,6 +147,13 @@ class MediaPublishForm(forms.ModelForm):
|
|||||||
valid_states.append(self.instance.state)
|
valid_states.append(self.instance.state)
|
||||||
self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states]
|
self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states]
|
||||||
|
|
||||||
|
if self.is_shared:
|
||||||
|
current_choices = list(self.fields["state"].choices)
|
||||||
|
current_choices.insert(0, ("shared", "Shared"))
|
||||||
|
self.fields["state"].choices = current_choices
|
||||||
|
self.fields["state"].initial = "shared"
|
||||||
|
self.initial["state"] = "shared"
|
||||||
|
|
||||||
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
|
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
|
||||||
if is_mediacms_editor(user):
|
if is_mediacms_editor(user):
|
||||||
pass
|
pass
|
||||||
@@ -156,6 +170,16 @@ class MediaPublishForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title')
|
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 = FormHelper()
|
||||||
self.helper.form_tag = True
|
self.helper.form_tag = True
|
||||||
self.helper.form_class = 'post-form'
|
self.helper.form_class = 'post-form'
|
||||||
@@ -173,12 +197,56 @@ class MediaPublishForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.helper.layout.append(FormActions(Submit('submit', 'Publish Media', css_class='primaryAction')))
|
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):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
state = cleaned_data.get("state")
|
state = cleaned_data.get("state")
|
||||||
categories = cleaned_data.get("category")
|
categories = cleaned_data.get("category")
|
||||||
|
|
||||||
if state in ['private', 'unlisted']:
|
if self.is_shared and state != "shared":
|
||||||
|
self.fields['confirm_state'].widget = forms.CheckboxInput()
|
||||||
|
state_index = None
|
||||||
|
for i, layout_item in enumerate(self.helper.layout):
|
||||||
|
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
|
||||||
|
state_index = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if state_index is not None:
|
||||||
|
layout_items = list(self.helper.layout)
|
||||||
|
layout_items.insert(state_index + 1, CustomField('confirm_state'))
|
||||||
|
self.helper.layout = Layout(*layout_items)
|
||||||
|
|
||||||
|
if not cleaned_data.get('confirm_state'):
|
||||||
|
if state == 'private':
|
||||||
|
error_parts = []
|
||||||
|
if self.has_rbac_categories:
|
||||||
|
rbac_cat_titles = self.instance.category.filter(is_rbac_category=True).values_list('title', flat=True)
|
||||||
|
error_parts.append(f"shared with users that have access to categories: {', '.join(rbac_cat_titles)}")
|
||||||
|
if self.has_custom_permissions:
|
||||||
|
error_parts.append("shared by me with other users (visible in 'Shared by me' page)")
|
||||||
|
|
||||||
|
error_message = f"I understand that changing to Private will remove all sharing. Currently this media is {' and '.join(error_parts)}. All this sharing will be removed."
|
||||||
|
self.add_error('confirm_state', error_message)
|
||||||
|
else:
|
||||||
|
error_message = f"I understand that changing to {state.title()} will maintain existing sharing settings."
|
||||||
|
self.add_error('confirm_state', error_message)
|
||||||
|
|
||||||
|
elif state in ['private', 'unlisted']:
|
||||||
custom_permissions = self.instance.permissions.exists()
|
custom_permissions = self.instance.permissions.exists()
|
||||||
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
|
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
|
||||||
if rbac_categories or custom_permissions:
|
if rbac_categories or custom_permissions:
|
||||||
@@ -189,7 +257,7 @@ class MediaPublishForm(forms.ModelForm):
|
|||||||
state_index = i
|
state_index = i
|
||||||
break
|
break
|
||||||
|
|
||||||
if state_index:
|
if state_index is not None:
|
||||||
layout_items = list(self.helper.layout)
|
layout_items = list(self.helper.layout)
|
||||||
layout_items.insert(state_index + 1, CustomField('confirm_state'))
|
layout_items.insert(state_index + 1, CustomField('confirm_state'))
|
||||||
self.helper.layout = Layout(*layout_items)
|
self.helper.layout = Layout(*layout_items)
|
||||||
@@ -202,11 +270,24 @@ class MediaPublishForm(forms.ModelForm):
|
|||||||
error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page"
|
error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page"
|
||||||
self.add_error('confirm_state', error_message)
|
self.add_error('confirm_state', error_message)
|
||||||
|
|
||||||
|
# Convert "shared" state to actual underlying state for saving. we dont keep shared state in DB
|
||||||
|
if state == "shared":
|
||||||
|
cleaned_data["state"] = self.actual_state
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
data = self.cleaned_data
|
data = self.cleaned_data
|
||||||
state = data.get("state")
|
state = data.get("state")
|
||||||
|
|
||||||
|
# If transitioning from shared to private, remove all sharing
|
||||||
|
if self.is_shared and state == 'private' and data.get('confirm_state'):
|
||||||
|
# Remove all custom permissions
|
||||||
|
self.instance.permissions.all().delete()
|
||||||
|
# Remove RBAC categories
|
||||||
|
rbac_cats = self.instance.category.filter(is_rbac_category=True)
|
||||||
|
self.instance.category.remove(*rbac_cats)
|
||||||
|
|
||||||
if state != self.initial["state"]:
|
if state != self.initial["state"]:
|
||||||
self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state)
|
self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state)
|
||||||
|
|
||||||
@@ -333,3 +414,35 @@ class ContactForm(forms.Form):
|
|||||||
if user.is_authenticated:
|
if user.is_authenticated:
|
||||||
self.fields.pop("name")
|
self.fields.pop("name")
|
||||||
self.fields.pop("from_email")
|
self.fields.pop("from_email")
|
||||||
|
|
||||||
|
|
||||||
|
class ReplaceMediaForm(forms.Form):
|
||||||
|
new_media_file = forms.FileField(
|
||||||
|
required=True,
|
||||||
|
label="New Media File",
|
||||||
|
help_text="Select a new file to replace the current media",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, media_instance, *args, **kwargs):
|
||||||
|
self.media_instance = media_instance
|
||||||
|
super(ReplaceMediaForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = True
|
||||||
|
self.helper.form_class = 'post-form'
|
||||||
|
self.helper.form_method = 'post'
|
||||||
|
self.helper.form_enctype = "multipart/form-data"
|
||||||
|
self.helper.form_show_errors = False
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
CustomField('new_media_file'),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.helper.layout.append(FormActions(Submit('submit', 'Replace Media', css_class='primaryAction')))
|
||||||
|
|
||||||
|
def clean_new_media_file(self):
|
||||||
|
file = self.cleaned_data.get("new_media_file", False)
|
||||||
|
if file:
|
||||||
|
if file.size > settings.UPLOAD_MAX_SIZE:
|
||||||
|
max_size_mb = settings.UPLOAD_MAX_SIZE / (1024 * 1024)
|
||||||
|
raise forms.ValidationError(f"File too large. Maximum size: {max_size_mb:.0f}MB")
|
||||||
|
return file
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "إزالة من القائمة",
|
"Remove from list": "إزالة من القائمة",
|
||||||
"Remove tag": "إزالة العلامة",
|
"Remove tag": "إزالة العلامة",
|
||||||
"Remove user": "إزالة المستخدم",
|
"Remove user": "إزالة المستخدم",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "حفظ",
|
"SAVE": "حفظ",
|
||||||
"SEARCH": "بحث",
|
"SEARCH": "بحث",
|
||||||
"SHARE": "مشاركة",
|
"SHARE": "مشاركة",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "",
|
"Remove from list": "",
|
||||||
"Remove tag": "",
|
"Remove tag": "",
|
||||||
"Remove user": "",
|
"Remove user": "",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "সংরক্ষণ করুন",
|
"SAVE": "সংরক্ষণ করুন",
|
||||||
"SEARCH": "অনুসন্ধান",
|
"SEARCH": "অনুসন্ধান",
|
||||||
"SHARE": "শেয়ার করুন",
|
"SHARE": "শেয়ার করুন",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Fjern fra liste",
|
"Remove from list": "Fjern fra liste",
|
||||||
"Remove tag": "Fjern tag",
|
"Remove tag": "Fjern tag",
|
||||||
"Remove user": "Fjern bruger",
|
"Remove user": "Fjern bruger",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "GEM",
|
"SAVE": "GEM",
|
||||||
"SEARCH": "SØG",
|
"SEARCH": "SØG",
|
||||||
"SHARE": "DEL",
|
"SHARE": "DEL",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Aus Liste entfernen",
|
"Remove from list": "Aus Liste entfernen",
|
||||||
"Remove tag": "Tag entfernen",
|
"Remove tag": "Tag entfernen",
|
||||||
"Remove user": "Benutzer entfernen",
|
"Remove user": "Benutzer entfernen",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "SPEICHERN",
|
"SAVE": "SPEICHERN",
|
||||||
"SEARCH": "SUCHE",
|
"SEARCH": "SUCHE",
|
||||||
"SHARE": "TEILEN",
|
"SHARE": "TEILEN",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Αφαίρεση από λίστα",
|
"Remove from list": "Αφαίρεση από λίστα",
|
||||||
"Remove tag": "Αφαίρεση ετικέτας",
|
"Remove tag": "Αφαίρεση ετικέτας",
|
||||||
"Remove user": "Αφαίρεση χρήστη",
|
"Remove user": "Αφαίρεση χρήστη",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "ΑΠΟΘΗΚΕΥΣΗ",
|
"SAVE": "ΑΠΟΘΗΚΕΥΣΗ",
|
||||||
"SEARCH": "ΑΝΑΖΗΤΗΣΗ",
|
"SEARCH": "ΑΝΑΖΗΤΗΣΗ",
|
||||||
"SHARE": "ΚΟΙΝΟΠΟΙΗΣΗ",
|
"SHARE": "ΚΟΙΝΟΠΟΙΗΣΗ",
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ translation_strings = {
|
|||||||
"Recommended": "",
|
"Recommended": "",
|
||||||
"Record Screen": "",
|
"Record Screen": "",
|
||||||
"Register": "",
|
"Register": "",
|
||||||
|
"Replace": "",
|
||||||
"Remove category": "",
|
"Remove category": "",
|
||||||
"Remove from list": "",
|
"Remove from list": "",
|
||||||
"Remove tag": "",
|
"Remove tag": "",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Eliminar de la lista",
|
"Remove from list": "Eliminar de la lista",
|
||||||
"Remove tag": "Eliminar etiqueta",
|
"Remove tag": "Eliminar etiqueta",
|
||||||
"Remove user": "Eliminar usuario",
|
"Remove user": "Eliminar usuario",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "GUARDAR",
|
"SAVE": "GUARDAR",
|
||||||
"SEARCH": "BUSCAR",
|
"SEARCH": "BUSCAR",
|
||||||
"SHARE": "COMPARTIR",
|
"SHARE": "COMPARTIR",
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Supprimer de la liste",
|
"Remove from list": "Supprimer de la liste",
|
||||||
"Remove tag": "Supprimer le tag",
|
"Remove tag": "Supprimer le tag",
|
||||||
"Remove user": "Supprimer l'utilisateur",
|
"Remove user": "Supprimer l'utilisateur",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "ENREGISTRER",
|
"SAVE": "ENREGISTRER",
|
||||||
"SEARCH": "RECHERCHER",
|
"SEARCH": "RECHERCHER",
|
||||||
"SHARE": "PARTAGER",
|
"SHARE": "PARTAGER",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "",
|
"Remove from list": "",
|
||||||
"Remove tag": "",
|
"Remove tag": "",
|
||||||
"Remove user": "",
|
"Remove user": "",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "שמור",
|
"SAVE": "שמור",
|
||||||
"SEARCH": "חפש",
|
"SEARCH": "חפש",
|
||||||
"SHARE": "שתף",
|
"SHARE": "שתף",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "सूची से हटाएं",
|
"Remove from list": "सूची से हटाएं",
|
||||||
"Remove tag": "टैग हटाएं",
|
"Remove tag": "टैग हटाएं",
|
||||||
"Remove user": "उपयोगकर्ता हटाएं",
|
"Remove user": "उपयोगकर्ता हटाएं",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "सहेजें",
|
"SAVE": "सहेजें",
|
||||||
"SEARCH": "खोजें",
|
"SEARCH": "खोजें",
|
||||||
"SHARE": "साझा करें",
|
"SHARE": "साझा करें",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Hapus dari daftar",
|
"Remove from list": "Hapus dari daftar",
|
||||||
"Remove tag": "Hapus tag",
|
"Remove tag": "Hapus tag",
|
||||||
"Remove user": "Hapus pengguna",
|
"Remove user": "Hapus pengguna",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "SIMPAN",
|
"SAVE": "SIMPAN",
|
||||||
"SEARCH": "CARI",
|
"SEARCH": "CARI",
|
||||||
"SHARE": "BAGIKAN",
|
"SHARE": "BAGIKAN",
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Rimuovi dalla lista",
|
"Remove from list": "Rimuovi dalla lista",
|
||||||
"Remove tag": "Rimuovi tag",
|
"Remove tag": "Rimuovi tag",
|
||||||
"Remove user": "Rimuovi utente",
|
"Remove user": "Rimuovi utente",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "SALVA",
|
"SAVE": "SALVA",
|
||||||
"SEARCH": "CERCA",
|
"SEARCH": "CERCA",
|
||||||
"SHARE": "CONDIVIDI",
|
"SHARE": "CONDIVIDI",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "リストから削除",
|
"Remove from list": "リストから削除",
|
||||||
"Remove tag": "タグを削除",
|
"Remove tag": "タグを削除",
|
||||||
"Remove user": "ユーザーを削除",
|
"Remove user": "ユーザーを削除",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "保存",
|
"SAVE": "保存",
|
||||||
"SEARCH": "検索",
|
"SEARCH": "検索",
|
||||||
"SHARE": "共有",
|
"SHARE": "共有",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "목록에서 제거",
|
"Remove from list": "목록에서 제거",
|
||||||
"Remove tag": "태그 제거",
|
"Remove tag": "태그 제거",
|
||||||
"Remove user": "사용자 제거",
|
"Remove user": "사용자 제거",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "저장",
|
"SAVE": "저장",
|
||||||
"SEARCH": "검색",
|
"SEARCH": "검색",
|
||||||
"SHARE": "공유",
|
"SHARE": "공유",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Verwijderen uit lijst",
|
"Remove from list": "Verwijderen uit lijst",
|
||||||
"Remove tag": "Tag verwijderen",
|
"Remove tag": "Tag verwijderen",
|
||||||
"Remove user": "Gebruiker verwijderen",
|
"Remove user": "Gebruiker verwijderen",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "OPSLAAN",
|
"SAVE": "OPSLAAN",
|
||||||
"SEARCH": "ZOEKEN",
|
"SEARCH": "ZOEKEN",
|
||||||
"SHARE": "DELEN",
|
"SHARE": "DELEN",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Remover da lista",
|
"Remove from list": "Remover da lista",
|
||||||
"Remove tag": "Remover tag",
|
"Remove tag": "Remover tag",
|
||||||
"Remove user": "Remover usuário",
|
"Remove user": "Remover usuário",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "SALVAR",
|
"SAVE": "SALVAR",
|
||||||
"SEARCH": "PESQUISAR",
|
"SEARCH": "PESQUISAR",
|
||||||
"SHARE": "COMPARTILHAR",
|
"SHARE": "COMPARTILHAR",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Удалить из списка",
|
"Remove from list": "Удалить из списка",
|
||||||
"Remove tag": "Удалить тег",
|
"Remove tag": "Удалить тег",
|
||||||
"Remove user": "Удалить пользователя",
|
"Remove user": "Удалить пользователя",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "СОХРАНИТЬ",
|
"SAVE": "СОХРАНИТЬ",
|
||||||
"SEARCH": "ПОИСК",
|
"SEARCH": "ПОИСК",
|
||||||
"SHARE": "ПОДЕЛИТЬСЯ",
|
"SHARE": "ПОДЕЛИТЬСЯ",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Odstrani s seznama",
|
"Remove from list": "Odstrani s seznama",
|
||||||
"Remove tag": "Odstrani oznako",
|
"Remove tag": "Odstrani oznako",
|
||||||
"Remove user": "Odstrani uporabnika",
|
"Remove user": "Odstrani uporabnika",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "SHRANI",
|
"SAVE": "SHRANI",
|
||||||
"SEARCH": "ISKANJE",
|
"SEARCH": "ISKANJE",
|
||||||
"SHARE": "DELI",
|
"SHARE": "DELI",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "Listeden kaldır",
|
"Remove from list": "Listeden kaldır",
|
||||||
"Remove tag": "Etiketi kaldır",
|
"Remove tag": "Etiketi kaldır",
|
||||||
"Remove user": "Kullanıcıyı kaldır",
|
"Remove user": "Kullanıcıyı kaldır",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "KAYDET",
|
"SAVE": "KAYDET",
|
||||||
"SEARCH": "ARA",
|
"SEARCH": "ARA",
|
||||||
"SHARE": "PAYLAŞ",
|
"SHARE": "PAYLAŞ",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "فہرست سے ہٹائیں",
|
"Remove from list": "فہرست سے ہٹائیں",
|
||||||
"Remove tag": "ٹیگ ہٹائیں",
|
"Remove tag": "ٹیگ ہٹائیں",
|
||||||
"Remove user": "صارف ہٹائیں",
|
"Remove user": "صارف ہٹائیں",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "محفوظ کریں",
|
"SAVE": "محفوظ کریں",
|
||||||
"SEARCH": "تلاش کریں",
|
"SEARCH": "تلاش کریں",
|
||||||
"SHARE": "شیئر کریں",
|
"SHARE": "شیئر کریں",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "",
|
"Remove from list": "",
|
||||||
"Remove tag": "",
|
"Remove tag": "",
|
||||||
"Remove user": "",
|
"Remove user": "",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "保存",
|
"SAVE": "保存",
|
||||||
"SEARCH": "搜索",
|
"SEARCH": "搜索",
|
||||||
"SHARE": "分享",
|
"SHARE": "分享",
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ translation_strings = {
|
|||||||
"Remove from list": "",
|
"Remove from list": "",
|
||||||
"Remove tag": "",
|
"Remove tag": "",
|
||||||
"Remove user": "",
|
"Remove user": "",
|
||||||
|
"Replace": "",
|
||||||
"SAVE": "儲存",
|
"SAVE": "儲存",
|
||||||
"SEARCH": "搜尋",
|
"SEARCH": "搜尋",
|
||||||
"SHARE": "分享",
|
"SHARE": "分享",
|
||||||
|
|||||||
@@ -965,3 +965,13 @@ def get_alphanumeric_only(string):
|
|||||||
"""
|
"""
|
||||||
string = "".join([char for char in string if char.isalnum()])
|
string = "".join([char for char in string if char.isalnum()])
|
||||||
return string.lower()
|
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
|
||||||
|
|||||||
24
files/migrations/0014_alter_subtitle_options_and_more.py
Normal file
24
files/migrations/0014_alter_subtitle_options_and_more.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-12-16 14:05
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('files', '0013_page_tinymcemedia'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='subtitle',
|
||||||
|
options={'ordering': ['language__title'], 'verbose_name': 'Caption', 'verbose_name_plural': 'Captions'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='transcriptionrequest',
|
||||||
|
options={'verbose_name': 'Caption Request', 'verbose_name_plural': 'Caption Requests'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='videotrimrequest',
|
||||||
|
options={'verbose_name': 'Trim Request', 'verbose_name_plural': 'Trim Requests'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
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):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
@@ -137,7 +144,7 @@ class Tag(models.Model):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
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]
|
self.title = self.title[:100]
|
||||||
super(Tag, self).save(*args, **kwargs)
|
super(Tag, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,9 @@ class Media(models.Model):
|
|||||||
if self.media_file != self.__original_media_file:
|
if self.media_file != self.__original_media_file:
|
||||||
# set this otherwise gets to infinite loop
|
# set this otherwise gets to infinite loop
|
||||||
self.__original_media_file = self.media_file
|
self.__original_media_file = self.media_file
|
||||||
self.media_init()
|
from .. import tasks
|
||||||
|
|
||||||
|
tasks.media_init.apply_async(args=[self.friendly_token], countdown=5)
|
||||||
|
|
||||||
# for video files, if user specified a different time
|
# for video files, if user specified a different time
|
||||||
# to automatically grub thumbnail
|
# to automatically grub thumbnail
|
||||||
@@ -329,10 +331,17 @@ class Media(models.Model):
|
|||||||
|
|
||||||
if to_transcribe:
|
if to_transcribe:
|
||||||
TranscriptionRequest.objects.create(media=self, translate_to_english=False)
|
TranscriptionRequest.objects.create(media=self, translate_to_english=False)
|
||||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=False)
|
tasks.whisper_transcribe.apply_async(
|
||||||
|
args=[self.friendly_token, False],
|
||||||
|
countdown=10,
|
||||||
|
)
|
||||||
|
|
||||||
if to_transcribe_and_translate:
|
if to_transcribe_and_translate:
|
||||||
TranscriptionRequest.objects.create(media=self, translate_to_english=True)
|
TranscriptionRequest.objects.create(media=self, translate_to_english=True)
|
||||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=True)
|
tasks.whisper_transcribe.apply_async(
|
||||||
|
args=[self.friendly_token, True],
|
||||||
|
countdown=10,
|
||||||
|
)
|
||||||
|
|
||||||
def update_search_vector(self):
|
def update_search_vector(self):
|
||||||
"""
|
"""
|
||||||
@@ -343,20 +352,11 @@ class Media(models.Model):
|
|||||||
# first get anything interesting out of the media
|
# first get anything interesting out of the media
|
||||||
# that needs to be search able
|
# that needs to be search able
|
||||||
|
|
||||||
a_tags = b_tags = ""
|
a_tags = ""
|
||||||
if self.id:
|
if self.id:
|
||||||
a_tags = " ".join([tag.title for tag in self.tags.all()])
|
a_tags = " ".join([tag.title for tag in self.tags.all()])
|
||||||
b_tags = " ".join([tag.title.replace("-", " ") for tag in self.tags.all()])
|
|
||||||
|
|
||||||
items = [
|
items = [self.friendly_token, self.title, self.user.username, self.user.email, self.user.name, self.description, a_tags]
|
||||||
self.title,
|
|
||||||
self.user.username,
|
|
||||||
self.user.email,
|
|
||||||
self.user.name,
|
|
||||||
self.description,
|
|
||||||
a_tags,
|
|
||||||
b_tags,
|
|
||||||
]
|
|
||||||
|
|
||||||
for subtitle in self.subtitles.all():
|
for subtitle in self.subtitles.all():
|
||||||
items.append(subtitle.subtitle_text)
|
items.append(subtitle.subtitle_text)
|
||||||
@@ -410,6 +410,11 @@ class Media(models.Model):
|
|||||||
self.media_type = "image"
|
self.media_type = "image"
|
||||||
elif kind == "pdf":
|
elif kind == "pdf":
|
||||||
self.media_type = "pdf"
|
self.media_type = "pdf"
|
||||||
|
elif kind == "audio":
|
||||||
|
self.media_type = "audio"
|
||||||
|
elif kind == "video":
|
||||||
|
self.media_type = "video"
|
||||||
|
|
||||||
if self.media_type in ["image", "pdf"]:
|
if self.media_type in ["image", "pdf"]:
|
||||||
self.encoding_status = "success"
|
self.encoding_status = "success"
|
||||||
else:
|
else:
|
||||||
@@ -731,7 +736,7 @@ class Media(models.Model):
|
|||||||
|
|
||||||
ret = []
|
ret = []
|
||||||
for cat in self.category.all():
|
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
|
return ret
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -101,10 +101,17 @@ class MediaSerializer(serializers.ModelSerializer):
|
|||||||
class SingleMediaSerializer(serializers.ModelSerializer):
|
class SingleMediaSerializer(serializers.ModelSerializer):
|
||||||
user = serializers.ReadOnlyField(source="user.username")
|
user = serializers.ReadOnlyField(source="user.username")
|
||||||
url = serializers.SerializerMethodField()
|
url = serializers.SerializerMethodField()
|
||||||
|
is_shared = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_url(self, obj):
|
def get_url(self, obj):
|
||||||
return self.context["request"].build_absolute_uri(obj.get_absolute_url())
|
return self.context["request"].build_absolute_uri(obj.get_absolute_url())
|
||||||
|
|
||||||
|
def get_is_shared(self, obj):
|
||||||
|
"""Check if media has custom permissions or RBAC categories"""
|
||||||
|
custom_permissions = obj.permissions.exists()
|
||||||
|
rbac_categories = obj.category.filter(is_rbac_category=True).exists()
|
||||||
|
return custom_permissions or rbac_categories
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Media
|
model = Media
|
||||||
read_only_fields = (
|
read_only_fields = (
|
||||||
@@ -133,6 +140,7 @@ class SingleMediaSerializer(serializers.ModelSerializer):
|
|||||||
"edit_date",
|
"edit_date",
|
||||||
"media_type",
|
"media_type",
|
||||||
"state",
|
"state",
|
||||||
|
"is_shared",
|
||||||
"duration",
|
"duration",
|
||||||
"thumbnail_url",
|
"thumbnail_url",
|
||||||
"poster_url",
|
"poster_url",
|
||||||
@@ -218,6 +226,7 @@ class CategorySerializer(serializers.ModelSerializer):
|
|||||||
"media_count",
|
"media_count",
|
||||||
"user",
|
"user",
|
||||||
"thumbnail_url",
|
"thumbnail_url",
|
||||||
|
"is_lms_course",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -625,6 +625,18 @@ def create_hls(friendly_token):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="media_init", queue="short_tasks")
|
||||||
|
def media_init(friendly_token):
|
||||||
|
try:
|
||||||
|
media = Media.objects.get(friendly_token=friendly_token)
|
||||||
|
except: # noqa
|
||||||
|
logger.info("failed to get media with friendly_token %s" % friendly_token)
|
||||||
|
return False
|
||||||
|
media.media_init()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@task(name="check_running_states", queue="short_tasks")
|
@task(name="check_running_states", queue="short_tasks")
|
||||||
def check_running_states():
|
def check_running_states():
|
||||||
# Experimental - unused
|
# Experimental - unused
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ urlpatterns = [
|
|||||||
re_path(r"^contact$", views.contact, name="contact"),
|
re_path(r"^contact$", views.contact, name="contact"),
|
||||||
re_path(r"^publish", views.publish_media, name="publish_media"),
|
re_path(r"^publish", views.publish_media, name="publish_media"),
|
||||||
re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"),
|
re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"),
|
||||||
|
re_path(r"^replace_media", views.replace_media, name="replace_media"),
|
||||||
re_path(r"^edit_video", views.edit_video, name="edit_video"),
|
re_path(r"^edit_video", views.edit_video, name="edit_video"),
|
||||||
re_path(r"^edit", views.edit_media, name="edit_media"),
|
re_path(r"^edit", views.edit_media, name="edit_media"),
|
||||||
re_path(r"^embed", views.embed_media, name="get_embed"),
|
re_path(r"^embed", views.embed_media, name="get_embed"),
|
||||||
@@ -79,6 +80,7 @@ urlpatterns = [
|
|||||||
views.trim_video,
|
views.trim_video,
|
||||||
),
|
),
|
||||||
re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
|
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/tags$", views.TagList.as_view()),
|
||||||
re_path(r"^api/v1/comments$", views.CommentList.as_view()),
|
re_path(r"^api/v1/comments$", views.CommentList.as_view()),
|
||||||
re_path(
|
re_path(
|
||||||
@@ -110,7 +112,7 @@ urlpatterns = [
|
|||||||
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
|
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
|
||||||
# Media uploads in ADMIN created pages
|
# Media uploads in ADMIN created pages
|
||||||
re_path(r"^tinymce/upload/", tinymce_handlers.upload_image, name="tinymce_upload_image"),
|
re_path(r"^tinymce/upload/", tinymce_handlers.upload_image, name="tinymce_upload_image"),
|
||||||
re_path("^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605
|
re_path(r"^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605
|
||||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Import all views for backward compatibility
|
# Import all views for backward compatibility
|
||||||
|
|
||||||
from .auth import custom_login_view, saml_metadata # noqa: F401
|
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 .comments import CommentDetail, CommentList # noqa: F401
|
||||||
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
|
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
|
||||||
from .media import MediaActions # noqa: F401
|
from .media import MediaActions # noqa: F401
|
||||||
@@ -32,6 +32,7 @@ from .pages import members # noqa: F401
|
|||||||
from .pages import publish_media # noqa: F401
|
from .pages import publish_media # noqa: F401
|
||||||
from .pages import recommended_media # noqa: F401
|
from .pages import recommended_media # noqa: F401
|
||||||
from .pages import record_screen # noqa: F401
|
from .pages import record_screen # noqa: F401
|
||||||
|
from .pages import replace_media # noqa: F401
|
||||||
from .pages import search # noqa: F401
|
from .pages import search # noqa: F401
|
||||||
from .pages import setlanguage # noqa: F401
|
from .pages import setlanguage # noqa: F401
|
||||||
from .pages import sitemap # noqa: F401
|
from .pages import sitemap # noqa: F401
|
||||||
|
|||||||
@@ -43,6 +43,49 @@ class CategoryList(APIView):
|
|||||||
return Response(ret)
|
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):
|
class TagList(APIView):
|
||||||
"""List tags"""
|
"""List tags"""
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ class MediaList(APIView):
|
|||||||
rbac_categories = request.user.get_rbac_categories_as_member()
|
rbac_categories = request.user.get_rbac_categories_as_member()
|
||||||
conditions |= Q(category__in=rbac_categories)
|
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:
|
elif author_param:
|
||||||
user_queryset = User.objects.all()
|
user_queryset = User.objects.all()
|
||||||
user = get_object_or_404(user_queryset, username=author_param)
|
user = get_object_or_404(user_queryset, username=author_param)
|
||||||
@@ -226,8 +226,13 @@ class MediaList(APIView):
|
|||||||
elif duration == '60-120':
|
elif duration == '60-120':
|
||||||
media = media.filter(duration__gte=3600)
|
media = media.filter(duration__gte=3600)
|
||||||
|
|
||||||
if publish_state and publish_state in ['private', 'public', 'unlisted']:
|
if publish_state:
|
||||||
media = media.filter(state=publish_state)
|
if publish_state == 'shared':
|
||||||
|
# Filter media that have custom permissions OR RBAC categories
|
||||||
|
shared_conditions = Q(permissions__isnull=False) | Q(category__is_rbac_category=True)
|
||||||
|
media = media.filter(shared_conditions).distinct()
|
||||||
|
elif publish_state in ['private', 'public', 'unlisted']:
|
||||||
|
media = media.filter(state=publish_state)
|
||||||
|
|
||||||
if not already_sorted:
|
if not already_sorted:
|
||||||
media = media.order_by(f"{ordering}{sort_by}")
|
media = media.order_by(f"{ordering}{sort_by}")
|
||||||
@@ -569,12 +574,31 @@ class MediaBulkUserActions(APIView):
|
|||||||
|
|
||||||
elif action == "add_to_category":
|
elif action == "add_to_category":
|
||||||
category_uids = request.data.get('category_uids', [])
|
category_uids = request.data.get('category_uids', [])
|
||||||
if not category_uids:
|
lti_context_id = request.data.get('lti_context_id')
|
||||||
return Response({"detail": "category_uids is required for add_to_category action"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
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:
|
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
|
added_count = 0
|
||||||
for category in categories:
|
for category in categories:
|
||||||
@@ -692,12 +716,9 @@ class MediaDetail(APIView):
|
|||||||
return media
|
return media
|
||||||
|
|
||||||
serializer = SingleMediaSerializer(media, context={"request": request})
|
serializer = SingleMediaSerializer(media, context={"request": request})
|
||||||
if media.state == "private":
|
related_media = show_related_media(media, request=request, limit=100)
|
||||||
related_media = []
|
related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request})
|
||||||
else:
|
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
|
ret = serializer.data
|
||||||
|
|
||||||
# update rattings info with user specific ratings
|
# update rattings info with user specific ratings
|
||||||
@@ -799,13 +820,14 @@ class MediaDetail(APIView):
|
|||||||
|
|
||||||
serializer = MediaSerializer(media, data=request.data, context={"request": request})
|
serializer = MediaSerializer(media, data=request.data, context={"request": request})
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save(user=request.user)
|
|
||||||
# no need to update the media file itself, only the metadata
|
|
||||||
# if request.data.get('media_file'):
|
# if request.data.get('media_file'):
|
||||||
# media_file = request.data["media_file"]
|
# media_file = request.data["media_file"]
|
||||||
# serializer.save(user=request.user, media_file=media_file)
|
# media.state = helpers.get_default_state(request.user)
|
||||||
|
# media.listable = False
|
||||||
|
# serializer.save(user=request.user, media_file=media_file)
|
||||||
# else:
|
# else:
|
||||||
# serializer.save(user=request.user)
|
# serializer.save(user=request.user)
|
||||||
|
serializer.save(user=request.user)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
@@ -18,11 +19,12 @@ from ..forms import (
|
|||||||
EditSubtitleForm,
|
EditSubtitleForm,
|
||||||
MediaMetadataForm,
|
MediaMetadataForm,
|
||||||
MediaPublishForm,
|
MediaPublishForm,
|
||||||
|
ReplaceMediaForm,
|
||||||
SubtitleForm,
|
SubtitleForm,
|
||||||
WhisperSubtitlesForm,
|
WhisperSubtitlesForm,
|
||||||
)
|
)
|
||||||
from ..frontend_translations import translate_string
|
from ..frontend_translations import translate_string
|
||||||
from ..helpers import get_alphanumeric_only
|
from ..helpers import get_alphanumeric_and_spaces
|
||||||
from ..methods import (
|
from ..methods import (
|
||||||
can_transcribe_video,
|
can_transcribe_video,
|
||||||
create_video_trim_request,
|
create_video_trim_request,
|
||||||
@@ -308,8 +310,8 @@ def edit_media(request):
|
|||||||
media.tags.remove(tag)
|
media.tags.remove(tag)
|
||||||
if form.cleaned_data.get("new_tags"):
|
if form.cleaned_data.get("new_tags"):
|
||||||
for tag in form.cleaned_data.get("new_tags").split(","):
|
for tag in form.cleaned_data.get("new_tags").split(","):
|
||||||
tag = get_alphanumeric_only(tag)
|
tag = get_alphanumeric_and_spaces(tag)
|
||||||
tag = tag[:99]
|
tag = tag[:100]
|
||||||
if tag:
|
if tag:
|
||||||
try:
|
try:
|
||||||
tag = Tag.objects.get(title=tag)
|
tag = Tag.objects.get(title=tag)
|
||||||
@@ -348,13 +350,13 @@ def publish_media(request):
|
|||||||
return HttpResponseRedirect(media.get_absolute_url())
|
return HttpResponseRedirect(media.get_absolute_url())
|
||||||
|
|
||||||
if request.method == "POST":
|
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():
|
if form.is_valid():
|
||||||
media = form.save()
|
media = form.save()
|
||||||
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
|
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
|
||||||
return HttpResponseRedirect(media.get_absolute_url())
|
return HttpResponseRedirect(media.get_absolute_url())
|
||||||
else:
|
else:
|
||||||
form = MediaPublishForm(request.user, instance=media)
|
form = MediaPublishForm(request.user, instance=media, request=request)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -363,6 +365,76 @@ def publish_media(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def replace_media(request):
|
||||||
|
"""Replace media file"""
|
||||||
|
|
||||||
|
if not getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False):
|
||||||
|
return HttpResponseRedirect("/")
|
||||||
|
|
||||||
|
friendly_token = request.GET.get("m", "").strip()
|
||||||
|
if not friendly_token:
|
||||||
|
return HttpResponseRedirect("/")
|
||||||
|
media = Media.objects.filter(friendly_token=friendly_token).first()
|
||||||
|
|
||||||
|
if not media:
|
||||||
|
return HttpResponseRedirect("/")
|
||||||
|
|
||||||
|
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
|
||||||
|
return HttpResponseRedirect("/")
|
||||||
|
|
||||||
|
if not is_media_allowed_type(media):
|
||||||
|
return HttpResponseRedirect(media.get_absolute_url())
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = ReplaceMediaForm(media, request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
new_media_file = form.cleaned_data.get("new_media_file")
|
||||||
|
|
||||||
|
media.encodings.all().delete()
|
||||||
|
|
||||||
|
if media.thumbnail:
|
||||||
|
helpers.rm_file(media.thumbnail.path)
|
||||||
|
media.thumbnail = None
|
||||||
|
if media.poster:
|
||||||
|
helpers.rm_file(media.poster.path)
|
||||||
|
media.poster = None
|
||||||
|
if media.uploaded_thumbnail:
|
||||||
|
helpers.rm_file(media.uploaded_thumbnail.path)
|
||||||
|
media.uploaded_thumbnail = None
|
||||||
|
if media.uploaded_poster:
|
||||||
|
helpers.rm_file(media.uploaded_poster.path)
|
||||||
|
media.uploaded_poster = None
|
||||||
|
if media.sprites:
|
||||||
|
helpers.rm_file(media.sprites.path)
|
||||||
|
media.sprites = None
|
||||||
|
if media.preview_file_path:
|
||||||
|
helpers.rm_file(media.preview_file_path)
|
||||||
|
media.preview_file_path = ""
|
||||||
|
|
||||||
|
if media.hls_file:
|
||||||
|
hls_dir = os.path.dirname(media.hls_file)
|
||||||
|
helpers.rm_dir(hls_dir)
|
||||||
|
media.hls_file = ""
|
||||||
|
|
||||||
|
media.media_file = new_media_file
|
||||||
|
|
||||||
|
media.listable = False
|
||||||
|
media.state = helpers.get_default_state(request.user)
|
||||||
|
media.save()
|
||||||
|
|
||||||
|
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media file was replaced successfully"))
|
||||||
|
return HttpResponseRedirect(media.get_absolute_url())
|
||||||
|
else:
|
||||||
|
form = ReplaceMediaForm(media)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"cms/replace_media.html",
|
||||||
|
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def edit_chapters(request):
|
def edit_chapters(request):
|
||||||
"""Edit chapters"""
|
"""Edit chapters"""
|
||||||
|
|||||||
55
files/widgets.py
Normal file
55
files/widgets.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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"""
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {'all': ('css/category_modal.css',)}
|
||||||
|
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
|
||||||
|
# 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_placeholder}">
|
||||||
|
<div class="category-list scrollable" data-panel="left"></div>
|
||||||
|
</div>
|
||||||
|
<div class="category-panel">
|
||||||
|
<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},"lms_mode":{lms_mode_json}}}</script>
|
||||||
|
</div>'''
|
||||||
|
|
||||||
|
return mark_safe(html)
|
||||||
@@ -150,6 +150,11 @@ const App = () => {
|
|||||||
canRedo={historyPosition < history.length - 1}
|
canRedo={historyPosition < history.length - 1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Timeline Header */}
|
||||||
|
<div className="timeline-header-container">
|
||||||
|
<h2 className="timeline-header-title">Add Chapters</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Timeline Controls */}
|
{/* Timeline Controls */}
|
||||||
<TimelineControls
|
<TimelineControls
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
|
|||||||
|
|
||||||
// Generate the same color background for a segment as shown in the timeline
|
// Generate the same color background for a segment as shown in the timeline
|
||||||
const getSegmentColorClass = (index: number) => {
|
const getSegmentColorClass = (index: number) => {
|
||||||
// Return CSS class based on index modulo 8
|
// Return CSS class based on index modulo 20
|
||||||
// This matches the CSS nth-child selectors in the timeline
|
// This matches the CSS classes for up to 20 segments
|
||||||
return `segment-default-color segment-color-${(index % 8) + 1}`;
|
return `segment-default-color segment-color-${(index % 20) + 1}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get selected segment
|
// Get selected segment
|
||||||
@@ -65,8 +65,8 @@ const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
|
|||||||
<div className="segment-actions">
|
<div className="segment-actions">
|
||||||
<button
|
<button
|
||||||
className="delete-button"
|
className="delete-button"
|
||||||
aria-label="Delete Segment"
|
aria-label="Delete Chapter"
|
||||||
data-tooltip="Delete this segment"
|
data-tooltip="Delete this chapter"
|
||||||
onClick={() => handleDeleteSegment(segment.id)}
|
onClick={() => handleDeleteSegment(segment.id)}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
|||||||
@@ -177,7 +177,16 @@ const TimelineControls = ({
|
|||||||
const [isAutoSaving, setIsAutoSaving] = useState(false);
|
const [isAutoSaving, setIsAutoSaving] = useState(false);
|
||||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const clipSegmentsRef = useRef(clipSegments);
|
const clipSegmentsRef = useRef(clipSegments);
|
||||||
|
// Track when a drag just ended to prevent Safari from triggering clicks after drag
|
||||||
|
const dragJustEndedRef = useRef<boolean>(false);
|
||||||
|
const dragEndTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Helper function to detect Safari browser
|
||||||
|
const isSafari = () => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||||
|
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
|
||||||
|
};
|
||||||
|
|
||||||
// Keep clipSegmentsRef updated
|
// Keep clipSegmentsRef updated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -867,6 +876,12 @@ const TimelineControls = ({
|
|||||||
logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current);
|
logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current);
|
||||||
clearTimeout(autoSaveTimerRef.current);
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear any pending drag end timeout
|
||||||
|
if (dragEndTimeoutRef.current) {
|
||||||
|
clearTimeout(dragEndTimeoutRef.current);
|
||||||
|
dragEndTimeoutRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [scheduleAutoSave]);
|
}, [scheduleAutoSave]);
|
||||||
|
|
||||||
@@ -1084,16 +1099,20 @@ const TimelineControls = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to calculate available space for a new segment
|
// Helper function to calculate available space for a new segment
|
||||||
const calculateAvailableSpace = (startTime: number): number => {
|
const calculateAvailableSpace = (startTime: number, segmentsOverride?: Segment[]): number => {
|
||||||
// Always return at least 0.1 seconds to ensure tooltip shows
|
// Always return at least 0.1 seconds to ensure tooltip shows
|
||||||
const MIN_SPACE = 0.1;
|
const MIN_SPACE = 0.1;
|
||||||
|
|
||||||
|
// Use override segments if provided, otherwise use ref to get latest segments
|
||||||
|
// This ensures we always have the most up-to-date segments, especially important for Safari
|
||||||
|
const segmentsToUse = segmentsOverride || clipSegmentsRef.current;
|
||||||
|
|
||||||
// Determine the amount of available space:
|
// Determine the amount of available space:
|
||||||
// 1. Check remaining space until the end of video
|
// 1. Check remaining space until the end of video
|
||||||
const remainingDuration = Math.max(0, duration - startTime);
|
const remainingDuration = Math.max(0, duration - startTime);
|
||||||
|
|
||||||
// 2. Find the next segment (if any)
|
// 2. Find the next segment (if any)
|
||||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
const sortedSegments = [...segmentsToUse].sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
// Find the next and previous segments
|
// Find the next and previous segments
|
||||||
const nextSegment = sortedSegments.find((seg) => seg.startTime > startTime);
|
const nextSegment = sortedSegments.find((seg) => seg.startTime > startTime);
|
||||||
@@ -1109,14 +1128,6 @@ const TimelineControls = ({
|
|||||||
availableSpace = duration - startTime;
|
availableSpace = duration - startTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the space calculation for debugging
|
|
||||||
logger.debug('Space calculation:', {
|
|
||||||
position: formatDetailedTime(startTime),
|
|
||||||
nextSegment: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
|
|
||||||
prevSegment: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
|
|
||||||
availableSpace: formatDetailedTime(Math.max(MIN_SPACE, availableSpace)),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Always return at least MIN_SPACE to ensure tooltip shows
|
// Always return at least MIN_SPACE to ensure tooltip shows
|
||||||
return Math.max(MIN_SPACE, availableSpace);
|
return Math.max(MIN_SPACE, availableSpace);
|
||||||
};
|
};
|
||||||
@@ -1125,8 +1136,11 @@ const TimelineControls = ({
|
|||||||
const updateTooltipForPosition = (currentPosition: number) => {
|
const updateTooltipForPosition = (currentPosition: number) => {
|
||||||
if (!timelineRef.current) return;
|
if (!timelineRef.current) return;
|
||||||
|
|
||||||
|
// Use ref to get latest segments to avoid stale state issues
|
||||||
|
const currentSegments = clipSegmentsRef.current;
|
||||||
|
|
||||||
// Find if we're in a segment at the current position with a small tolerance
|
// Find if we're in a segment at the current position with a small tolerance
|
||||||
const segmentAtPosition = clipSegments.find((seg) => {
|
const segmentAtPosition = currentSegments.find((seg) => {
|
||||||
const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime;
|
const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime;
|
||||||
const isVeryCloseToStart = Math.abs(currentPosition - seg.startTime) < 0.001;
|
const isVeryCloseToStart = Math.abs(currentPosition - seg.startTime) < 0.001;
|
||||||
const isVeryCloseToEnd = Math.abs(currentPosition - seg.endTime) < 0.001;
|
const isVeryCloseToEnd = Math.abs(currentPosition - seg.endTime) < 0.001;
|
||||||
@@ -1134,7 +1148,7 @@ const TimelineControls = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Find the next and previous segments
|
// Find the next and previous segments
|
||||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
const sortedSegments = [...currentSegments].sort((a, b) => a.startTime - b.startTime);
|
||||||
const nextSegment = sortedSegments.find((seg) => seg.startTime > currentPosition);
|
const nextSegment = sortedSegments.find((seg) => seg.startTime > currentPosition);
|
||||||
const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < currentPosition);
|
const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < currentPosition);
|
||||||
|
|
||||||
@@ -1144,21 +1158,13 @@ const TimelineControls = ({
|
|||||||
setShowEmptySpaceTooltip(false);
|
setShowEmptySpaceTooltip(false);
|
||||||
} else {
|
} else {
|
||||||
// We're in a cutaway area
|
// We're in a cutaway area
|
||||||
// Calculate available space for new segment
|
// Calculate available space for new segment using current segments
|
||||||
const availableSpace = calculateAvailableSpace(currentPosition);
|
const availableSpace = calculateAvailableSpace(currentPosition, currentSegments);
|
||||||
setAvailableSegmentDuration(availableSpace);
|
setAvailableSegmentDuration(availableSpace);
|
||||||
|
|
||||||
// Always show empty space tooltip
|
// Always show empty space tooltip
|
||||||
setSelectedSegmentId(null);
|
setSelectedSegmentId(null);
|
||||||
setShowEmptySpaceTooltip(true);
|
setShowEmptySpaceTooltip(true);
|
||||||
|
|
||||||
// Log position info for debugging
|
|
||||||
logger.debug('Cutaway position:', {
|
|
||||||
current: formatDetailedTime(currentPosition),
|
|
||||||
prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
|
|
||||||
nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
|
|
||||||
availableSpace: formatDetailedTime(availableSpace),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update tooltip position
|
// Update tooltip position
|
||||||
@@ -1188,6 +1194,12 @@ const TimelineControls = ({
|
|||||||
|
|
||||||
if (!timelineRef.current || !scrollContainerRef.current) return;
|
if (!timelineRef.current || !scrollContainerRef.current) return;
|
||||||
|
|
||||||
|
// Safari-specific fix: Ignore clicks that happen immediately after a drag operation
|
||||||
|
// Safari fires click events after drag ends, which can cause issues with stale state
|
||||||
|
if (isSafari() && dragJustEndedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If on mobile device and video hasn't been initialized, don't handle timeline clicks
|
// If on mobile device and video hasn't been initialized, don't handle timeline clicks
|
||||||
if (isIOSUninitialized) {
|
if (isIOSUninitialized) {
|
||||||
return;
|
return;
|
||||||
@@ -1195,7 +1207,6 @@ const TimelineControls = ({
|
|||||||
|
|
||||||
// Check if video is globally playing before the click
|
// Check if video is globally playing before the click
|
||||||
const wasPlaying = videoRef.current && !videoRef.current.paused;
|
const wasPlaying = videoRef.current && !videoRef.current.paused;
|
||||||
logger.debug('Video was playing before timeline click:', wasPlaying);
|
|
||||||
|
|
||||||
// Reset continuation flag when clicking on timeline - ensures proper boundary detection
|
// Reset continuation flag when clicking on timeline - ensures proper boundary detection
|
||||||
setContinuePastBoundary(false);
|
setContinuePastBoundary(false);
|
||||||
@@ -1216,14 +1227,6 @@ const TimelineControls = ({
|
|||||||
|
|
||||||
const newTime = position * duration;
|
const newTime = position * duration;
|
||||||
|
|
||||||
// Log the position for debugging
|
|
||||||
logger.debug(
|
|
||||||
'Timeline clicked at:',
|
|
||||||
formatDetailedTime(newTime),
|
|
||||||
'distance from end:',
|
|
||||||
formatDetailedTime(duration - newTime)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store position globally for iOS Safari (this is critical for first-time visits)
|
// Store position globally for iOS Safari (this is critical for first-time visits)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.lastSeekedPosition = newTime;
|
window.lastSeekedPosition = newTime;
|
||||||
@@ -1236,8 +1239,12 @@ const TimelineControls = ({
|
|||||||
setClickedTime(newTime);
|
setClickedTime(newTime);
|
||||||
setDisplayTime(newTime);
|
setDisplayTime(newTime);
|
||||||
|
|
||||||
|
// Use ref to get latest segments to avoid stale state issues, especially in Safari
|
||||||
|
// Safari can fire click events immediately after drag before React re-renders
|
||||||
|
const currentSegments = clipSegmentsRef.current;
|
||||||
|
|
||||||
// Find if we clicked in a segment with a small tolerance for boundaries
|
// Find if we clicked in a segment with a small tolerance for boundaries
|
||||||
const segmentAtClickedTime = clipSegments.find((seg) => {
|
const segmentAtClickedTime = currentSegments.find((seg) => {
|
||||||
// Standard check for being inside a segment
|
// Standard check for being inside a segment
|
||||||
const isInside = newTime >= seg.startTime && newTime <= seg.endTime;
|
const isInside = newTime >= seg.startTime && newTime <= seg.endTime;
|
||||||
// Additional checks for being exactly at the start or end boundary (with small tolerance)
|
// Additional checks for being exactly at the start or end boundary (with small tolerance)
|
||||||
@@ -1258,7 +1265,7 @@ const TimelineControls = ({
|
|||||||
if (isPlayingSegments && wasPlaying) {
|
if (isPlayingSegments && wasPlaying) {
|
||||||
// Update the current segment index if we clicked into a segment
|
// Update the current segment index if we clicked into a segment
|
||||||
if (segmentAtClickedTime) {
|
if (segmentAtClickedTime) {
|
||||||
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
const orderedSegments = [...currentSegments].sort((a, b) => a.startTime - b.startTime);
|
||||||
const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id);
|
const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id);
|
||||||
|
|
||||||
if (targetSegmentIndex !== -1) {
|
if (targetSegmentIndex !== -1) {
|
||||||
@@ -1311,8 +1318,9 @@ const TimelineControls = ({
|
|||||||
// We're in a cutaway area - always show tooltip
|
// We're in a cutaway area - always show tooltip
|
||||||
setSelectedSegmentId(null);
|
setSelectedSegmentId(null);
|
||||||
|
|
||||||
// Calculate the available space for a new segment
|
// Calculate the available space for a new segment using current segments from ref
|
||||||
const availableSpace = calculateAvailableSpace(newTime);
|
// This ensures we use the latest segments even if React hasn't re-rendered yet
|
||||||
|
const availableSpace = calculateAvailableSpace(newTime, currentSegments);
|
||||||
setAvailableSegmentDuration(availableSpace);
|
setAvailableSegmentDuration(availableSpace);
|
||||||
|
|
||||||
// Calculate and set tooltip position correctly for zoomed timeline
|
// Calculate and set tooltip position correctly for zoomed timeline
|
||||||
@@ -1334,18 +1342,6 @@ const TimelineControls = ({
|
|||||||
|
|
||||||
// Always show the empty space tooltip in cutaway areas
|
// Always show the empty space tooltip in cutaway areas
|
||||||
setShowEmptySpaceTooltip(true);
|
setShowEmptySpaceTooltip(true);
|
||||||
|
|
||||||
// Log the cutaway area details
|
|
||||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
|
||||||
const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < newTime);
|
|
||||||
const nextSegment = sortedSegments.find((seg) => seg.startTime > newTime);
|
|
||||||
|
|
||||||
logger.debug('Clicked in cutaway area:', {
|
|
||||||
position: formatDetailedTime(newTime),
|
|
||||||
availableSpace: formatDetailedTime(availableSpace),
|
|
||||||
prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
|
|
||||||
nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1498,6 +1494,10 @@ const TimelineControls = ({
|
|||||||
return seg;
|
return seg;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update the ref immediately during drag to ensure we always have latest segments
|
||||||
|
// This is critical for Safari which may fire events before React re-renders
|
||||||
|
clipSegmentsRef.current = updatedSegments;
|
||||||
|
|
||||||
// Create a custom event to update the segments WITHOUT recording in history during drag
|
// Create a custom event to update the segments WITHOUT recording in history during drag
|
||||||
const updateEvent = new CustomEvent('update-segments', {
|
const updateEvent = new CustomEvent('update-segments', {
|
||||||
detail: {
|
detail: {
|
||||||
@@ -1582,6 +1582,26 @@ const TimelineControls = ({
|
|||||||
return seg;
|
return seg;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CRITICAL: Update the ref immediately with the new segments
|
||||||
|
// This ensures that if Safari fires a click event before React re-renders,
|
||||||
|
// the click handler will use the updated segments instead of stale ones
|
||||||
|
clipSegmentsRef.current = finalSegments;
|
||||||
|
|
||||||
|
// Safari-specific fix: Set flag to ignore clicks immediately after drag
|
||||||
|
// Safari fires click events after drag ends, which can interfere with state updates
|
||||||
|
if (isSafari()) {
|
||||||
|
dragJustEndedRef.current = true;
|
||||||
|
// Clear the flag after a delay to allow React to re-render with updated segments
|
||||||
|
// Increased timeout to ensure state has propagated
|
||||||
|
if (dragEndTimeoutRef.current) {
|
||||||
|
clearTimeout(dragEndTimeoutRef.current);
|
||||||
|
}
|
||||||
|
dragEndTimeoutRef.current = setTimeout(() => {
|
||||||
|
dragJustEndedRef.current = false;
|
||||||
|
dragEndTimeoutRef.current = null;
|
||||||
|
}, 200); // 200ms to ensure React has processed the state update and re-rendered
|
||||||
|
}
|
||||||
|
|
||||||
// Now we can create a history record for the complete drag operation
|
// Now we can create a history record for the complete drag operation
|
||||||
const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end';
|
const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end';
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
@@ -1594,6 +1614,13 @@ const TimelineControls = ({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dispatch segment-drag-end event for other listeners
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent('segment-drag-end', {
|
||||||
|
detail: { segmentId },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// After drag is complete, do a final check to see if playhead is inside the segment
|
// After drag is complete, do a final check to see if playhead is inside the segment
|
||||||
if (selectedSegmentId === segmentId && videoRef.current) {
|
if (selectedSegmentId === segmentId && videoRef.current) {
|
||||||
const currentTime = videoRef.current.currentTime;
|
const currentTime = videoRef.current.currentTime;
|
||||||
@@ -3943,9 +3970,7 @@ const TimelineControls = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowSaveChaptersModal(true)}
|
onClick={() => setShowSaveChaptersModal(true)}
|
||||||
className="save-chapters-button"
|
className="save-chapters-button"
|
||||||
data-tooltip={clipSegments.length === 0
|
{...(clipSegments.length === 0 && { 'data-tooltip': 'Clear all chapters' })}
|
||||||
? "Clear all chapters"
|
|
||||||
: "Save chapters"}
|
|
||||||
>
|
>
|
||||||
{clipSegments.length === 0
|
{clipSegments.length === 0
|
||||||
? 'Clear Chapters'
|
? 'Clear Chapters'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const useVideoChapters = () => {
|
|||||||
// Sort by start time to find chronological position
|
// Sort by start time to find chronological position
|
||||||
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
|
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
|
||||||
// Find the index of our new segment
|
// Find the index of our new segment
|
||||||
const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime);
|
const chapterIndex = sortedSegments.findIndex((seg) => seg.startTime === newSegmentStartTime);
|
||||||
return `Chapter ${chapterIndex + 1}`;
|
return `Chapter ${chapterIndex + 1}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,12 +28,18 @@ const useVideoChapters = () => {
|
|||||||
const renumberAllSegments = (segments: Segment[]): Segment[] => {
|
const renumberAllSegments = (segments: Segment[]): Segment[] => {
|
||||||
// Sort segments by start time
|
// Sort segments by start time
|
||||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
// Renumber each segment based on its chronological position
|
// Renumber each segment based on its chronological position
|
||||||
return sortedSegments.map((segment, index) => ({
|
// Only update titles that follow the default "Chapter X" pattern to preserve custom titles
|
||||||
...segment,
|
return sortedSegments.map((segment, index) => {
|
||||||
chapterTitle: `Chapter ${index + 1}`
|
const currentTitle = segment.chapterTitle || '';
|
||||||
}));
|
const isDefaultTitle = /^Chapter \d+$/.test(currentTitle);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...segment,
|
||||||
|
chapterTitle: isDefaultTitle ? `Chapter ${index + 1}` : currentTitle,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
||||||
@@ -54,6 +60,9 @@ const useVideoChapters = () => {
|
|||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
|
||||||
|
// Track if editor has been initialized to prevent re-initialization on Safari metadata events
|
||||||
|
const isInitializedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
// Timeline state
|
// Timeline state
|
||||||
const [trimStart, setTrimStart] = useState(0);
|
const [trimStart, setTrimStart] = useState(0);
|
||||||
@@ -102,11 +111,7 @@ const useVideoChapters = () => {
|
|||||||
// Detect Safari browser
|
// Detect Safari browser
|
||||||
const isSafari = () => {
|
const isSafari = () => {
|
||||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||||
const isSafariBrowser = /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
|
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
|
||||||
if (isSafariBrowser) {
|
|
||||||
logger.debug('Safari browser detected, enabling audio support fallbacks');
|
|
||||||
}
|
|
||||||
return isSafariBrowser;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize video event listeners
|
// Initialize video event listeners
|
||||||
@@ -115,7 +120,15 @@ const useVideoChapters = () => {
|
|||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
const handleLoadedMetadata = () => {
|
const handleLoadedMetadata = () => {
|
||||||
logger.debug('Video loadedmetadata event fired, duration:', video.duration);
|
// CRITICAL: Prevent re-initialization if editor has already been initialized
|
||||||
|
// Safari fires loadedmetadata multiple times, which was resetting segments
|
||||||
|
if (isInitializedRef.current) {
|
||||||
|
// Still update duration and trimEnd in case they changed
|
||||||
|
setDuration(video.duration);
|
||||||
|
setTrimEnd(video.duration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setDuration(video.duration);
|
setDuration(video.duration);
|
||||||
setTrimEnd(video.duration);
|
setTrimEnd(video.duration);
|
||||||
|
|
||||||
@@ -124,9 +137,7 @@ const useVideoChapters = () => {
|
|||||||
let initialSegments: Segment[] = [];
|
let initialSegments: Segment[] = [];
|
||||||
|
|
||||||
// Check if we have existing chapters from the backend
|
// Check if we have existing chapters from the backend
|
||||||
const existingChapters =
|
const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [];
|
||||||
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) ||
|
|
||||||
[];
|
|
||||||
|
|
||||||
if (existingChapters.length > 0) {
|
if (existingChapters.length > 0) {
|
||||||
// Create segments from existing chapters
|
// Create segments from existing chapters
|
||||||
@@ -169,7 +180,7 @@ const useVideoChapters = () => {
|
|||||||
setHistory([initialState]);
|
setHistory([initialState]);
|
||||||
setHistoryPosition(0);
|
setHistoryPosition(0);
|
||||||
setClipSegments(initialSegments);
|
setClipSegments(initialSegments);
|
||||||
logger.debug('Editor initialized with segments:', initialSegments.length);
|
isInitializedRef.current = true; // Mark as initialized
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeEditor();
|
initializeEditor();
|
||||||
@@ -177,20 +188,18 @@ const useVideoChapters = () => {
|
|||||||
|
|
||||||
// Safari-specific fallback for audio files
|
// Safari-specific fallback for audio files
|
||||||
const handleCanPlay = () => {
|
const handleCanPlay = () => {
|
||||||
logger.debug('Video canplay event fired');
|
|
||||||
// If loadedmetadata hasn't fired yet but we have duration, trigger initialization
|
// If loadedmetadata hasn't fired yet but we have duration, trigger initialization
|
||||||
if (video.duration && duration === 0) {
|
// Also check if already initialized to prevent re-initialization
|
||||||
logger.debug('Safari fallback: Using canplay event to initialize');
|
if (video.duration && duration === 0 && !isInitializedRef.current) {
|
||||||
handleLoadedMetadata();
|
handleLoadedMetadata();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Additional Safari fallback for audio files
|
// Additional Safari fallback for audio files
|
||||||
const handleLoadedData = () => {
|
const handleLoadedData = () => {
|
||||||
logger.debug('Video loadeddata event fired');
|
|
||||||
// If we still don't have duration, try again
|
// If we still don't have duration, try again
|
||||||
if (video.duration && duration === 0) {
|
// Also check if already initialized to prevent re-initialization
|
||||||
logger.debug('Safari fallback: Using loadeddata event to initialize');
|
if (video.duration && duration === 0 && !isInitializedRef.current) {
|
||||||
handleLoadedMetadata();
|
handleLoadedMetadata();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -222,14 +231,12 @@ const useVideoChapters = () => {
|
|||||||
|
|
||||||
// Safari-specific fallback event listeners for audio files
|
// Safari-specific fallback event listeners for audio files
|
||||||
if (isSafari()) {
|
if (isSafari()) {
|
||||||
logger.debug('Adding Safari-specific event listeners for audio support');
|
|
||||||
video.addEventListener('canplay', handleCanPlay);
|
video.addEventListener('canplay', handleCanPlay);
|
||||||
video.addEventListener('loadeddata', handleLoadedData);
|
video.addEventListener('loadeddata', handleLoadedData);
|
||||||
|
|
||||||
// Additional timeout fallback for Safari audio files
|
// Additional timeout fallback for Safari audio files
|
||||||
const safariTimeout = setTimeout(() => {
|
const safariTimeout = setTimeout(() => {
|
||||||
if (video.duration && duration === 0) {
|
if (video.duration && duration === 0 && !isInitializedRef.current) {
|
||||||
logger.debug('Safari timeout fallback: Force initializing editor');
|
|
||||||
handleLoadedMetadata();
|
handleLoadedMetadata();
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -261,21 +268,21 @@ const useVideoChapters = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSafari() && videoRef.current) {
|
if (isSafari() && videoRef.current) {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
|
|
||||||
const initializeSafariOnInteraction = () => {
|
const initializeSafariOnInteraction = () => {
|
||||||
// Try to load video metadata by attempting to play and immediately pause
|
// Try to load video metadata by attempting to play and immediately pause
|
||||||
const attemptInitialization = async () => {
|
const attemptInitialization = async () => {
|
||||||
try {
|
try {
|
||||||
logger.debug('Safari: Attempting auto-initialization on user interaction');
|
logger.debug('Safari: Attempting auto-initialization on user interaction');
|
||||||
|
|
||||||
// Briefly play to trigger metadata loading, then pause
|
// Briefly play to trigger metadata loading, then pause
|
||||||
await video.play();
|
await video.play();
|
||||||
video.pause();
|
video.pause();
|
||||||
|
|
||||||
// Check if we now have duration and initialize if needed
|
// Check if we now have duration and initialize if needed
|
||||||
if (video.duration > 0 && clipSegments.length === 0) {
|
if (video.duration > 0 && clipSegments.length === 0) {
|
||||||
logger.debug('Safari: Successfully initialized metadata, creating default segment');
|
logger.debug('Safari: Successfully initialized metadata, creating default segment');
|
||||||
|
|
||||||
const defaultSegment: Segment = {
|
const defaultSegment: Segment = {
|
||||||
id: 1,
|
id: 1,
|
||||||
chapterTitle: '',
|
chapterTitle: '',
|
||||||
@@ -286,14 +293,14 @@ const useVideoChapters = () => {
|
|||||||
setDuration(video.duration);
|
setDuration(video.duration);
|
||||||
setTrimEnd(video.duration);
|
setTrimEnd(video.duration);
|
||||||
setClipSegments([defaultSegment]);
|
setClipSegments([defaultSegment]);
|
||||||
|
|
||||||
const initialState: EditorState = {
|
const initialState: EditorState = {
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: video.duration,
|
trimEnd: video.duration,
|
||||||
splitPoints: [],
|
splitPoints: [],
|
||||||
clipSegments: [defaultSegment],
|
clipSegments: [defaultSegment],
|
||||||
};
|
};
|
||||||
|
|
||||||
setHistory([initialState]);
|
setHistory([initialState]);
|
||||||
setHistoryPosition(0);
|
setHistoryPosition(0);
|
||||||
}
|
}
|
||||||
@@ -315,7 +322,7 @@ const useVideoChapters = () => {
|
|||||||
// Add listeners for various user interactions
|
// Add listeners for various user interactions
|
||||||
document.addEventListener('click', handleUserInteraction);
|
document.addEventListener('click', handleUserInteraction);
|
||||||
document.addEventListener('keydown', handleUserInteraction);
|
document.addEventListener('keydown', handleUserInteraction);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('click', handleUserInteraction);
|
document.removeEventListener('click', handleUserInteraction);
|
||||||
document.removeEventListener('keydown', handleUserInteraction);
|
document.removeEventListener('keydown', handleUserInteraction);
|
||||||
@@ -332,7 +339,7 @@ const useVideoChapters = () => {
|
|||||||
// This play/pause will trigger metadata loading in Safari
|
// This play/pause will trigger metadata loading in Safari
|
||||||
await video.play();
|
await video.play();
|
||||||
video.pause();
|
video.pause();
|
||||||
|
|
||||||
// The metadata events should fire now and initialize segments
|
// The metadata events should fire now and initialize segments
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -564,8 +571,11 @@ const useVideoChapters = () => {
|
|||||||
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
|
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Renumber all segments to ensure proper chronological naming
|
||||||
|
const renumberedSegments = renumberAllSegments(e.detail.segments);
|
||||||
|
|
||||||
// Update segment state immediately for UI feedback
|
// Update segment state immediately for UI feedback
|
||||||
setClipSegments(e.detail.segments);
|
setClipSegments(renumberedSegments);
|
||||||
|
|
||||||
// Always save state to history for non-intermediate actions
|
// Always save state to history for non-intermediate actions
|
||||||
if (isSignificantChange) {
|
if (isSignificantChange) {
|
||||||
@@ -573,7 +583,7 @@ const useVideoChapters = () => {
|
|||||||
// ensure we capture the state properly
|
// ensure we capture the state properly
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Deep clone to ensure state is captured correctly
|
// Deep clone to ensure state is captured correctly
|
||||||
const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
|
const segmentsClone = JSON.parse(JSON.stringify(renumberedSegments));
|
||||||
|
|
||||||
// Create a complete state snapshot
|
// Create a complete state snapshot
|
||||||
const stateWithAction: EditorState = {
|
const stateWithAction: EditorState = {
|
||||||
@@ -919,10 +929,10 @@ const useVideoChapters = () => {
|
|||||||
const singleChapter = backendChapters[0];
|
const singleChapter = backendChapters[0];
|
||||||
const startSeconds = parseTimeToSeconds(singleChapter.startTime);
|
const startSeconds = parseTimeToSeconds(singleChapter.startTime);
|
||||||
const endSeconds = parseTimeToSeconds(singleChapter.endTime);
|
const endSeconds = parseTimeToSeconds(singleChapter.endTime);
|
||||||
|
|
||||||
// Check if this single chapter spans the entire video (within 0.1 second tolerance)
|
// Check if this single chapter spans the entire video (within 0.1 second tolerance)
|
||||||
const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
|
const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
|
||||||
|
|
||||||
if (isFullVideoChapter) {
|
if (isFullVideoChapter) {
|
||||||
logger.debug('Manual save: Single chapter spans full video - sending empty array');
|
logger.debug('Manual save: Single chapter spans full video - sending empty array');
|
||||||
backendChapters = [];
|
backendChapters = [];
|
||||||
|
|||||||
@@ -82,27 +82,24 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--foreground, #333);
|
color: var(--foreground, #333);
|
||||||
margin: 0;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-chapters-button {
|
.save-chapters-button {
|
||||||
display: flex;
|
color: #ffffff;
|
||||||
align-items: center;
|
background: #059669;
|
||||||
gap: 0.5rem;
|
border-radius: 0.25rem;
|
||||||
padding: 0.5rem 1rem;
|
font-size: 0.75rem;
|
||||||
background-color: #3b82f6;
|
padding: 0.25rem 0.5rem;
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
border: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
min-width: fit-content;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #2563eb;
|
background-color: #059669;
|
||||||
transform: translateY(-1px);
|
box-shadow: 0 4px 6px -1px rgba(5, 150, 105, 0.3);
|
||||||
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.has-changes {
|
&.has-changes {
|
||||||
@@ -205,9 +202,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
border-color: #3b82f6;
|
border-color: #059669;
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
|
||||||
background-color: rgba(59, 130, 246, 0.05);
|
background-color: rgba(5, 150, 105, 0.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,29 +284,68 @@
|
|||||||
color: rgba(51, 51, 51, 0.7);
|
color: rgba(51, 51, 51, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Generate 20 shades of #059669 (rgb(5, 150, 105)) */
|
||||||
|
/* Base color: #059669 = rgb(5, 150, 105) */
|
||||||
|
/* Creating variations from lighter to darker */
|
||||||
.segment-color-1 {
|
.segment-color-1 {
|
||||||
background-color: rgba(59, 130, 246, 0.15);
|
background-color: rgba(167, 243, 208, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-2 {
|
.segment-color-2 {
|
||||||
background-color: rgba(16, 185, 129, 0.15);
|
background-color: rgba(134, 239, 172, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-3 {
|
.segment-color-3 {
|
||||||
background-color: rgba(245, 158, 11, 0.15);
|
background-color: rgba(101, 235, 136, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-4 {
|
.segment-color-4 {
|
||||||
background-color: rgba(239, 68, 68, 0.15);
|
background-color: rgba(68, 231, 100, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-5 {
|
.segment-color-5 {
|
||||||
background-color: rgba(139, 92, 246, 0.15);
|
background-color: rgba(35, 227, 64, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-6 {
|
.segment-color-6 {
|
||||||
background-color: rgba(236, 72, 153, 0.15);
|
background-color: rgba(20, 207, 54, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-7 {
|
.segment-color-7 {
|
||||||
background-color: rgba(6, 182, 212, 0.15);
|
background-color: rgba(15, 187, 48, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-8 {
|
.segment-color-8 {
|
||||||
background-color: rgba(250, 204, 21, 0.15);
|
background-color: rgba(10, 167, 42, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-9 {
|
||||||
|
background-color: rgba(5, 150, 105, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-10 {
|
||||||
|
background-color: rgba(4, 135, 95, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-11 {
|
||||||
|
background-color: rgba(3, 120, 85, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-12 {
|
||||||
|
background-color: rgba(2, 105, 75, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-13 {
|
||||||
|
background-color: rgba(2, 90, 65, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-14 {
|
||||||
|
background-color: rgba(1, 75, 55, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-15 {
|
||||||
|
background-color: rgba(1, 66, 48, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-16 {
|
||||||
|
background-color: rgba(1, 57, 41, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-17 {
|
||||||
|
background-color: rgba(1, 48, 34, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-18 {
|
||||||
|
background-color: rgba(0, 39, 27, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-19 {
|
||||||
|
background-color: rgba(0, 30, 20, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-20 {
|
||||||
|
background-color: rgba(0, 21, 13, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive styles */
|
/* Responsive styles */
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
.ios-notification-icon {
|
.ios-notification-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #0066cc;
|
color: #059669;
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ios-desktop-mode-btn {
|
.ios-desktop-mode-btn {
|
||||||
background-color: #0066cc;
|
background-color: #059669;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@@ -92,12 +92,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-button-primary {
|
.modal-button-primary {
|
||||||
background-color: #0066cc;
|
background-color: #059669;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-button-primary:hover {
|
.modal-button-primary:hover {
|
||||||
background-color: #0055aa;
|
background-color: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-button-secondary {
|
.modal-button-secondary {
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
.spinner {
|
.spinner {
|
||||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-top: 4px solid #0066cc;
|
border-top: 4px solid #059669;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: #0066cc;
|
background-color: #059669;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@@ -258,12 +258,12 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
background-color: #0066cc;
|
background-color: #059669;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.centered-choice:hover {
|
.centered-choice:hover {
|
||||||
background-color: #0055aa;
|
background-color: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
@@ -300,7 +300,7 @@
|
|||||||
|
|
||||||
.countdown {
|
.countdown {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #0066cc;
|
color: #059669;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
#chapters-editor-root {
|
#chapters-editor-root {
|
||||||
|
.timeline-header-container {
|
||||||
|
margin-left: 1rem;
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #059669;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-container-card {
|
.timeline-container-card {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
@@ -11,6 +23,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid rgba(16, 185, 129, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-title {
|
.timeline-title {
|
||||||
@@ -20,7 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timeline-title-text {
|
.timeline-title-text {
|
||||||
font-weight: 700;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-time {
|
.current-time {
|
||||||
@@ -48,10 +62,11 @@
|
|||||||
.timeline-container {
|
.timeline-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
background-color: #fafbfc;
|
background-color: #e2ede4;
|
||||||
height: 70px;
|
height: 70px;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-marker {
|
.timeline-marker {
|
||||||
@@ -194,7 +209,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
background-color: rgba(16, 185, 129, 0.6);
|
||||||
color: white;
|
color: white;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
@@ -202,15 +217,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.clip-segment:hover .clip-segment-info {
|
.clip-segment:hover .clip-segment-info {
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(16, 185, 129, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clip-segment.selected .clip-segment-info {
|
.clip-segment.selected .clip-segment-info {
|
||||||
background-color: rgba(59, 130, 246, 0.5);
|
background-color: rgba(5, 150, 105, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clip-segment.selected:hover .clip-segment-info {
|
.clip-segment.selected:hover .clip-segment-info {
|
||||||
background-color: rgba(59, 130, 246, 0.4);
|
background-color: rgba(5, 150, 105, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clip-segment-name {
|
.clip-segment-name {
|
||||||
@@ -540,7 +555,7 @@
|
|||||||
.save-copy-button,
|
.save-copy-button,
|
||||||
.save-segments-button {
|
.save-segments-button {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: #0066cc;
|
background: #059669;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
@@ -713,7 +728,7 @@
|
|||||||
height: 50px;
|
height: 50px;
|
||||||
border: 5px solid rgba(0, 0, 0, 0.1);
|
border: 5px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-top-color: #0066cc;
|
border-top-color: #059669;
|
||||||
animation: spin 1s ease-in-out infinite;
|
animation: spin 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,7 +768,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.75rem 1.25rem;
|
padding: 0.75rem 1.25rem;
|
||||||
background-color: #0066cc;
|
background-color: #059669;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -766,7 +781,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-choice-button:hover {
|
.modal-choice-button:hover {
|
||||||
background-color: #0056b3;
|
background-color:rgb(7, 119, 84);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-choice-button svg {
|
.modal-choice-button svg {
|
||||||
@@ -941,7 +956,6 @@
|
|||||||
|
|
||||||
.save-chapters-button:hover {
|
.save-chapters-button:hover {
|
||||||
background-color: #2563eb;
|
background-color: #2563eb;
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
|
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -309,6 +309,11 @@ const App = () => {
|
|||||||
canRedo={historyPosition < history.length - 1}
|
canRedo={historyPosition < history.length - 1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Timeline Header */}
|
||||||
|
<div className="timeline-header-container">
|
||||||
|
<h2 className="timeline-header-title">Trim or Split</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Timeline Controls */}
|
{/* Timeline Controls */}
|
||||||
<TimelineControls
|
<TimelineControls
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
|
|||||||
|
|
||||||
// Generate the same color background for a segment as shown in the timeline
|
// Generate the same color background for a segment as shown in the timeline
|
||||||
const getSegmentColorClass = (index: number) => {
|
const getSegmentColorClass = (index: number) => {
|
||||||
// Return CSS class based on index modulo 8
|
// Return CSS class based on index modulo 20
|
||||||
// This matches the CSS nth-child selectors in the timeline
|
// This matches the CSS classes for up to 20 segments
|
||||||
return `segment-default-color segment-color-${(index % 8) + 1}`;
|
return `segment-default-color segment-color-${(index % 20) + 1}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.segment-thumbnail {
|
.segment-thumbnail {
|
||||||
|
display: none;
|
||||||
width: 4rem;
|
width: 4rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
@@ -129,7 +130,7 @@
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background-color: #f3f4f6;
|
background-color: #f3f4f6;
|
||||||
padding: 0 0.5rem;
|
padding: 0;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
@@ -169,28 +170,67 @@
|
|||||||
color: rgba(51, 51, 51, 0.7);
|
color: rgba(51, 51, 51, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Generate 20 shades of #2563eb (rgb(37, 99, 235)) */
|
||||||
|
/* Base color: #2563eb = rgb(37, 99, 235) */
|
||||||
|
/* Creating variations from lighter to darker */
|
||||||
.segment-color-1 {
|
.segment-color-1 {
|
||||||
background-color: rgba(59, 130, 246, 0.15);
|
background-color: rgba(147, 179, 247, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-2 {
|
.segment-color-2 {
|
||||||
background-color: rgba(16, 185, 129, 0.15);
|
background-color: rgba(129, 161, 243, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-3 {
|
.segment-color-3 {
|
||||||
background-color: rgba(245, 158, 11, 0.15);
|
background-color: rgba(111, 143, 239, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-4 {
|
.segment-color-4 {
|
||||||
background-color: rgba(239, 68, 68, 0.15);
|
background-color: rgba(93, 125, 237, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-5 {
|
.segment-color-5 {
|
||||||
background-color: rgba(139, 92, 246, 0.15);
|
background-color: rgba(75, 107, 235, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-6 {
|
.segment-color-6 {
|
||||||
background-color: rgba(236, 72, 153, 0.15);
|
background-color: rgba(65, 99, 235, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-7 {
|
.segment-color-7 {
|
||||||
background-color: rgba(6, 182, 212, 0.15);
|
background-color: rgba(55, 91, 235, 0.2);
|
||||||
}
|
}
|
||||||
.segment-color-8 {
|
.segment-color-8 {
|
||||||
background-color: rgba(250, 204, 21, 0.15);
|
background-color: rgba(45, 83, 235, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-9 {
|
||||||
|
background-color: rgba(37, 99, 235, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-10 {
|
||||||
|
background-color: rgba(33, 89, 215, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-11 {
|
||||||
|
background-color: rgba(29, 79, 195, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-12 {
|
||||||
|
background-color: rgba(25, 69, 175, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-13 {
|
||||||
|
background-color: rgba(21, 59, 155, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-14 {
|
||||||
|
background-color: rgba(17, 49, 135, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-15 {
|
||||||
|
background-color: rgba(15, 43, 119, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-16 {
|
||||||
|
background-color: rgba(13, 37, 103, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-17 {
|
||||||
|
background-color: rgba(11, 31, 87, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-18 {
|
||||||
|
background-color: rgba(9, 25, 71, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-19 {
|
||||||
|
background-color: rgba(7, 19, 55, 0.2);
|
||||||
|
}
|
||||||
|
.segment-color-20 {
|
||||||
|
background-color: rgba(5, 13, 39, 0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
#video-editor-trim-root {
|
#video-editor-trim-root {
|
||||||
|
.timeline-header-container {
|
||||||
|
margin-left: 1rem;
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2563eb;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-container-card {
|
.timeline-container-card {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
@@ -11,6 +23,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid rgba(59, 130, 246, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-title {
|
.timeline-title {
|
||||||
@@ -20,7 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timeline-title-text {
|
.timeline-title-text {
|
||||||
font-weight: 700;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-time {
|
.current-time {
|
||||||
@@ -48,10 +62,11 @@
|
|||||||
.timeline-container {
|
.timeline-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
background-color: #fafbfc;
|
background-color: #eff6ff;
|
||||||
height: 70px;
|
height: 70px;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-marker {
|
.timeline-marker {
|
||||||
@@ -194,7 +209,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
background-color: rgba(59, 130, 246, 0.6);
|
||||||
color: white;
|
color: white;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
@@ -202,15 +217,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.clip-segment:hover .clip-segment-info {
|
.clip-segment:hover .clip-segment-info {
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(59, 130, 246, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clip-segment.selected .clip-segment-info {
|
.clip-segment.selected .clip-segment-info {
|
||||||
background-color: rgba(59, 130, 246, 0.5);
|
background-color: rgba(37, 99, 235, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clip-segment.selected:hover .clip-segment-info {
|
.clip-segment.selected:hover .clip-segment-info {
|
||||||
background-color: rgba(59, 130, 246, 0.4);
|
background-color: rgba(37, 99, 235, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clip-segment-name {
|
.clip-segment-name {
|
||||||
|
|||||||
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>
|
</div>
|
||||||
`;
|
`;
|
||||||
textEl.textContent = 'Pause';
|
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
|
// Clear any text content in the text element
|
||||||
@@ -239,6 +287,11 @@ class SeekIndicator extends Component {
|
|||||||
this.showTimeout = setTimeout(() => {
|
this.showTimeout = setTimeout(() => {
|
||||||
this.hide();
|
this.hide();
|
||||||
}, 500);
|
}, 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.authorThumbnail = options.authorThumbnail || '';
|
||||||
this.videoTitle = options.videoTitle || 'Video';
|
this.videoTitle = options.videoTitle || 'Video';
|
||||||
this.videoUrl = options.videoUrl || '';
|
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
|
// Initialize after player is ready
|
||||||
this.player().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
|
// Create avatar container
|
||||||
if (this.authorThumbnail) {
|
if (this.authorThumbnail && this.showUserAvatar) {
|
||||||
const avatarContainer = document.createElement('div');
|
const avatarContainer = document.createElement('div');
|
||||||
avatarContainer.className = 'embed-avatar-container';
|
avatarContainer.className = 'embed-avatar-container';
|
||||||
avatarContainer.style.cssText = `
|
avatarContainer.style.cssText = `
|
||||||
@@ -125,7 +137,7 @@ class EmbedInfoOverlay extends Component {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (this.videoUrl) {
|
if (this.videoUrl && this.linkTitle) {
|
||||||
const titleLink = document.createElement('a');
|
const titleLink = document.createElement('a');
|
||||||
titleLink.href = this.videoUrl;
|
titleLink.href = this.videoUrl;
|
||||||
titleLink.target = '_blank';
|
titleLink.target = '_blank';
|
||||||
@@ -186,10 +198,16 @@ class EmbedInfoOverlay extends Component {
|
|||||||
const player = this.player();
|
const player = this.player();
|
||||||
const overlay = this.el();
|
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
|
// Sync overlay visibility with control bar visibility
|
||||||
const updateOverlayVisibility = () => {
|
const updateOverlayVisibility = () => {
|
||||||
const controlBar = player.getChild('controlBar');
|
|
||||||
|
|
||||||
if (!player.hasStarted()) {
|
if (!player.hasStarted()) {
|
||||||
// Show overlay when video hasn't started (poster is showing) - like before
|
// Show overlay when video hasn't started (poster is showing) - like before
|
||||||
overlay.style.opacity = '1';
|
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 videojs from 'video.js';
|
||||||
import 'video.js/dist/video-js.css';
|
import 'video.js/dist/video-js.css';
|
||||||
import '../../styles/embed.css';
|
import '../../styles/embed.css';
|
||||||
@@ -17,6 +17,7 @@ import CustomRemainingTime from '../controls/CustomRemainingTime';
|
|||||||
import CustomChaptersOverlay from '../controls/CustomChaptersOverlay';
|
import CustomChaptersOverlay from '../controls/CustomChaptersOverlay';
|
||||||
import CustomSettingsMenu from '../controls/CustomSettingsMenu';
|
import CustomSettingsMenu from '../controls/CustomSettingsMenu';
|
||||||
import SeekIndicator from '../controls/SeekIndicator';
|
import SeekIndicator from '../controls/SeekIndicator';
|
||||||
|
import VideoContextMenu from '../overlays/VideoContextMenu';
|
||||||
import UserPreferences from '../../utils/UserPreferences';
|
import UserPreferences from '../../utils/UserPreferences';
|
||||||
import PlayerConfig from '../../config/playerConfig';
|
import PlayerConfig from '../../config/playerConfig';
|
||||||
import { AutoplayHandler } from '../../utils/AutoplayHandler';
|
import { AutoplayHandler } from '../../utils/AutoplayHandler';
|
||||||
@@ -169,7 +170,7 @@ const enableStandardButtonTooltips = (player) => {
|
|||||||
}, 500); // Delay to ensure all components are ready
|
}, 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 videoRef = useRef(null);
|
||||||
const playerRef = useRef(null); // Track the player instance
|
const playerRef = useRef(null); // Track the player instance
|
||||||
const userPreferences = useRef(new UserPreferences()); // User preferences 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 keyboardHandler = useRef(null); // Keyboard handler instance
|
||||||
const playbackEventHandler = useRef(null); // Playback event 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)
|
// Check if this is an embed player (disable next video and autoplay features)
|
||||||
const isEmbedPlayer = videoId === 'video-embed';
|
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
|
// Environment-based development mode configuration
|
||||||
const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app');
|
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(
|
const mediaData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
typeof window !== 'undefined' && window.MEDIA_DATA
|
typeof window !== 'undefined' && window.MEDIA_DATA
|
||||||
@@ -214,12 +207,37 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
|||||||
},
|
},
|
||||||
siteUrl: 'https://deic.mediacms.io',
|
siteUrl: 'https://deic.mediacms.io',
|
||||||
nextLink: 'https://deic.mediacms.io/view?m=elygiagorgechania',
|
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
|
// Define chapters as JSON object
|
||||||
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
|
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
|
||||||
// CONDITIONAL LOGIC:
|
// CONDITIONAL LOGIC:
|
||||||
@@ -531,8 +549,6 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
|||||||
isPlayList: mediaData?.isPlayList,
|
isPlayList: mediaData?.isPlayList,
|
||||||
related_media: mediaData.data?.related_media || [],
|
related_media: mediaData.data?.related_media || [],
|
||||||
nextLink: mediaData?.nextLink || null,
|
nextLink: mediaData?.nextLink || null,
|
||||||
urlAutoplay: mediaData?.urlAutoplay || true,
|
|
||||||
urlMuted: mediaData?.urlMuted || false,
|
|
||||||
sources: getVideoSources(),
|
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(() => {
|
useEffect(() => {
|
||||||
// Only initialize if we don't already have a player and element exists
|
// Only initialize if we don't already have a player and element exists
|
||||||
if (videoRef.current && !playerRef.current) {
|
if (videoRef.current && !playerRef.current) {
|
||||||
@@ -1078,6 +1300,9 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
|||||||
currentVideo,
|
currentVideo,
|
||||||
relatedVideos,
|
relatedVideos,
|
||||||
goToNextVideo,
|
goToNextVideo,
|
||||||
|
showRelated: finalShowRelated,
|
||||||
|
showUserAvatar: finalShowUserAvatar,
|
||||||
|
linkTitle: finalLinkTitle,
|
||||||
});
|
});
|
||||||
customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup
|
customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup
|
||||||
|
|
||||||
@@ -1098,8 +1323,8 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle URL timestamp parameter
|
// Handle URL timestamp parameter
|
||||||
if (mediaData.urlTimestamp !== null && mediaData.urlTimestamp >= 0) {
|
if (finalTimestamp !== null && finalTimestamp >= 0) {
|
||||||
const timestamp = mediaData.urlTimestamp;
|
const timestamp = finalTimestamp;
|
||||||
|
|
||||||
// Wait for video metadata to be loaded before seeking
|
// Wait for video metadata to be loaded before seeking
|
||||||
if (playerRef.current.readyState() >= 1) {
|
if (playerRef.current.readyState() >= 1) {
|
||||||
@@ -1997,6 +2222,10 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
|||||||
authorThumbnail: currentVideo.author_thumbnail,
|
authorThumbnail: currentVideo.author_thumbnail,
|
||||||
videoTitle: currentVideo.title,
|
videoTitle: currentVideo.title,
|
||||||
videoUrl: currentVideo.url,
|
videoUrl: currentVideo.url,
|
||||||
|
showTitle: finalShowTitle,
|
||||||
|
showRelated: finalShowRelated,
|
||||||
|
showUserAvatar: finalShowUserAvatar,
|
||||||
|
linkTitle: finalLinkTitle,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// END: Add Embed Info Overlay Component
|
// END: Add Embed Info Overlay Component
|
||||||
@@ -2083,52 +2312,113 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
|||||||
// Make the video element focusable
|
// Make the video element focusable
|
||||||
const videoElement = playerRef.current.el();
|
const videoElement = playerRef.current.el();
|
||||||
videoElement.setAttribute('tabindex', '0');
|
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);
|
//}, 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 (
|
return (
|
||||||
<video
|
<>
|
||||||
ref={videoRef}
|
<video
|
||||||
id={videoId}
|
ref={videoRef}
|
||||||
controls={true}
|
id={videoId}
|
||||||
className={`video-js vjs-fluid vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`}
|
controls={true}
|
||||||
preload="auto"
|
className={`video-js ${isEmbedPlayer ? 'vjs-fill' : 'vjs-fluid'} vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`}
|
||||||
poster={currentVideo.poster}
|
preload="auto"
|
||||||
tabIndex="0"
|
poster={currentVideo.poster}
|
||||||
>
|
tabIndex="0"
|
||||||
{/* <source src="/videos/sample-video.mp4" type="video/mp4" />
|
>
|
||||||
<source src="/videos/sample-video.webm" type="video/webm" /> */}
|
{/* <source src="/videos/sample-video.mp4" type="video/mp4" />
|
||||||
<p className="vjs-no-js">
|
<source src="/videos/sample-video.webm" type="video/webm" /> */}
|
||||||
To view this video please enable JavaScript, and consider upgrading to a web browser that
|
<p className="vjs-no-js">
|
||||||
<a href="https://videojs.com/html5-video-support/" target="_blank">
|
To view this video please enable JavaScript, and consider upgrading to a web browser that
|
||||||
supports HTML5 video
|
<a href="https://videojs.com/html5-video-support/" target="_blank">
|
||||||
</a>
|
supports HTML5 video
|
||||||
</p>
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Add subtitle tracks */}
|
{/* Add subtitle tracks */}
|
||||||
{/* {subtitleTracks &&
|
{/* {subtitleTracks &&
|
||||||
subtitleTracks.map((track, index) => (
|
subtitleTracks.map((track, index) => (
|
||||||
<track
|
<track
|
||||||
key={index}
|
key={index}
|
||||||
kind={track.kind}
|
kind={track.kind}
|
||||||
src={track.src}
|
src={track.src}
|
||||||
srcLang={track.srclang}
|
srcLang={track.srclang}
|
||||||
label={track.label}
|
label={track.label}
|
||||||
default={track.default}
|
default={track.default}
|
||||||
/>
|
/>
|
||||||
))} */}
|
))} */}
|
||||||
{/*
|
{/*
|
||||||
<track kind="chapters" src="/sample-chapters.vtt" /> */}
|
<track kind="chapters" src="/sample-chapters.vtt" /> */}
|
||||||
{/* Add chapters track */}
|
{/* Add chapters track */}
|
||||||
{/* {chaptersData &&
|
{/* {chaptersData &&
|
||||||
chaptersData.length > 0 &&
|
chaptersData.length > 0 &&
|
||||||
(console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */}
|
(console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */}
|
||||||
</video>
|
</video>
|
||||||
|
<VideoContextMenu
|
||||||
|
visible={contextMenuVisible}
|
||||||
|
position={contextMenuPosition}
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
onCopyVideoUrl={handleCopyVideoUrl}
|
||||||
|
onCopyVideoUrlAtTime={handleCopyVideoUrlAtTime}
|
||||||
|
onCopyEmbedCode={handleCopyEmbedCode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,17 @@ export class EndScreenHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleVideoEnded() {
|
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
|
// For embed players, show big play button when video ends
|
||||||
if (isEmbedPlayer) {
|
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
|
// Keep controls active after video ends
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.player && !this.player.isDisposed()) {
|
if (this.player && !this.player.isDisposed()) {
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
{
|
{
|
||||||
"presets": [
|
"presets": [
|
||||||
"@babel/react", ["@babel/env", {
|
"@babel/react",
|
||||||
"modules": false,
|
[
|
||||||
"useBuiltIns": "usage",
|
"@babel/env",
|
||||||
"corejs": 3,
|
{
|
||||||
"targets": {
|
"modules": false,
|
||||||
"browsers": ["defaults"]
|
"useBuiltIns": "usage",
|
||||||
}
|
"corejs": 3,
|
||||||
}]
|
"targets": {
|
||||||
]
|
"browsers": ["defaults"]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"test": {
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"@babel/env",
|
||||||
|
{
|
||||||
|
"targets": { "node": "current" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,3 +27,39 @@ Open in browser: [http://localhost:8088](http://localhost:8088)
|
|||||||
Generates the folder "**_frontend/dist_**".
|
Generates the folder "**_frontend/dist_**".
|
||||||
|
|
||||||
Copy folders and files from "**_frontend/dist/static_**" into "**_static_**".
|
Copy folders and files from "**_frontend/dist/static_**" into "**_static_**".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test Scripts
|
||||||
|
|
||||||
|
#### test
|
||||||
|
|
||||||
|
Run all unit tests once.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
#### test-watch
|
||||||
|
|
||||||
|
Run tests in watch mode for development.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test-watch
|
||||||
|
```
|
||||||
|
|
||||||
|
#### test-coverage
|
||||||
|
|
||||||
|
Run tests with coverage reporting in `./coverage` folder.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test-coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
#### test-coverage-watch
|
||||||
|
|
||||||
|
Run tests with coverage in watch mode.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test-coverage-watch
|
||||||
|
```
|
||||||
|
|||||||
9
frontend/jest.config.js
Normal file
9
frontend/jest.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import("jest").Config} **/
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': 'ts-jest',
|
||||||
|
'^.+\\.jsx?$': 'babel-jest',
|
||||||
|
},
|
||||||
|
collectCoverageFrom: ['src/**'],
|
||||||
|
};
|
||||||
@@ -1,57 +1,69 @@
|
|||||||
{
|
{
|
||||||
"name": "mediacms-frontend",
|
"name": "mediacms-frontend",
|
||||||
"version": "0.9.1",
|
"version": "0.9.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "",
|
"license": "",
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "mediacms-scripts development --config=./config/mediacms.config.js --host=0.0.0.0 --port=8088",
|
"start": "mediacms-scripts development --config=./config/mediacms.config.js --host=0.0.0.0 --port=8088",
|
||||||
"dist": "mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist"
|
"dist": "mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist",
|
||||||
},
|
"test": "jest",
|
||||||
"browserslist": [
|
"test-coverage": "npx rimraf ./coverage && jest --coverage",
|
||||||
"cover 99.5%"
|
"test-coverage-watch": "npm run test-coverage -- --watchAll",
|
||||||
],
|
"test-watch": "jest --watch"
|
||||||
"devDependencies": {
|
},
|
||||||
"@babel/core": "^7.26.9",
|
"browserslist": [
|
||||||
"@babel/preset-env": "^7.26.9",
|
"cover 99.5%"
|
||||||
"@babel/preset-react": "^7.26.3",
|
],
|
||||||
"@types/minimatch": "^5.1.2",
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.10",
|
"@babel/core": "^7.26.9",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@babel/preset-env": "^7.26.9",
|
||||||
"autoprefixer": "^10.4.21",
|
"@babel/preset-react": "^7.26.3",
|
||||||
"babel-loader": "^10.0.0",
|
"@types/flux": "^3.1.15",
|
||||||
"compass-mixins": "^0.12.12",
|
"@types/jest": "^29.5.12",
|
||||||
"copy-webpack-plugin": "^13.0.0",
|
"@types/minimatch": "^5.1.2",
|
||||||
"core-js": "^3.41.0",
|
"@types/react": "^19.0.10",
|
||||||
"css-loader": "^7.1.2",
|
"@types/react-dom": "^19.0.4",
|
||||||
"dotenv": "^16.4.7",
|
"@types/url-parse": "^1.4.11",
|
||||||
"ejs": "^3.1.10",
|
"autoprefixer": "^10.4.21",
|
||||||
"ejs-compiled-loader": "^3.1.0",
|
"babel-jest": "^30.2.0",
|
||||||
"mediacms-scripts": "file:packages/scripts",
|
"babel-loader": "^10.0.0",
|
||||||
"postcss-loader": "^8.1.1",
|
"compass-mixins": "^0.12.12",
|
||||||
"prettier": "^3.5.3",
|
"copy-webpack-plugin": "^13.0.0",
|
||||||
"prop-types": "^15.8.1",
|
"core-js": "^3.41.0",
|
||||||
"sass": "^1.85.1",
|
"css-loader": "^7.1.2",
|
||||||
"sass-loader": "^16.0.5",
|
"dotenv": "^16.4.7",
|
||||||
"ts-loader": "^9.5.2",
|
"ejs": "^3.1.10",
|
||||||
"typescript": "^5.8.2",
|
"ejs-compiled-loader": "^3.1.0",
|
||||||
"url-loader": "^4.1.1",
|
"jest": "^29.7.0",
|
||||||
"webpack": "^5.98.0"
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
},
|
"jsdom": "^27.3.0",
|
||||||
"dependencies": {
|
"mediacms-scripts": "file:packages/scripts",
|
||||||
"@react-pdf-viewer/core": "^3.9.0",
|
"postcss-loader": "^8.1.1",
|
||||||
"@react-pdf-viewer/default-layout": "^3.9.0",
|
"prettier": "^3.5.3",
|
||||||
"axios": "^1.8.2",
|
"prop-types": "^15.8.1",
|
||||||
"flux": "^4.0.4",
|
"sass": "^1.85.1",
|
||||||
"normalize.css": "^8.0.1",
|
"sass-loader": "^16.0.5",
|
||||||
"pdfjs-dist": "3.4.120",
|
"ts-jest": "^29.2.5",
|
||||||
"react": "^17.0.2",
|
"ts-loader": "^9.5.2",
|
||||||
"react-dom": "^17.0.2",
|
"typescript": "^5.9.3",
|
||||||
"react-mentions": "^4.3.1",
|
"url-loader": "^4.1.1",
|
||||||
"sortablejs": "^1.13.0",
|
"webpack": "^5.98.0"
|
||||||
"timeago.js": "^4.0.2",
|
},
|
||||||
"url-parse": "^1.5.10"
|
"dependencies": {
|
||||||
}
|
"@react-pdf-viewer/core": "^3.9.0",
|
||||||
|
"@react-pdf-viewer/default-layout": "^3.9.0",
|
||||||
|
"axios": "^1.8.2",
|
||||||
|
"flux": "^4.0.4",
|
||||||
|
"normalize.css": "^8.0.1",
|
||||||
|
"pdfjs-dist": "3.4.120",
|
||||||
|
"react": "^17.0.2",
|
||||||
|
"react-dom": "^17.0.2",
|
||||||
|
"react-mentions": "^4.3.1",
|
||||||
|
"sortablejs": "^1.13.0",
|
||||||
|
"timeago.js": "^4.0.2",
|
||||||
|
"url-parse": "^1.5.10"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import './BulkActionCategoryModal.scss';
|
import './BulkActionCategoryModal.scss';
|
||||||
import { translateString } from '../utils/helpers/';
|
import { translateString } from '../utils/helpers/';
|
||||||
|
import { inEmbeddedApp } from '../utils/helpers/embeddedApp';
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -24,6 +25,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
|||||||
onError,
|
onError,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
}) => {
|
}) => {
|
||||||
|
const isLmsMode = inEmbeddedApp();
|
||||||
const [existingCategories, setExistingCategories] = useState<Category[]>([]);
|
const [existingCategories, setExistingCategories] = useState<Category[]>([]);
|
||||||
const [allCategories, setAllCategories] = useState<Category[]>([]);
|
const [allCategories, setAllCategories] = useState<Category[]>([]);
|
||||||
const [categoriesToAdd, setCategoriesToAdd] = useState<Category[]>([]);
|
const [categoriesToAdd, setCategoriesToAdd] = useState<Category[]>([]);
|
||||||
@@ -66,20 +68,27 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
|||||||
const existingData = await existingResponse.json();
|
const existingData = await existingResponse.json();
|
||||||
const existing = existingData.results || [];
|
const existing = existingData.results || [];
|
||||||
|
|
||||||
// Fetch all categories
|
// Fetch all categories (or LMS courses only in embed mode)
|
||||||
const allResponse = await fetch('/api/v1/categories');
|
const categoriesUrl = isLmsMode
|
||||||
|
? '/api/v1/categories/contributor?lms_courses_only=true'
|
||||||
|
: '/api/v1/categories';
|
||||||
|
const allResponse = await fetch(categoriesUrl);
|
||||||
if (!allResponse.ok) {
|
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 allData = await allResponse.json();
|
||||||
const all = allData.results || allData;
|
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);
|
setAllCategories(all);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching categories:', 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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -126,7 +135,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!addResponse.ok) {
|
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) {
|
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();
|
onCancel();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing categories:', 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 {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
@@ -184,7 +193,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
|||||||
<div className="category-modal-overlay">
|
<div className="category-modal-overlay">
|
||||||
<div className="category-modal">
|
<div className="category-modal">
|
||||||
<div className="category-modal-header">
|
<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 className="category-modal-close" onClick={onCancel}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -192,14 +201,14 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
|||||||
|
|
||||||
<div className="category-modal-content">
|
<div className="category-modal-content">
|
||||||
<div className="category-panel">
|
<div className="category-panel">
|
||||||
<h3>{translateString('Categories')}</h3>
|
<h3>{isLmsMode ? translateString('Courses') : translateString('Categories')}</h3>
|
||||||
|
|
||||||
{isLoading ? (
|
{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">
|
<div className="category-list scrollable">
|
||||||
{leftPanelCategories.length === 0 ? (
|
{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) => (
|
leftPanelCategories.map((category) => (
|
||||||
<div
|
<div
|
||||||
@@ -227,11 +236,11 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{isLoading ? (
|
{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">
|
<div className="category-list scrollable">
|
||||||
{rightPanelCategories.length === 0 ? (
|
{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) => {
|
rightPanelCategories.map((category) => {
|
||||||
const isExisting = existingCategories.some((c) => c.uid === category.uid);
|
const isExisting = existingCategories.some((c) => c.uid === category.uid);
|
||||||
@@ -251,7 +260,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
|||||||
removeCategoryFromAddList(category);
|
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 ? '↺' : '×'}
|
{isMarkedForRemoval ? '↺' : '×'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import './BulkActionsDropdown.scss';
|
import './BulkActionsDropdown.scss';
|
||||||
import { translateString } from '../utils/helpers/';
|
import { translateString } from '../utils/helpers/';
|
||||||
|
import { inEmbeddedApp } from '../utils/helpers/embeddedApp';
|
||||||
|
|
||||||
interface BulkActionsDropdownProps {
|
interface BulkActionsDropdownProps {
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
@@ -12,7 +13,7 @@ const BULK_ACTIONS = [
|
|||||||
{ value: 'add-remove-coeditors', label: translateString('Add / Remove Co-Editors'), enabled: true },
|
{ 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-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-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: 'add-remove-tags', label: translateString('Add / Remove Tags'), enabled: true },
|
||||||
{ value: 'enable-comments', label: translateString('Enable Comments'), enabled: true },
|
{ value: 'enable-comments', label: translateString('Enable Comments'), enabled: true },
|
||||||
{ value: 'disable-comments', label: translateString('Disable Comments'), enabled: true },
|
{ value: 'disable-comments', label: translateString('Disable Comments'), enabled: true },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { translateString } from '../utils/helpers/';
|
import { translateString, inSelectMediaEmbedMode } from '../utils/helpers/';
|
||||||
|
|
||||||
interface MediaListHeaderProps {
|
interface MediaListHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -11,10 +11,12 @@ interface MediaListHeaderProps {
|
|||||||
|
|
||||||
export const MediaListHeader: React.FC<MediaListHeaderProps> = (props) => {
|
export const MediaListHeader: React.FC<MediaListHeaderProps> = (props) => {
|
||||||
const viewAllText = props.viewAllText || translateString('VIEW ALL');
|
const viewAllText = props.viewAllText || translateString('VIEW ALL');
|
||||||
|
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={(props.className ? props.className + ' ' : '') + 'media-list-header'} style={props.style}>
|
<div className={(props.className ? props.className + ' ' : '') + 'media-list-header'} style={props.style}>
|
||||||
<h2>{props.title}</h2>
|
<h2>{props.title}</h2>
|
||||||
{props.viewAllLink ? (
|
{!isSelectMediaMode && props.viewAllLink ? (
|
||||||
<h3>
|
<h3>
|
||||||
{' '}
|
{' '}
|
||||||
<a href={props.viewAllLink} title={viewAllText}>
|
<a href={props.viewAllLink} title={viewAllText}>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user