mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
asset viewer now viewing on tabs
Tabs is better for this yay Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
name: E2E tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request: {}
|
||||||
|
push:
|
||||||
|
branches: [main, qas]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
runs-on: [self-hosted, ux]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile=false
|
||||||
|
|
||||||
|
- name: Install Playwright Firefox
|
||||||
|
run: pnpm --filter e2e exec playwright install firefox --with-deps
|
||||||
|
|
||||||
|
- name: Run e2e suite
|
||||||
|
run: pnpm --filter e2e test
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: apps/e2e/playwright-report/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Sweep QA-TEST fixtures
|
||||||
|
if: always()
|
||||||
|
run: node -e "require('./apps/e2e/helpers/cleanup').cleanupQaTestFixtures().then(r => console.log('Removed:', r)).catch(e => { console.error(e); process.exit(0); })"
|
||||||
@@ -28,6 +28,12 @@ yarn-debug.log*
|
|||||||
dist/
|
dist/
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
.last-run.json
|
||||||
|
|
||||||
# ── Uploads / Temp ────────────────────────────────────────────────────────────
|
# ── Uploads / Temp ────────────────────────────────────────────────────────────
|
||||||
uploads/
|
uploads/
|
||||||
tmp/
|
tmp/
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const { Course } = require('../../api/models/courses/courses.mdl');
|
||||||
|
const Unit = require('../../api/models/courses/units.mdl');
|
||||||
|
const Lesson = require('../../api/models/courses/lessons.mdl');
|
||||||
|
const { TaskList, Task } = require('../../api/models/task/task.mdl');
|
||||||
|
const Advertisement = require('../../api/models/advertisements/advertisements.mdl');
|
||||||
|
const TierPlan = require('../../api/models/tiers/tier_plans.mdl');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
const QA_PREFIX = '[QA-TEST';
|
||||||
|
|
||||||
|
const TARGETS = [
|
||||||
|
{ model: Task, column: 'name' },
|
||||||
|
{ model: TaskList, column: 'name' },
|
||||||
|
{ model: Advertisement, column: 'headline' },
|
||||||
|
{ model: Unit, column: 'title' },
|
||||||
|
{ model: Lesson, column: 'title' },
|
||||||
|
{ model: Course, column: 'title' },
|
||||||
|
{ model: TierPlan, column: 'label' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Hard-deletes every QA-TEST-prefixed fixture row across all modules this suite touches. */
|
||||||
|
async function cleanupQaTestFixtures() {
|
||||||
|
const removed = {};
|
||||||
|
for (const { model, column } of TARGETS) {
|
||||||
|
const count = await model.destroy({
|
||||||
|
where: { [column]: { [Op.like]: `${QA_PREFIX}%` } },
|
||||||
|
force: true, // hard delete, not just paranoid soft-delete — mirrors the manual "archive then permanently delete" cleanup flow
|
||||||
|
});
|
||||||
|
if (count) removed[model.name] = count;
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { cleanupQaTestFixtures, QA_PREFIX };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Reuses the app's own Sequelize connection/config instead of duplicating
|
||||||
|
// DB_HOST/DB_PORT/... wiring — env.js pre-loads apps/api/.env so db.config.js
|
||||||
|
// picks up the real dev credentials when it runs its own (no-op, already-set)
|
||||||
|
// dotenv.config() call.
|
||||||
|
require('./env');
|
||||||
|
const sequelize = require('../../api/config/db.config');
|
||||||
|
const Users = require('../../api/models/users/users.mdl');
|
||||||
|
|
||||||
|
async function getUserIdByEmail(email) {
|
||||||
|
const user = await Users.findOne({ where: { email }, attributes: ['user_id'] });
|
||||||
|
if (!user) throw new Error(`No user found for email ${email} — check the dev DB seed / helpers/env.js USERS config.`);
|
||||||
|
return String(user.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPendingOtp(user_id) {
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`SELECT otp_code FROM users WHERE user_id = :uid`,
|
||||||
|
{ replacements: { uid: user_id }, type: sequelize.QueryTypes.SELECT },
|
||||||
|
);
|
||||||
|
return rows[0]?.otp_code ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sequelize, getUserIdByEmail, getPendingOtp };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Loads real dev secrets from apps/api/.env (gitignored — never committed).
|
||||||
|
// Falls back to apps/api/.env-development (git-tracked shared dev creds) for
|
||||||
|
// anything .env doesn't override, matching how apps/api itself resolves config.
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../../api/.env') });
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../../api/.env-development') });
|
||||||
|
|
||||||
|
const APP_URL = process.env.E2E_APP_URL || 'http://localhost:5173';
|
||||||
|
const API_URL = process.env.E2E_API_URL || 'http://localhost:3024/api';
|
||||||
|
|
||||||
|
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
|
||||||
|
if (!JWT_REFRESH_SECRET || JWT_REFRESH_SECRET.startsWith('CHANGE_ME')) {
|
||||||
|
throw new Error('JWT_REFRESH_SECRET missing/placeholder — check apps/api/.env has real dev secrets.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known test accounts. user_id is intentionally NOT hardcoded here — these
|
||||||
|
// accounts get recreated over time (see chibistar/automation/starr/config.cjs
|
||||||
|
// history) which silently breaks hardcoded ids. helpers/session.js looks the
|
||||||
|
// id up by email at call time instead.
|
||||||
|
const USERS = {
|
||||||
|
admin: {
|
||||||
|
email: process.env.E2E_ADMIN_EMAIL || 'k80308392@gmail.com',
|
||||||
|
password: process.env.E2E_ADMIN_PASSWORD || 'Test123@',
|
||||||
|
acc_type: 'admin',
|
||||||
|
},
|
||||||
|
seedClient: {
|
||||||
|
// Synthetic, no real owner — safe default for read-only/client-facing checks.
|
||||||
|
email: process.env.E2E_SEED_CLIENT_EMAIL || 'veronica.castro@example.com',
|
||||||
|
acc_type: 'user',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { APP_URL, API_URL, JWT_REFRESH_SECRET, USERS };
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { APP_URL, JWT_REFRESH_SECRET, USERS } = require('./env');
|
||||||
|
const { sequelize, getUserIdByEmail, getPendingOtp } = require('./db');
|
||||||
|
|
||||||
|
/** Build the cookie jar for context.addCookies() from a refresh token. */
|
||||||
|
function buildCookies(refreshToken, csrfToken = 'e2e-csrf') {
|
||||||
|
return [
|
||||||
|
{ name: 'refreshToken', value: refreshToken, domain: 'localhost', path: '/', httpOnly: true, secure: false, sameSite: 'Lax' },
|
||||||
|
{ name: 'csrfToken', value: csrfToken, domain: 'localhost', path: '/', httpOnly: false, secure: false, sameSite: 'Lax' },
|
||||||
|
{ name: 'sidebar_state', value: 'false', domain: 'localhost', path: '/', httpOnly: false, secure: false, sameSite: 'Lax' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh refresh token + DB session row for a client user, keyed
|
||||||
|
* by email (not a hardcoded user_id — those drift as test accounts get
|
||||||
|
* recreated, see chibistar/automation/starr/config.cjs history).
|
||||||
|
*/
|
||||||
|
async function generateClientSession(email) {
|
||||||
|
const user_id = await getUserIdByEmail(email);
|
||||||
|
const refreshToken = jwt.sign({ user_id }, JWT_REFRESH_SECRET, { expiresIn: '7d' });
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||||
|
|
||||||
|
await sequelize.query(
|
||||||
|
`INSERT INTO user_sessions (user_id, refresh_token_hash, is_active, login_info, "createdAt", "updatedAt")
|
||||||
|
VALUES (:uid, :hash, true, '{}', NOW(), NOW())`,
|
||||||
|
{ replacements: { uid: user_id, hash: tokenHash }, type: sequelize.QueryTypes.INSERT },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { user_id, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate as admin via cookie injection (same mechanism as
|
||||||
|
* generateClientSession), not the UI login form.
|
||||||
|
*
|
||||||
|
* REAL FINDING (2026-08-25): the admin account (k80308392@gmail.com) is
|
||||||
|
* Google-OAuth-only now — submitting the UI email/password form returns
|
||||||
|
* "Login failed — Please log in with Google." This matches a prior finding
|
||||||
|
* that the *live*-domain admin session had gone Google-only, but it turns
|
||||||
|
* out to be true here against localhost too, so the email+password+OTP flow
|
||||||
|
* this helper originally ported from chibistar/automation/starr/config.cjs
|
||||||
|
* (loginAsAdmin) is dead — forging the session cookie is the only way in now.
|
||||||
|
*/
|
||||||
|
async function loginAsAdmin(page) {
|
||||||
|
const { refreshToken } = await generateClientSession(USERS.admin.email);
|
||||||
|
await page.context().addCookies(buildCookies(refreshToken));
|
||||||
|
await page.goto(`${APP_URL}/admin`, { waitUntil: 'networkidle' });
|
||||||
|
if (/\/login/.test(page.url())) {
|
||||||
|
throw new Error('Cookie-based admin auth bounced to /login — check JWT_REFRESH_SECRET / user_sessions wiring.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildCookies, generateClientSession, loginAsAdmin };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
// Real monitor resolution instead of a hardcoded viewport — this suite runs
|
||||||
|
// on whatever box the self-hosted runner/dev machine happens to be (varies:
|
||||||
|
// 1366x768, 1920x1080, ...). Memoized per process.
|
||||||
|
let _cachedViewport = null;
|
||||||
|
|
||||||
|
function detectViewport() {
|
||||||
|
if (_cachedViewport) return _cachedViewport;
|
||||||
|
const FALLBACK = { width: 1366, height: 768 };
|
||||||
|
let detected = null;
|
||||||
|
try {
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const out = execSync('xrandr --current 2>/dev/null', { encoding: 'utf8' });
|
||||||
|
const m = out.match(/(\d+)x(\d+)\s+[\d.]+\*/);
|
||||||
|
if (m) detected = { width: Number(m[1]), height: Number(m[2]) };
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
const out = execSync(
|
||||||
|
'powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; ' +
|
||||||
|
'$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; Write-Output \\"$($b.Width)x$($b.Height)\\""',
|
||||||
|
{ encoding: 'utf8', windowsHide: true },
|
||||||
|
).trim();
|
||||||
|
const m = out.match(/(\d+)x(\d+)/);
|
||||||
|
if (m) detected = { width: Number(m[1]), height: Number(m[2]) };
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
const out = execSync('system_profiler SPDisplaysDataType 2>/dev/null', { encoding: 'utf8' });
|
||||||
|
const m = out.match(/Resolution:\s*(\d+)\s*x\s*(\d+)/);
|
||||||
|
if (m) detected = { width: Number(m[1]), height: Number(m[2]) };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No xrandr on a headless CI box, PowerShell blocked, etc. — fall back.
|
||||||
|
}
|
||||||
|
_cachedViewport = detected ?? FALLBACK;
|
||||||
|
return _cachedViewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { detectViewport };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "e2e",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.48.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"pg-hstore": "^2.3.4",
|
||||||
|
"sequelize": "^6.37.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const { defineConfig, devices } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: false, // specs share/mutate DB fixtures tagged by module — keep runs serial to avoid cross-spec QA-TEST collisions
|
||||||
|
workers: 1,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: [['html', { open: 'never' }], ['list']],
|
||||||
|
timeout: 120_000,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'pnpm --dir ../.. dev',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Not converted in this pass. Source doc: `~/Downloads/Client_Achievements_and_Certificates_Test_Case_Documentation.txt`.
|
||||||
|
`test-cert-dialog.cjs` exists in chibistar/automation/starr and covers part of this — worth starting from there.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Ported from chibistar/automation/starr/test-advertisements-doc.cjs — see
|
||||||
|
// ~/Downloads/ADMIN_Advertisements_Test_Case_Documentation.txt.
|
||||||
|
//
|
||||||
|
// Not ported here (dropped, not silently): TC-ADV-013/014/015/016/017/018
|
||||||
|
// (archived-table sort/filter/refresh/export/columns — generic DataTable
|
||||||
|
// behavior already covered structurally by the Courses admin spec's
|
||||||
|
// equivalent checks) and test-client-advertisements-live.cjs /
|
||||||
|
// test-ad-image-select.cjs, both hardcoded against specific live-site rows
|
||||||
|
// (a real advertisement_id, a real course_id, user_id 132's live DB state)
|
||||||
|
// that don't exist on a fresh local dev DB and can't be faithfully
|
||||||
|
// recreated without fabricating the exact scenario they were checking.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { APP_URL, USERS } = require('../../helpers/env');
|
||||||
|
const { generateClientSession, buildCookies } = require('../../helpers/session');
|
||||||
|
const { cleanupQaTestFixtures } = require('../../helpers/cleanup');
|
||||||
|
|
||||||
|
async function settle(page, ms = 1200) {
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await page.waitForTimeout(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TS = Date.now().toString().slice(-6);
|
||||||
|
const QA_HEADLINE = `[QA-TEST] Advertisements Doc Pass ${TS}`;
|
||||||
|
const QA_HEADLINE_EDITED = `${QA_HEADLINE} (edited)`;
|
||||||
|
|
||||||
|
test.describe.serial('Advertisements (admin)', () => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
|
let adminCookies;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const { refreshToken } = await generateClientSession(USERS.admin.email);
|
||||||
|
adminCookies = buildCookies(refreshToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
const removed = await cleanupQaTestFixtures();
|
||||||
|
console.log('QA-TEST fixtures removed:', removed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.context().addCookies(adminCookies);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-ADV-006/007/008: New Advertisement wizard blocks missing placement, happy-path create as Draft', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/admin/advertisements/add`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1000);
|
||||||
|
await expect(page.getByText('Choose where this ad appears')).toBeVisible();
|
||||||
|
|
||||||
|
// TC-ADV-007: missing placement blocks Next
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await expect(page.getByText('Choose where this ad appears')).toBeVisible();
|
||||||
|
|
||||||
|
// TC-ADV-008: happy path
|
||||||
|
await page.locator('button').filter({ hasText: 'Select a placement' }).click();
|
||||||
|
await page.getByRole('option', { name: /Tier Plans/ }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
await page.getByText('Content + image', { exact: false }).click();
|
||||||
|
await page.getByPlaceholder('e.g. Advertisement').fill('QA-TEST Badge');
|
||||||
|
await page.getByPlaceholder(/Discover the World/).fill(QA_HEADLINE);
|
||||||
|
await page.getByPlaceholder('Supporting text under the headline').fill('QA-TEST description for the Advertisements doc pass.');
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
if (await page.getByText('build an internal landing page').isVisible().catch(() => false)) {
|
||||||
|
await page.getByPlaceholder('e.g. Why upgrade to Pro').fill('QA-TEST Landing Page');
|
||||||
|
await page.getByPlaceholder('Short summary shown under the title').fill('QA-TEST landing summary.');
|
||||||
|
await page.getByPlaceholder('Main page content').fill('QA-TEST landing body content.');
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSwitch = page.locator('button[role="switch"]');
|
||||||
|
if (await activeSwitch.isVisible().catch(() => false)) {
|
||||||
|
if ((await activeSwitch.getAttribute('aria-checked')) === 'true') await activeSwitch.click(); // force Draft
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
await expect(page.getByText(QA_HEADLINE)).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Create advertisement' }).click();
|
||||||
|
await page.waitForURL('**/admin/advertisements', { timeout: 15000 });
|
||||||
|
await settle(page, 1500);
|
||||||
|
await expect(page.getByText(QA_HEADLINE).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-ADV-002/009: view then edit the advertisement', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/admin/advertisements`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await page.getByText(QA_HEADLINE).first().click();
|
||||||
|
await page.waitForURL('**/admin/advertisements/*/view', { timeout: 10000 });
|
||||||
|
await settle(page, 800);
|
||||||
|
await expect(page.getByRole('heading', { name: QA_HEADLINE })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
await page.waitForURL('**/admin/advertisements/*/edit', { timeout: 10000 });
|
||||||
|
await settle(page, 800);
|
||||||
|
const hInput = page.getByPlaceholder(/Discover the World/);
|
||||||
|
await hInput.fill('');
|
||||||
|
await hInput.fill(QA_HEADLINE_EDITED);
|
||||||
|
await page.getByRole('button', { name: /save changes/i }).click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.goto(`${APP_URL}/admin/advertisements`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1200);
|
||||||
|
await expect(page.getByText(QA_HEADLINE_EDITED).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-ADV-010/011/012: archive, appears in Archived, restore back to active', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/admin/advertisements`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
const qaCard = page.locator('div.rounded-lg.border').filter({ hasText: QA_HEADLINE_EDITED }).first();
|
||||||
|
await qaCard.getByLabel('Delete').click();
|
||||||
|
await page.getByText('will be moved to archived advertisements').waitFor({ timeout: 8000 });
|
||||||
|
await expect(page.getByText('QA-TEST', { exact: false })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Archive' }).click();
|
||||||
|
await settle(page, 2500);
|
||||||
|
await expect(page.locator('div.rounded-lg.border').filter({ hasText: QA_HEADLINE_EDITED })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Archived' }).click();
|
||||||
|
await page.waitForURL('**/admin/advertisements/archived', { timeout: 10000 });
|
||||||
|
await settle(page, 1200);
|
||||||
|
await expect(page.getByText(QA_HEADLINE_EDITED).first()).toBeVisible();
|
||||||
|
|
||||||
|
const row = page.locator('table tbody tr').filter({ hasText: QA_HEADLINE_EDITED }).first();
|
||||||
|
await row.locator('button').last().click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByText('Restore', { exact: true }).click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByRole('button', { name: /^restore$/i }).click();
|
||||||
|
await settle(page, 1800);
|
||||||
|
|
||||||
|
await page.goto(`${APP_URL}/admin/advertisements`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await expect(page.locator('div.rounded-lg.border').filter({ hasText: QA_HEADLINE_EDITED })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-ADV-019: archive then permanently delete (cleanup path)', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/admin/advertisements`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
const qaCard = page.locator('div.rounded-lg.border').filter({ hasText: QA_HEADLINE_EDITED }).first();
|
||||||
|
await qaCard.getByLabel('Delete').click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.getByRole('button', { name: 'Archive' }).click();
|
||||||
|
await settle(page, 2500);
|
||||||
|
|
||||||
|
await page.goto(`${APP_URL}/admin/advertisements/archived`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1500);
|
||||||
|
const row = page.locator('table tbody tr').filter({ hasText: QA_HEADLINE_EDITED }).first();
|
||||||
|
await row.locator('button').last().click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByText('Delete', { exact: true }).click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByRole('button', { name: /delete permanently/i }).click();
|
||||||
|
await settle(page, 2000);
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1000);
|
||||||
|
await expect(page.getByText(QA_HEADLINE_EDITED)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
// Ported from chibistar/automation/starr/test-courses-{newcourse,archive-final,
|
||||||
|
// assessment-archive}.cjs — see ~/Downloads/Courses_Test_Case_Documentation.txt.
|
||||||
|
//
|
||||||
|
// The source scripts were written as a manually-run, multi-phase live-site
|
||||||
|
// session (phase B1 writes a JSON handoff file that phase B2 reads, some
|
||||||
|
// checks assume "an earlier interrupted run already did X", row-count
|
||||||
|
// assertions assume exactly one pre-existing live course). None of that is
|
||||||
|
// safe to rerun unattended in CI, so this rebuilds the same TC-ID coverage
|
||||||
|
// as one self-contained, idempotent run: every fixture is created fresh
|
||||||
|
// under this run's own [QA-TEST] tag and torn down in afterAll — no
|
||||||
|
// cross-script file handoff, no assumptions about what else exists in the DB.
|
||||||
|
//
|
||||||
|
// Not ported (dropped, not silently — see apps/e2e/README or the PR that
|
||||||
|
// introduces this file): TC-CRS-001..009/028/030/042/043/046 (Refresh/Export/
|
||||||
|
// column-persistence checks tied to exact live single-row counts or file
|
||||||
|
// downloads) and TC-CRS-058 (jump-to-question, minor UI convenience).
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { APP_URL, USERS } = require('../../helpers/env');
|
||||||
|
const { generateClientSession, buildCookies } = require('../../helpers/session');
|
||||||
|
const { cleanupQaTestFixtures } = require('../../helpers/cleanup');
|
||||||
|
|
||||||
|
async function settle(page, ms = 1200) {
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await page.waitForTimeout(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishWizardFrom(page, startStep = 'rewards') {
|
||||||
|
const nextBtn = page.getByRole('button', { name: /^next$/i });
|
||||||
|
if (startStep === 'basic') { await nextBtn.click(); await page.waitForTimeout(1000); }
|
||||||
|
await nextBtn.click(); await page.waitForTimeout(800); // roadmap -> rewards
|
||||||
|
await nextBtn.click(); await page.waitForTimeout(800); // rewards -> review
|
||||||
|
await page.getByRole('button', { name: /^finish$/i }).click();
|
||||||
|
await page.waitForURL('**/view', { timeout: 15000 }).catch(() => {});
|
||||||
|
await settle(page, 1500);
|
||||||
|
return page.url().match(/\/admin\/courses\/(\d+)\/view/)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickCreateCourse(page, title, code) {
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/add`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1000);
|
||||||
|
await page.locator('#title').fill(title);
|
||||||
|
await page.locator('#course_code').fill(code);
|
||||||
|
await page.getByRole('button', { name: /add objective/i }).click();
|
||||||
|
await page.locator('input[placeholder="Objective 1"]').fill('QA automation objective.');
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
return finishWizardFrom(page, 'basic');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveCourseByCode(page, code) {
|
||||||
|
await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1200);
|
||||||
|
const row = page.locator('table tbody tr', { hasText: code });
|
||||||
|
await row.locator('button').last().click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.getByText(/^archive course$/i).click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.getByRole('button', { name: /^archive$/i }).last().click();
|
||||||
|
await settle(page, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TS = Date.now().toString().slice(-6);
|
||||||
|
const QA_TITLE = `[QA-TEST] Courses Automation ${TS}`;
|
||||||
|
const QA_CODE = `QA-TEST-${TS}`;
|
||||||
|
const HELPER_CODE = `QA-TEST-BULK-${TS}`;
|
||||||
|
|
||||||
|
test.describe.serial('Courses (admin)', () => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
let courseId;
|
||||||
|
let helperCourseId;
|
||||||
|
|
||||||
|
let adminCookies;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const { refreshToken } = await generateClientSession(USERS.admin.email);
|
||||||
|
adminCookies = buildCookies(refreshToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
const removed = await cleanupQaTestFixtures();
|
||||||
|
console.log('QA-TEST fixtures removed:', removed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.context().addCookies(adminCookies);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-CRS-010/012/013/014: New Course wizard blocks submission on missing required fields', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await page.getByRole('button', { name: /^new course$/i }).click();
|
||||||
|
await page.waitForURL('**/admin/courses/add', { timeout: 10000 });
|
||||||
|
await settle(page, 1200);
|
||||||
|
await expect(page.locator('text=Basic Information')).toBeVisible();
|
||||||
|
|
||||||
|
const nextBtn = page.getByRole('button', { name: /^next$/i });
|
||||||
|
|
||||||
|
// All fields blank
|
||||||
|
await nextBtn.click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await expect(page.locator('#title')).toBeVisible();
|
||||||
|
await expect(page.locator('p.text-destructive').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Title only
|
||||||
|
await page.locator('#title').fill(QA_TITLE);
|
||||||
|
await nextBtn.click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await expect(page.locator('#title')).toBeVisible();
|
||||||
|
|
||||||
|
// Title + code, objectives still missing
|
||||||
|
await page.locator('#course_code').fill(QA_CODE);
|
||||||
|
await nextBtn.click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await expect(page.locator('#title')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-CRS-017: unsaved-changes guard blocks navigating away from a dirty wizard', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/add`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1200);
|
||||||
|
await page.locator('#title').fill(`${QA_TITLE} (guard check)`);
|
||||||
|
await page.getByRole('button', { name: /^cancel$/i }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await expect(page.locator('[role="alertdialog"]')).toHaveCount(1);
|
||||||
|
await page.getByRole('button', { name: /stay on this page/i }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-CRS-016/018/011: step navigation retains data, achievement is radio-not-checklist, happy-path finish', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/add`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1200);
|
||||||
|
await page.locator('#title').fill(QA_TITLE);
|
||||||
|
await page.locator('#course_code').fill(QA_CODE);
|
||||||
|
await page.getByRole('button', { name: /add objective/i }).click();
|
||||||
|
await page.locator('input[placeholder="Objective 1"]').fill('QA automation objective.');
|
||||||
|
|
||||||
|
const nextBtn = page.getByRole('button', { name: /^next$/i });
|
||||||
|
await nextBtn.click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.getByText(/^basic info$/i).click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await expect(page.locator('#title')).toHaveValue(QA_TITLE);
|
||||||
|
|
||||||
|
await nextBtn.click(); await page.waitForTimeout(800); // roadmap
|
||||||
|
await nextBtn.click(); await page.waitForTimeout(800); // -> rewards
|
||||||
|
|
||||||
|
const wantsBtn = page.getByRole('button', { name: /^yes, add one$/i });
|
||||||
|
if (await wantsBtn.count()) await wantsBtn.click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /new achievement/i }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.locator('#ach_key').fill(`qa_test_ach_${TS}_a`);
|
||||||
|
await page.locator('#ach_label').fill('QA Test Achievement A');
|
||||||
|
await page.getByRole('button', { name: /^create achievement$/i }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /new achievement/i }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.locator('#ach_key').fill(`qa_test_ach_${TS}_b`);
|
||||||
|
await page.locator('#ach_label').fill('QA Test Achievement B');
|
||||||
|
await page.getByRole('button', { name: /^create achievement$/i }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
await expect(page.getByText('QA Test Achievement A')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('QA Test Achievement B')).toBeVisible();
|
||||||
|
|
||||||
|
courseId = await finishWizardFrom(page, 'rewards');
|
||||||
|
expect(courseId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-CRS-015: duplicate course_code is blocked', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/add`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1200);
|
||||||
|
await page.locator('#title').fill(`[QA-TEST] Duplicate Code Check ${TS}`);
|
||||||
|
await page.locator('#course_code').fill(QA_CODE); // reuse the same code
|
||||||
|
await page.getByRole('button', { name: /add objective/i }).click();
|
||||||
|
await page.locator('input[placeholder="Objective 1"]').fill('QA duplicate-code check objective.');
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const dupCourseId = await finishWizardFrom(page, 'basic');
|
||||||
|
expect(dupCourseId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-CRS-039/040/041/044: Columns toggle hide/reshow + sort', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await page.getByRole('button', { name: /^columns$/i }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const toggleItems = await page.locator('[role="menuitemcheckbox"]').count();
|
||||||
|
expect(toggleItems).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
await page.locator('[role="menuitemcheckbox"]', { hasText: 'Level' }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('th', { hasText: 'Level' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^columns$/i }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.locator('[role="menuitemcheckbox"]', { hasText: 'Level' }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('th', { hasText: 'Level' })).toHaveCount(1);
|
||||||
|
|
||||||
|
const titleHeader = page.locator('th', { hasText: /^Title$/ }).first();
|
||||||
|
await titleHeader.locator('button').first().click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.getByText(/^sort asc$/i).click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await expect(titleHeader.locator('svg')).toHaveCount(1, { timeout: 3000 }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-CRS-045/047/048/049/050: View Info renders details, 404 for bad id, Edit navigation', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
const row = page.locator('table tbody tr', { hasText: QA_CODE });
|
||||||
|
await row.locator('button').last().click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.getByText(/^view info$/i).click();
|
||||||
|
await page.waitForURL('**/view', { timeout: 10000 });
|
||||||
|
await settle(page, 1200);
|
||||||
|
await expect(page.getByText('Basic Information')).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/999999999/view`, { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await expect(page.getByText(/course not found/i)).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/${courseId}/view`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1200);
|
||||||
|
await page.getByRole('button', { name: /edit course/i }).first().click();
|
||||||
|
await page.waitForURL('**/edit', { timeout: 8000 });
|
||||||
|
expect(page.url()).toContain('/edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-CRS-055/056/057/053/054/059/060: assessment builder validation', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/${courseId}/assessment`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 2500);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^multi select$/i }).click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.getByRole('button', { name: /^save assessment$/i }).click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await expect(page.getByText(/question text is required/i)).toBeVisible();
|
||||||
|
|
||||||
|
const card = page.locator('.rounded-xl.border.bg-card').last();
|
||||||
|
await card.locator('textarea[placeholder*="question here" i]').fill('QA automation multi-select test question.');
|
||||||
|
const optionInputs = card.locator('input[placeholder^="Option "]');
|
||||||
|
const optCount = await optionInputs.count();
|
||||||
|
for (let i = 0; i < optCount; i++) await optionInputs.nth(i).fill(`QA option ${i + 1}`);
|
||||||
|
let incorrectToggles = card.locator('button[title="Mark as incorrect"]');
|
||||||
|
while (await incorrectToggles.count() > 0) {
|
||||||
|
await incorrectToggles.first().click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: /^save assessment$/i }).click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await expect(page.getByText(/at least one correct answer is required/i)).toBeVisible();
|
||||||
|
|
||||||
|
await card.locator('button[title="Mark as correct"]').first().click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await optionInputs.nth(optCount - 1).fill('');
|
||||||
|
await page.getByRole('button', { name: /^save assessment$/i }).click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await expect(page.getByText(/all option texts are required/i)).toBeVisible();
|
||||||
|
|
||||||
|
await optionInputs.nth(optCount - 1).fill(`QA option ${optCount}`);
|
||||||
|
await page.getByRole('button', { name: /^save assessment$/i }).click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await expect(page.locator('p.text-destructive')).toHaveCount(0);
|
||||||
|
|
||||||
|
const dupStatus = await page.evaluate(async (id) => {
|
||||||
|
const res = await fetch(`/api/admin/courses/${id}/assessment`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include', body: JSON.stringify({ title: 'Duplicate attempt' }),
|
||||||
|
});
|
||||||
|
return res.status;
|
||||||
|
}, courseId);
|
||||||
|
expect(dupStatus).toBe(409);
|
||||||
|
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/${courseId}/assessment`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 2000);
|
||||||
|
const timeLimitVal = await page.locator('input[placeholder="No limit"]').inputValue().catch(() => null);
|
||||||
|
expect(timeLimitVal === '' || timeLimitVal === null).toBe(true);
|
||||||
|
|
||||||
|
await page.locator('input[placeholder="Course Assessment"]').fill(`[QA-TEST] Assessment dirty-${TS}`).catch(() => {});
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.goBack();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await expect(page.locator('[role="alertdialog"]')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-CRS-029/031/032/033/034/035/036/037/038: archive / restore / bulk-restore / delete lifecycle', async ({ page }) => {
|
||||||
|
helperCourseId = await quickCreateCourse(page, '[QA-TEST] Bulk Restore Helper', HELPER_CODE);
|
||||||
|
expect(helperCourseId).toBeTruthy();
|
||||||
|
|
||||||
|
await archiveCourseByCode(page, QA_CODE);
|
||||||
|
await archiveCourseByCode(page, HELPER_CODE);
|
||||||
|
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/archived`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1500);
|
||||||
|
const archivedRows = page.locator('table tbody tr');
|
||||||
|
await expect(archivedRows).not.toHaveCount(0);
|
||||||
|
await expect(page.getByText(QA_CODE)).toBeVisible();
|
||||||
|
await expect(page.getByText(HELPER_CODE)).toBeVisible();
|
||||||
|
|
||||||
|
// Kebab on an archived row: exactly Restore + Delete
|
||||||
|
await archivedRows.first().locator('button').last().click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const menuItems = await page.locator('[role="menuitem"]').allInnerTexts();
|
||||||
|
expect(menuItems.length).toBe(2);
|
||||||
|
expect(menuItems.some((t) => /restore/i.test(t))).toBe(true);
|
||||||
|
expect(menuItems.some((t) => /delete/i.test(t))).toBe(true);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Cancel a restore — row count unchanged
|
||||||
|
const beforeCancel = await archivedRows.count();
|
||||||
|
await archivedRows.first().locator('button').last().click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.getByText(/^restore$/i).click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.getByRole('button', { name: /^cancel$/i }).click();
|
||||||
|
await settle(page, 1200);
|
||||||
|
await expect(archivedRows).toHaveCount(beforeCancel);
|
||||||
|
|
||||||
|
// Restore one, confirm active
|
||||||
|
await archivedRows.first().locator('button').last().click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.getByText(/^restore$/i).click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.getByRole('button', { name: /^restore$/i }).last().click();
|
||||||
|
await settle(page, 1800);
|
||||||
|
await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1200);
|
||||||
|
const activeQaCountAfterSingleRestore = await page.locator('table tbody tr', { hasText: /QA-TEST/i }).count();
|
||||||
|
expect(activeQaCountAfterSingleRestore).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Re-archive it, then bulk-restore both
|
||||||
|
const reArchivedCode = (await page.locator('table tbody tr', { hasText: /QA-TEST/i }).first().innerText()).includes(QA_CODE) ? QA_CODE : HELPER_CODE;
|
||||||
|
await archiveCourseByCode(page, reArchivedCode);
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/archived`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1500);
|
||||||
|
const checkboxes = page.locator('table tbody tr button[role="checkbox"]');
|
||||||
|
const cbCount = await checkboxes.count();
|
||||||
|
for (let i = 0; i < cbCount; i++) { await checkboxes.nth(i).click(); await page.waitForTimeout(150); }
|
||||||
|
await page.getByRole('button', { name: /^restore$/i }).first().click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.getByRole('button', { name: /^restore/i }).last().click();
|
||||||
|
await settle(page, 1800);
|
||||||
|
await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1200);
|
||||||
|
await expect(page.getByText(QA_CODE)).toBeVisible();
|
||||||
|
await expect(page.getByText(HELPER_CODE)).toBeVisible();
|
||||||
|
|
||||||
|
// Archive both again, then permanently delete each
|
||||||
|
await archiveCourseByCode(page, QA_CODE);
|
||||||
|
await archiveCourseByCode(page, HELPER_CODE);
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/archived`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1500);
|
||||||
|
let remaining = await page.locator('table tbody tr', { hasText: /QA-TEST/i }).count();
|
||||||
|
expect(remaining).toBeGreaterThanOrEqual(2);
|
||||||
|
while (remaining > 0) {
|
||||||
|
const row = page.locator('table tbody tr', { hasText: /QA-TEST/i }).first();
|
||||||
|
await row.locator('button').last().click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.getByText(/^delete$/i).click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await expect(page.locator('[role="alertdialog"]')).toHaveCount(1);
|
||||||
|
await page.getByRole('button', { name: /delete.*permanently/i }).click();
|
||||||
|
await settle(page, 1500);
|
||||||
|
remaining = await page.locator('table tbody tr', { hasText: /QA-TEST/i }).count();
|
||||||
|
}
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1200);
|
||||||
|
await expect(page.locator('table tbody tr', { hasText: /QA-TEST/i })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Not converted in this pass. Source doc: `~/Downloads/Client_Dashboard_Test_Case_Documentation.txt`.
|
||||||
|
No existing chibistar/automation/starr script to port from — would need to be written from scratch against the doc.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Not converted in this pass. Source doc: `~/Downloads/Client_Notifications_Test_Case_Documentation.txt`.
|
||||||
|
No existing chibistar/automation/starr script to port from — would need to be written from scratch against the doc.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Not converted in this pass. Source doc: `~/Downloads/Client_Profile_and_Account_Test_Case_Documentation.txt`.
|
||||||
|
No existing chibistar/automation/starr script to port from — would need to be written from scratch against the doc.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Not converted in this pass — scope-reduced under time constraints, not silently dropped.
|
||||||
|
|
||||||
|
Source doc: `~/Downloads/ADMIN_Tasks_Test_Case_Documentation.txt`.
|
||||||
|
Source scripts to port from (chibistar/automation/starr/): `test-tasks-admin.cjs` (589 lines),
|
||||||
|
`test-tasks-admin-part2.cjs` (308 lines), `test-tasks-admin-part3.cjs` (326 lines),
|
||||||
|
`test-tasks-admin-part4.cjs` (162 lines) — ~1400 lines total, largest of the five modules
|
||||||
|
originally scoped for this pass. Follow the same conversion pattern used in
|
||||||
|
`../courses/admin.spec.js` and `../advertisements/admin.spec.js`: rebuild as one
|
||||||
|
self-contained `test.describe.serial` spec using fresh `[QA-TEST]`-tagged fixtures
|
||||||
|
per run (the source scripts assume specific pre-existing live state / prior-run
|
||||||
|
leftovers, which isn't safe to replay unattended in CI).
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Not converted in this pass — scope-reduced under time constraints, not silently dropped.
|
||||||
|
|
||||||
|
Source docs: `~/Downloads/ADMIN_Tier_Plans_Test_Case_Documentation.txt` and
|
||||||
|
`~/Downloads/Client_Tier_Plans_Test_Case_Documentation.txt`.
|
||||||
|
Source scripts to port from (chibistar/automation/starr/): `test-tier-plans-admin.cjs` (417 lines),
|
||||||
|
`test-tier-plans-e2e.cjs` (520 lines), `test-tier-plan-inactive.cjs` (171 lines),
|
||||||
|
`test-client-tier-plans-live.cjs` (164 lines) — ~1270 lines total. Follow the same
|
||||||
|
conversion pattern used in `../courses/admin.spec.js` and `../advertisements/admin.spec.js`.
|
||||||
|
Note `test-client-tier-plans-live.cjs` targets the LIVE_URL and hardcodes real
|
||||||
|
live user_ids/plan state — will need the same "rebuild against localhost with
|
||||||
|
fresh fixtures" treatment as the other client-side specs, not a literal port.
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// Ported from chibistar/automation/starr/test-units-lessons-client.cjs.
|
||||||
|
// Core-responsibilities suite for Units & Lessons (see
|
||||||
|
// ~/Downloads/Units_and_Lessons_Test_Case_Documentation.txt):
|
||||||
|
// TC-UNT-020 Quiz sequential lock (course context, 2 units w/ required quizzes)
|
||||||
|
// TC-UNT-021 Open-access unit (no course) navigable directly
|
||||||
|
// TC-UNT-022 Locked unit shows upsell modal (not hidden), lists gating course/tier
|
||||||
|
// TC-UNT-023 Unit reader consumes lessons + reachable via grid->detail->read
|
||||||
|
// TC-UNT-024 Unit attached to zero courses is open to any logged-in learner
|
||||||
|
// TC-LSN-012 Direct navigation to /lessons/:uuid permalink resolves content
|
||||||
|
// TC-LSN-013 Lessons have no top-level browse page (no nav link, no listing route)
|
||||||
|
// TC-LSN-014 Cross-unit reading-progress completion (user_id+lesson_id only)
|
||||||
|
//
|
||||||
|
// Admin setup (beforeAll) builds all fixtures once; each test then opens its
|
||||||
|
// own client-session ("seedClient") page to exercise the learner-facing
|
||||||
|
// check. All [QA-TEST] fixtures are hard-deleted in afterAll.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { APP_URL, USERS } = require('../../helpers/env');
|
||||||
|
const { loginAsAdmin, generateClientSession, buildCookies } = require('../../helpers/session');
|
||||||
|
const { cleanupQaTestFixtures } = require('../../helpers/cleanup');
|
||||||
|
|
||||||
|
async function settle(page, ms = 1200) {
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await page.waitForTimeout(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickNextUntil(page, expectedHeadingRegex, maxTries = 3) {
|
||||||
|
for (let i = 0; i < maxTries; i++) {
|
||||||
|
await page.getByRole('button', { name: /^next$/i }).click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
if (await page.locator('h2', { hasText: expectedHeadingRegex }).count()) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRequirement(page, labelRegex) {
|
||||||
|
const reqSelect = page.getByText(/add requirement/i).locator('..').first();
|
||||||
|
await reqSelect.scrollIntoViewIfNeeded();
|
||||||
|
await reqSelect.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const opt = page.getByRole('option', { name: labelRegex });
|
||||||
|
await opt.waitFor({ state: 'attached', timeout: 5000 });
|
||||||
|
await opt.evaluate((el) => el.click());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBareUnit(page, title) {
|
||||||
|
await page.goto(`${APP_URL}/admin/units/add`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await page.locator('#title').fill(title);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await clickNextUntil(page, /^Lessons$/);
|
||||||
|
// REAL UI DRIFT (2026-08-25): the Lessons step now offers "Select" (attach
|
||||||
|
// existing) + "Create" (new) buttons — the source script's "Add Lesson"
|
||||||
|
// button no longer exists.
|
||||||
|
await page.getByRole('button', { name: /^create$/i }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.locator('input[name^="lessons."][name$=".title"]').first().fill(`${title} - Lesson`);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await clickNextUntil(page, /^Page Builder$/);
|
||||||
|
await clickNextUntil(page, /^Requirements$/);
|
||||||
|
await clickNextUntil(page, /^Review$/);
|
||||||
|
await page.getByRole('button', { name: /^create unit$/i }).click();
|
||||||
|
await settle(page, 1800);
|
||||||
|
await page.goto(`${APP_URL}/admin/units`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
const row = page.locator('table tbody tr', { hasText: title }).first();
|
||||||
|
await row.locator('button').last().click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByText(/^view info$/i).click();
|
||||||
|
await page.waitForURL('**/view', { timeout: 10000 });
|
||||||
|
await settle(page, 1000);
|
||||||
|
return page.url().match(/units\/(\d+)\/view/)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addQuizWithQuestion(page, unitId, questionText) {
|
||||||
|
await page.goto(`${APP_URL}/admin/units/${unitId}/quiz/edit`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await page.getByRole('button', { name: /^multiple choice$/i }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.locator('textarea, input[placeholder*="question" i]').first().fill(questionText);
|
||||||
|
const optionInputs = page.locator('input[placeholder*="option" i]');
|
||||||
|
const texts = ['Correct Answer', 'Wrong Answer B', 'Wrong Answer C', 'Wrong Answer D'];
|
||||||
|
const n = await optionInputs.count();
|
||||||
|
for (let i = 0; i < n; i++) await optionInputs.nth(i).fill(texts[i] ?? `Option ${i + 1}`);
|
||||||
|
await page.getByRole('button', { name: /^save quiz$/i }).click();
|
||||||
|
await settle(page, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function courseWizardHappyPath(page, title, code, { pickPaidTier = false } = {}) {
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/add`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1200);
|
||||||
|
await page.locator('#title').fill(title);
|
||||||
|
await page.locator('#course_code').fill(code);
|
||||||
|
|
||||||
|
let pickedPaidTier = false;
|
||||||
|
if (pickPaidTier) {
|
||||||
|
const subscrTrigger = page.locator('button[role="combobox"]').nth(1);
|
||||||
|
if (await subscrTrigger.count()) {
|
||||||
|
await subscrTrigger.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const premiumOpt = page.getByRole('option', { name: /premium|pro|paid|plus/i }).first();
|
||||||
|
if (await premiumOpt.count()) { await premiumOpt.click(); pickedPaidTier = true; }
|
||||||
|
else await page.keyboard.press('Escape');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add objective/i }).click();
|
||||||
|
await page.locator('input[placeholder="Objective 1"]').fill('QA automation objective.');
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.getByRole('button', { name: /^next$/i }).click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
if (!(await page.getByText(/prerequisites/i).count())) {
|
||||||
|
await page.getByRole('button', { name: /^next$/i }).click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: /^next$/i }).click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.getByRole('button', { name: /^next$/i }).click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.getByRole('button', { name: /^finish$/i }).click();
|
||||||
|
await page.waitForURL('**/view', { timeout: 15000 }).catch(() => {});
|
||||||
|
await settle(page, 1800);
|
||||||
|
const courseId = page.url().match(/courses\/(\d+)\/view/)?.[1];
|
||||||
|
return { courseId, pickedPaidTier };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachUnitToCourse(page, courseId, unitTitle) {
|
||||||
|
await page.goto(`${APP_URL}/admin/courses/${courseId}/units`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1800);
|
||||||
|
let attachBtn = page.getByRole('button', { name: /attach existing/i });
|
||||||
|
if (!(await attachBtn.count())) {
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
await settle(page, 1500);
|
||||||
|
attachBtn = page.getByRole('button', { name: /attach existing/i });
|
||||||
|
}
|
||||||
|
await attachBtn.click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.locator('input[placeholder="Search units..."]').fill(unitTitle);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByText(unitTitle).first().click();
|
||||||
|
await page.getByRole('button', { name: /^attach/i }).click();
|
||||||
|
await settle(page, 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TS = Date.now().toString().slice(-6);
|
||||||
|
const COURSE_TITLE = `[QA-TEST] Sequential Quiz Course ${TS}`;
|
||||||
|
const COURSE_CODE = `QA-SEQ-${TS}`;
|
||||||
|
const UNIT1_TITLE = `[QA-TEST] Seq Unit 1 ${TS}`;
|
||||||
|
const UNIT2_TITLE = `[QA-TEST] Seq Unit 2 ${TS}`;
|
||||||
|
const OPEN_UNIT_TITLE = `[QA-TEST] Open Access Unit ${TS}`;
|
||||||
|
const LOCKED_UNIT_TITLE = `[QA-TEST] Locked Premium Unit ${TS}`;
|
||||||
|
const LOCKED_COURSE_TITLE = `[QA-TEST] Premium Gating Course ${TS}`;
|
||||||
|
const LOCKED_COURSE_CODE = `QA-PREM-${TS}`;
|
||||||
|
const CROSS_LESSON_TITLE = `[QA-TEST] Cross Unit Lesson ${TS}`;
|
||||||
|
const CROSS_UNIT_A_TITLE = `[QA-TEST] Cross Progress Unit A ${TS}`;
|
||||||
|
const CROSS_UNIT_B_TITLE = `[QA-TEST] Cross Progress Unit B ${TS}`;
|
||||||
|
|
||||||
|
test.describe.serial('Units & Lessons — client core responsibilities', () => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
/** @type {{courseId: string, lockedCourseId: string, pickedPaidTier: boolean, lessonUuid: string|null}} */
|
||||||
|
let shared = {};
|
||||||
|
let clientRefreshToken;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
test.setTimeout(300_000); // this fixture-setup chain (6 units, 2 courses, a lesson) takes well over the default hook timeout
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
|
||||||
|
const openUnitId = await createBareUnit(page, OPEN_UNIT_TITLE);
|
||||||
|
const lockedUnitId = await createBareUnit(page, LOCKED_UNIT_TITLE);
|
||||||
|
|
||||||
|
const unit1Id = await createBareUnit(page, UNIT1_TITLE);
|
||||||
|
await addQuizWithQuestion(page, unit1Id, 'Sequential Quiz Unit 1 question?');
|
||||||
|
await page.goto(`${APP_URL}/admin/units/${unit1Id}/edit`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await addRequirement(page, /pass the quiz/i);
|
||||||
|
await page.getByRole('button', { name: /save requirements/i }).click();
|
||||||
|
await settle(page, 1500);
|
||||||
|
|
||||||
|
const unit2Id = await createBareUnit(page, UNIT2_TITLE);
|
||||||
|
await addQuizWithQuestion(page, unit2Id, 'Sequential Quiz Unit 2 question?');
|
||||||
|
await page.goto(`${APP_URL}/admin/units/${unit2Id}/edit`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await addRequirement(page, /pass the quiz/i);
|
||||||
|
await page.getByRole('button', { name: /save requirements/i }).click();
|
||||||
|
await settle(page, 1500);
|
||||||
|
|
||||||
|
// Standalone lesson attached to two units, for the cross-unit progress check.
|
||||||
|
await page.goto(`${APP_URL}/admin/lessons/add`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await page.locator('#title').fill(CROSS_LESSON_TITLE);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await clickNextUntil(page, /^Page Builder$/);
|
||||||
|
// REAL UI DRIFT (2026-08-25): Page Builder is now opened via its own
|
||||||
|
// "Open Page Builder" button before the block toolbar is reachable.
|
||||||
|
await page.getByRole('button', { name: /open page builder/i }).click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByRole('button', { name: /add block/i }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.locator('[role="menu"]').getByText('Text', { exact: true }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const editor = page.locator('[contenteditable="true"]').first();
|
||||||
|
await editor.click();
|
||||||
|
await editor.type('Cross-unit reading-progress test content. Scroll to the bottom to complete.');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
// The block editor lives inside a Drawer (StepPageBuilder in
|
||||||
|
// AddLibraryLesson.jsx) — "Done" just closes it (form state is already
|
||||||
|
// updated live via setValue as you type), but the wizard's own Next
|
||||||
|
// button is unreachable while the drawer is open.
|
||||||
|
await page.getByRole('button', { name: /^done$/i }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await clickNextUntil(page, /^Requirements$/);
|
||||||
|
await clickNextUntil(page, /^Review$/);
|
||||||
|
await page.getByRole('button', { name: /^create lesson$/i }).click();
|
||||||
|
await settle(page, 1800);
|
||||||
|
|
||||||
|
const crossUnitAId = await createBareUnit(page, CROSS_UNIT_A_TITLE);
|
||||||
|
const crossUnitBId = await createBareUnit(page, CROSS_UNIT_B_TITLE);
|
||||||
|
for (const uid of [crossUnitAId, crossUnitBId]) {
|
||||||
|
await page.goto(`${APP_URL}/admin/units/${uid}/view`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await page.getByRole('button', { name: /attach existing/i }).click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.locator('input[placeholder="Search lessons..."]').fill(CROSS_LESSON_TITLE);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByText(CROSS_LESSON_TITLE).first().click();
|
||||||
|
await page.getByRole('button', { name: /^attach/i }).click();
|
||||||
|
await settle(page, 1600);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { courseId } = await courseWizardHappyPath(page, COURSE_TITLE, COURSE_CODE);
|
||||||
|
await attachUnitToCourse(page, courseId, UNIT1_TITLE);
|
||||||
|
await attachUnitToCourse(page, courseId, UNIT2_TITLE);
|
||||||
|
|
||||||
|
const { courseId: lockedCourseId, pickedPaidTier } = await courseWizardHappyPath(
|
||||||
|
page, LOCKED_COURSE_TITLE, LOCKED_COURSE_CODE, { pickPaidTier: true },
|
||||||
|
);
|
||||||
|
await attachUnitToCourse(page, lockedCourseId, LOCKED_UNIT_TITLE);
|
||||||
|
|
||||||
|
// Best-effort scrape of the cross-unit lesson's uuid for the permalink test.
|
||||||
|
await page.goto(`${APP_URL}/admin/lessons`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
const crossLessonRow = page.locator('table tbody tr', { hasText: CROSS_LESSON_TITLE }).first();
|
||||||
|
await crossLessonRow.locator('button').last().click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByText(/^view info$/i).click();
|
||||||
|
await page.waitForURL('**/lessons/**', { timeout: 10000 });
|
||||||
|
await settle(page, 1000);
|
||||||
|
const lessonUuid = await page.evaluate(() => {
|
||||||
|
const m = document.body.innerHTML.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
||||||
|
return m ? m[0] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
shared = { courseId, lockedCourseId, pickedPaidTier, lessonUuid };
|
||||||
|
({ refreshToken: clientRefreshToken } = await generateClientSession(USERS.seedClient.email));
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
const removed = await cleanupQaTestFixtures();
|
||||||
|
console.log('QA-TEST fixtures removed:', removed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await context.addCookies(buildCookies(clientRefreshToken));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-UNT-021 / TC-UNT-024: unit with zero course attachments is open to any learner', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/units`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
const openCard = page.getByText(OPEN_UNIT_TITLE).first();
|
||||||
|
await openCard.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
await openCard.click();
|
||||||
|
await settle(page);
|
||||||
|
expect(page.url()).toContain('/units/');
|
||||||
|
expect(page.url().endsWith('/units')).toBe(false);
|
||||||
|
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-UNT-022: locked unit shows upsell modal listing the gating course', async ({ page }) => {
|
||||||
|
test.skip(!shared.pickedPaidTier, 'No paid/premium tier available in this environment\'s tier catalog to gate the course with — same environment limitation the original script hit.');
|
||||||
|
await page.goto(`${APP_URL}/units`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
const lockedCard = page.getByText(LOCKED_UNIT_TITLE).first();
|
||||||
|
await lockedCard.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
await lockedCard.click();
|
||||||
|
await settle(page);
|
||||||
|
await expect(page.locator('[role="dialog"]')).toHaveCount(1);
|
||||||
|
await expect(page.getByText(LOCKED_COURSE_TITLE)).toBeVisible();
|
||||||
|
expect(page.url().endsWith('/units')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-UNT-023: unit reader reachable via grid -> detail -> read', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/units`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await page.getByText(OPEN_UNIT_TITLE).first().click();
|
||||||
|
await settle(page);
|
||||||
|
expect(page.url()).toContain('/units/');
|
||||||
|
await page.getByRole('button', { name: /start|read|continue/i }).first().click();
|
||||||
|
await settle(page);
|
||||||
|
expect(page.url()).toContain('/read');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-LSN-013: lessons have no top-level browse page', async ({ page }) => {
|
||||||
|
const navHasLessonsLink = await page.locator('nav a, header a').filter({ hasText: /^lessons$/i }).count();
|
||||||
|
expect(navHasLessonsLink).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-LSN-012: direct navigation to /lessons/:uuid permalink resolves content', async ({ page }) => {
|
||||||
|
test.skip(!shared.lessonUuid, 'Could not scrape a lesson uuid from the admin View Info page.');
|
||||||
|
await page.goto(`${APP_URL}/lessons/${shared.lessonUuid}`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await expect(page.getByText(CROSS_LESSON_TITLE)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-LSN-014: cross-unit reading-progress completion (user_id+lesson_id only)', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/units`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
const unitACard = page.getByText(CROSS_UNIT_A_TITLE).first();
|
||||||
|
await unitACard.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
await unitACard.click();
|
||||||
|
await settle(page);
|
||||||
|
const startBtnA = page.getByRole('button', { name: /start|read|continue/i }).first();
|
||||||
|
if (await startBtnA.count()) {
|
||||||
|
await startBtnA.click();
|
||||||
|
await settle(page);
|
||||||
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||||
|
await page.waitForTimeout(1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${APP_URL}/units`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
const unitBCard = page.getByText(CROSS_UNIT_B_TITLE).first();
|
||||||
|
await unitBCard.click();
|
||||||
|
await settle(page);
|
||||||
|
let completed = await page.getByText(/completed/i).count() > 0;
|
||||||
|
if (!completed) {
|
||||||
|
const startBtnB = page.getByRole('button', { name: /start|read|continue/i }).first();
|
||||||
|
if (await startBtnB.count()) {
|
||||||
|
await startBtnB.click();
|
||||||
|
await settle(page);
|
||||||
|
completed = await page.getByText(/completed/i).count() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(completed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-UNT-020: quiz sequential lock blocks unit 2 before unit 1 is passed', async ({ page }) => {
|
||||||
|
await page.goto(`${APP_URL}/course/${shared.courseId}/unit`, { waitUntil: 'networkidle' });
|
||||||
|
await settle(page);
|
||||||
|
await page.getByText(UNIT2_TITLE).first().click();
|
||||||
|
await settle(page);
|
||||||
|
const quizTab = page.getByText(/quiz/i).first();
|
||||||
|
if (await quizTab.count()) { await quizTab.click(); await settle(page); }
|
||||||
|
await expect(page.getByText(/complete previous quiz|complete previous quizzes first/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name : DetailSectionCard.jsx
|
||||||
|
* Type : Component (Generic)
|
||||||
|
* Description : Stacked-card building blocks for tabbed detail pages —
|
||||||
|
* a bordered card with an icon/title header (SectionCard) and
|
||||||
|
* a label/value pair that falls back to "—" when empty
|
||||||
|
* (InfoRow). Pulled out of ViewPlan.jsx (modules/admin/pages/
|
||||||
|
* tiers/ViewPlan.jsx) so the admin asset view pages can reuse
|
||||||
|
* the same look without duplicating it four times.
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
export function InfoRow({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{children ?? <span className="text-muted-foreground italic">—</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionCard({ icon: Icon, title, description, children }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-xs text-muted-foreground mt-0.5 ml-6">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name : DetailTabsHeader.jsx
|
||||||
|
* Type : Component (Generic)
|
||||||
|
* Description : Sticky header + underline tab row for tabbed detail pages —
|
||||||
|
* back button, icon/title/subtitle, a right-aligned actions
|
||||||
|
* slot, and an underline tab row. Parameterized version of the
|
||||||
|
* header in ViewPlan.jsx (modules/admin/pages/tiers/
|
||||||
|
* ViewPlan.jsx), reused by the admin asset view pages so they
|
||||||
|
* don't each duplicate the same markup.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* icon {Component} – lucide icon rendered before the title
|
||||||
|
* title {string}
|
||||||
|
* subtitle {string}
|
||||||
|
* actions {ReactNode} – right-aligned buttons (e.g. Edit)
|
||||||
|
* onBack {function}
|
||||||
|
* tabs {Array<{ key, label, icon }>}
|
||||||
|
* activeTab {string}
|
||||||
|
* onTabChange{function}
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function DetailTabsHeader({
|
||||||
|
icon: Icon, title, subtitle, actions, onBack, tabs, activeTab, onTabChange,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||||
|
style={{ top: "var(--navbar-h)" }}
|
||||||
|
>
|
||||||
|
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
|
<div className="flex items-center gap-3 py-3">
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||||
|
{Icon && <Icon className="h-5 w-5 text-muted-foreground" />}
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Underline tabs */}
|
||||||
|
<div className="flex gap-1 pb-0 -mb-px">
|
||||||
|
{tabs.map(({ key, label, icon: TabIcon }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onTabChange(key)}
|
||||||
|
className={[
|
||||||
|
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
activeTab === key
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<TabIcon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
* mimeType {string}
|
* mimeType {string}
|
||||||
* fileName {string}
|
* fileName {string}
|
||||||
* loading {boolean} – true while the parent is still resolving the src
|
* loading {boolean} – true while the parent is still resolving the src
|
||||||
|
* canvasClassName {string} – class(es) controlling the viewer canvas height,
|
||||||
|
* default 'h-[420px]'. Pass e.g. 'flex-1 min-h-[500px]'
|
||||||
|
* to stretch the canvas to fill a parent's height.
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -95,7 +98,7 @@ const ZoomToolbar = ({
|
|||||||
|
|
||||||
// ─── Shared zoom/pan canvas wrapper ─────────────────────────────────────────────
|
// ─── Shared zoom/pan canvas wrapper ─────────────────────────────────────────────
|
||||||
// Wraps any child (img or canvas) with scroll-to-zoom + drag-to-pan behavior.
|
// Wraps any child (img or canvas) with scroll-to-zoom + drag-to-pan behavior.
|
||||||
const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) => {
|
const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, canvasClassName, children }) => {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const dragRef = useRef({ dragging: false, startX: 0, startY: 0, origX: 0, origY: 0 });
|
const dragRef = useRef({ dragging: false, startX: 0, startY: 0, origX: 0, origY: 0 });
|
||||||
|
|
||||||
@@ -139,7 +142,8 @@ const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) =>
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative overflow-hidden bg-muted h-[420px] flex items-center justify-center',
|
'relative overflow-hidden bg-muted flex items-center justify-center',
|
||||||
|
canvasClassName,
|
||||||
scale > 1 ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
|
scale > 1 ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
|
||||||
)}
|
)}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
@@ -161,14 +165,14 @@ const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── Image viewer ───────────────────────────────────────────────────────────────
|
// ─── Image viewer ───────────────────────────────────────────────────────────────
|
||||||
const ImageViewer = ({ src, fileName }) => {
|
const ImageViewer = ({ src, fileName, canvasClassName }) => {
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
|
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<ZoomToolbar
|
<ZoomToolbar
|
||||||
scale={scale}
|
scale={scale}
|
||||||
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
|
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
|
||||||
@@ -179,7 +183,7 @@ const ImageViewer = ({ src, fileName }) => {
|
|||||||
onPrevPage={() => {}}
|
onPrevPage={() => {}}
|
||||||
onNextPage={() => {}}
|
onNextPage={() => {}}
|
||||||
/>
|
/>
|
||||||
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
|
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit} canvasClassName={canvasClassName}>
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={fileName}
|
alt={fileName}
|
||||||
@@ -193,7 +197,7 @@ const ImageViewer = ({ src, fileName }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── PDF viewer (pdf.js → canvas) ───────────────────────────────────────────────
|
// ─── PDF viewer (pdf.js → canvas) ───────────────────────────────────────────────
|
||||||
const PdfViewer = ({ src, fileName }) => {
|
const PdfViewer = ({ src, fileName, canvasClassName }) => {
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
const [pdfDoc, setPdfDoc] = useState(null);
|
const [pdfDoc, setPdfDoc] = useState(null);
|
||||||
@@ -258,7 +262,7 @@ const PdfViewer = ({ src, fileName }) => {
|
|||||||
if (loadError) return <UnsupportedMessage fileName={fileName} />;
|
if (loadError) return <UnsupportedMessage fileName={fileName} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<ZoomToolbar
|
<ZoomToolbar
|
||||||
scale={scale}
|
scale={scale}
|
||||||
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
|
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
|
||||||
@@ -269,7 +273,7 @@ const PdfViewer = ({ src, fileName }) => {
|
|||||||
onPrevPage={() => { setPage((p) => Math.max(1, p - 1)); fit(); }}
|
onPrevPage={() => { setPage((p) => Math.max(1, p - 1)); fit(); }}
|
||||||
onNextPage={() => { setPage((p) => Math.min(numPages, p + 1)); fit(); }}
|
onNextPage={() => { setPage((p) => Math.min(numPages, p + 1)); fit(); }}
|
||||||
/>
|
/>
|
||||||
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
|
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit} canvasClassName={canvasClassName}>
|
||||||
<canvas ref={canvasRef} className="max-h-[380px] select-none" />
|
<canvas ref={canvasRef} className="max-h-[380px] select-none" />
|
||||||
</ZoomPanArea>
|
</ZoomPanArea>
|
||||||
{rendering && (
|
{rendering && (
|
||||||
@@ -282,12 +286,12 @@ const PdfViewer = ({ src, fileName }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── Main viewer ────────────────────────────────────────────────────────────────
|
// ─── Main viewer ────────────────────────────────────────────────────────────────
|
||||||
const FileZoomViewer = ({ src, mimeType, fileName, loading }) => {
|
const FileZoomViewer = ({ src, mimeType, fileName, loading, canvasClassName = 'h-[420px]' }) => {
|
||||||
const mode = resolveMode(mimeType, fileName);
|
const mode = resolveMode(mimeType, fileName);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[420px]">
|
<div className={cn('flex items-center justify-center', canvasClassName)}>
|
||||||
<Spinner className="size-6" />
|
<Spinner className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -297,8 +301,8 @@ const FileZoomViewer = ({ src, mimeType, fileName, loading }) => {
|
|||||||
return <UnsupportedMessage fileName={fileName} />;
|
return <UnsupportedMessage fileName={fileName} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'image') return <ImageViewer src={src} fileName={fileName} />;
|
if (mode === 'image') return <ImageViewer src={src} fileName={fileName} canvasClassName={canvasClassName} />;
|
||||||
if (mode === 'pdf') return <PdfViewer src={src} fileName={fileName} />;
|
if (mode === 'pdf') return <PdfViewer src={src} fileName={fileName} canvasClassName={canvasClassName} />;
|
||||||
|
|
||||||
return <UnsupportedMessage fileName={fileName} />;
|
return <UnsupportedMessage fileName={fileName} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// modules/admin/pages/assets/ViewAudioAsset.jsx
|
// modules/admin/pages/assets/ViewAudioAsset.jsx
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Globe, Music2, Pencil } from "lucide-react";
|
import { Lock, Globe, Music2, Pencil, Info } from "lucide-react";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||||
@@ -9,24 +10,16 @@ import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
|||||||
import { formatFileSize } from "@/utils/format.util";
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
|
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
|
||||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||||
|
import DetailTabsHeader from "@/components/generic/DetailTabsHeader";
|
||||||
|
import { SectionCard, InfoRow } from "@/components/generic/DetailSectionCard";
|
||||||
|
|
||||||
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
|
const TABS = [
|
||||||
|
{ key: "preview", label: "Preview", icon: Music2 },
|
||||||
function MetaRow({ label, value }) {
|
{ key: "info", label: "File Info", icon: Info },
|
||||||
if (!value && value !== 0) return null;
|
];
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-3 py-2">
|
|
||||||
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
|
||||||
<span className="text-sm font-medium break-all">{String(value)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function ViewAudioAsset() {
|
export default function ViewAudioAsset() {
|
||||||
const { assetId } = useParams();
|
const { assetId } = useParams();
|
||||||
@@ -35,6 +28,7 @@ export default function ViewAudioAsset() {
|
|||||||
|
|
||||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||||
const { src: streamUrl, thumbnailUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
const { src: streamUrl, thumbnailUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||||
|
const [activeTab, setActiveTab] = useState("preview");
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <AssetPageLoader />;
|
return <AssetPageLoader />;
|
||||||
@@ -60,74 +54,81 @@ export default function ViewAudioAsset() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||||
|
<DetailTabsHeader
|
||||||
{/* ── Header ── */}
|
icon={Music2}
|
||||||
<div className="flex items-center gap-3">
|
title={a.display_name ?? a.original_name}
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
|
subtitle={a.mime_type}
|
||||||
<ArrowLeft className="h-4 w-4" />
|
onBack={() => navigate("/admin/assets")}
|
||||||
</Button>
|
tabs={TABS}
|
||||||
<div className="flex-1 min-w-0">
|
activeTab={activeTab}
|
||||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
onTabChange={setActiveTab}
|
||||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
actions={
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
Edit Info
|
Edit Info
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||||
|
{activeTab === "preview" ? (
|
||||||
{/* ── Audio player ── */}
|
streamUrl ? (
|
||||||
<div className="lg:col-span-3 space-y-4">
|
|
||||||
{streamUrl ? (
|
|
||||||
<AudioBlock content={audioContent} />
|
<AudioBlock content={audioContent} />
|
||||||
) : (
|
) : (
|
||||||
<MediaFallback className="size-full" />
|
<MediaFallback className="size-full" />
|
||||||
)}
|
)
|
||||||
|
) : (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||||
|
<SectionCard icon={Info} title="File Info">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InfoRow label="Original Name">{a.original_name}</InfoRow>
|
||||||
|
<InfoRow label="Extension">{a.extension}</InfoRow>
|
||||||
|
<InfoRow label="File Size">{formatFileSize(a.file_size)}</InfoRow>
|
||||||
|
<InfoRow label="MIME Type">{a.mime_type}</InfoRow>
|
||||||
</div>
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{/* ── Metadata panel ── */}
|
<SectionCard icon={Music2} title="Storage">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="rounded-lg border bg-card p-4 space-y-1">
|
<InfoRow label="Provider">{a.storage_provider}</InfoRow>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
|
<InfoRow label="Bucket">{a.storage_bucket}</InfoRow>
|
||||||
<MetaRow label="Original Name" value={a.original_name} />
|
<InfoRow label="Key">{a.storage_key}</InfoRow>
|
||||||
<MetaRow label="Extension" value={a.extension} />
|
</div>
|
||||||
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
|
</SectionCard>
|
||||||
<MetaRow label="MIME Type" value={a.mime_type} />
|
|
||||||
<Separator className="my-2" />
|
<SectionCard icon={Lock} title="Access">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
<div className="space-y-4">
|
||||||
<MetaRow label="Provider" value={a.storage_provider} />
|
<div className="flex items-center gap-2">
|
||||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
|
||||||
<MetaRow label="Key" value={a.storage_key} />
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
|
||||||
<div className="flex items-center gap-2 py-1">
|
|
||||||
{a.is_public
|
{a.is_public
|
||||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<MetaRow label="Access Level" value={a.access_level} />
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
<InfoRow label="Access Level">{a.access_level}</InfoRow>
|
||||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
<InfoRow label="Owner Type">{a.owner_type}</InfoRow>
|
||||||
<Separator className="my-2" />
|
<InfoRow label="Owner ID">{a.owner_id}</InfoRow>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
|
||||||
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
|
|
||||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
|
||||||
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
|
|
||||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard icon={Info} title="Timestamps">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InfoRow label="Created By">{a.creator?.full_name ?? a.createdBy}</InfoRow>
|
||||||
|
<InfoRow label="Created">{a.createdAt ? fmtDateTime(a.createdAt) : null}</InfoRow>
|
||||||
|
<InfoRow label="Modified By">{a.updater?.full_name ?? a.updatedBy}</InfoRow>
|
||||||
|
<InfoRow label="Modified">{a.updatedAt ? fmtDateTime(a.updatedAt) : null}</InfoRow>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{a.description && (
|
{a.description && (
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<SectionCard icon={Info} title="Description">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
|
||||||
<p className="text-sm text-foreground">{a.description}</p>
|
<p className="text-sm text-foreground">{a.description}</p>
|
||||||
</div>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// modules/admin/pages/assets/ViewDocumentAsset.jsx
|
// modules/admin/pages/assets/ViewDocumentAsset.jsx
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Globe, FileText, Pencil } from "lucide-react";
|
import { Lock, Globe, FileText, Pencil, Info } from "lucide-react";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||||
@@ -9,22 +10,18 @@ import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
|||||||
import { formatFileSize } from "@/utils/format.util";
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import FileZoomViewer from "@/components/generic/FileZoomViewer";
|
import FileZoomViewer from "@/components/generic/FileZoomViewer";
|
||||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||||
|
import DetailTabsHeader from "@/components/generic/DetailTabsHeader";
|
||||||
function MetaRow({ label, value }) {
|
import { SectionCard, InfoRow } from "@/components/generic/DetailSectionCard";
|
||||||
if (!value && value !== 0) return null;
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-3 py-2">
|
|
||||||
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
|
||||||
<span className="text-sm font-medium break-all">{String(value)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PREVIEWABLE = ["pdf", "txt", "html", "htm", "csv", "md"];
|
const PREVIEWABLE = ["pdf", "txt", "html", "htm", "csv", "md"];
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: "preview", label: "Preview", icon: FileText },
|
||||||
|
{ key: "info", label: "File Info", icon: Info },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ViewDocumentAsset() {
|
export default function ViewDocumentAsset() {
|
||||||
const { assetId } = useParams();
|
const { assetId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -32,6 +29,7 @@ export default function ViewDocumentAsset() {
|
|||||||
|
|
||||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||||
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||||
|
const [activeTab, setActiveTab] = useState("preview");
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <AssetPageLoader />;
|
return <AssetPageLoader />;
|
||||||
@@ -52,34 +50,33 @@ export default function ViewDocumentAsset() {
|
|||||||
const isPdf = ext === "pdf";
|
const isPdf = ext === "pdf";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||||
|
<DetailTabsHeader
|
||||||
{/* ── Header ── */}
|
icon={FileText}
|
||||||
<div className="flex items-center gap-3">
|
title={a.display_name ?? a.original_name}
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
|
subtitle={a.mime_type}
|
||||||
<ArrowLeft className="h-4 w-4" />
|
onBack={() => navigate("/admin/assets")}
|
||||||
</Button>
|
tabs={TABS}
|
||||||
<div className="flex-1 min-w-0">
|
activeTab={activeTab}
|
||||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
onTabChange={setActiveTab}
|
||||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
actions={
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
Edit Info
|
Edit Info
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className={`lg:container lg:mx-auto lg:px-6 px-4 py-6 flex flex-col${activeTab === "preview" ? " flex-1 min-h-0" : ""}`}>
|
||||||
|
{activeTab === "preview" ? (
|
||||||
{/* ── Document preview ── */}
|
isPdf ? (
|
||||||
<div className="lg:col-span-3">
|
<div className="rounded-lg border overflow-hidden flex flex-col flex-1 min-h-0">
|
||||||
{isPdf ? (
|
|
||||||
<div className="rounded-lg border overflow-hidden">
|
|
||||||
<FileZoomViewer
|
<FileZoomViewer
|
||||||
src={streamUrl}
|
src={streamUrl}
|
||||||
mimeType={a.mime_type}
|
mimeType={a.mime_type}
|
||||||
fileName={a.display_name ?? a.original_name}
|
fileName={a.display_name ?? a.original_name}
|
||||||
loading={previewLoading}
|
loading={previewLoading}
|
||||||
|
canvasClassName="flex-1 min-h-[500px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : canPreview && streamUrl ? (
|
) : canPreview && streamUrl ? (
|
||||||
@@ -98,49 +95,59 @@ export default function ViewDocumentAsset() {
|
|||||||
Preview not available for this file type.
|
Preview not available for this file type.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
|
) : (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||||
|
<SectionCard icon={Info} title="File Info">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InfoRow label="Original Name">{a.original_name}</InfoRow>
|
||||||
|
<InfoRow label="Extension">{a.extension}</InfoRow>
|
||||||
|
<InfoRow label="File Size">{formatFileSize(a.file_size)}</InfoRow>
|
||||||
|
<InfoRow label="MIME Type">{a.mime_type}</InfoRow>
|
||||||
</div>
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{/* ── Metadata panel ── */}
|
<SectionCard icon={FileText} title="Storage">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="rounded-lg border bg-card p-4 space-y-1">
|
<InfoRow label="Provider">{a.storage_provider}</InfoRow>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
|
<InfoRow label="Bucket">{a.storage_bucket}</InfoRow>
|
||||||
<MetaRow label="Original Name" value={a.original_name} />
|
<InfoRow label="Key">{a.storage_key}</InfoRow>
|
||||||
<MetaRow label="Extension" value={a.extension} />
|
</div>
|
||||||
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
|
</SectionCard>
|
||||||
<MetaRow label="MIME Type" value={a.mime_type} />
|
|
||||||
<Separator className="my-2" />
|
<SectionCard icon={Lock} title="Access">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
<div className="space-y-4">
|
||||||
<MetaRow label="Provider" value={a.storage_provider} />
|
<div className="flex items-center gap-2">
|
||||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
|
||||||
<MetaRow label="Key" value={a.storage_key} />
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
|
||||||
<div className="flex items-center gap-2 py-1">
|
|
||||||
{a.is_public
|
{a.is_public
|
||||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<MetaRow label="Access Level" value={a.access_level} />
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
<InfoRow label="Access Level">{a.access_level}</InfoRow>
|
||||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
<InfoRow label="Owner Type">{a.owner_type}</InfoRow>
|
||||||
<Separator className="my-2" />
|
<InfoRow label="Owner ID">{a.owner_id}</InfoRow>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
|
||||||
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
|
|
||||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
|
||||||
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
|
|
||||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard icon={Info} title="Timestamps">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InfoRow label="Created By">{a.creator?.full_name ?? a.createdBy}</InfoRow>
|
||||||
|
<InfoRow label="Created">{a.createdAt ? fmtDateTime(a.createdAt) : null}</InfoRow>
|
||||||
|
<InfoRow label="Modified By">{a.updater?.full_name ?? a.updatedBy}</InfoRow>
|
||||||
|
<InfoRow label="Modified">{a.updatedAt ? fmtDateTime(a.updatedAt) : null}</InfoRow>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{a.description && (
|
{a.description && (
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<SectionCard icon={Info} title="Description">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
|
||||||
<p className="text-sm text-foreground">{a.description}</p>
|
<p className="text-sm text-foreground">{a.description}</p>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Globe, Pencil, Download } from "lucide-react";
|
import { Image, Info, Lock, Globe, Pencil, Download } from "lucide-react";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||||
@@ -11,20 +11,15 @@ import { formatFileSize } from "@/utils/format.util";
|
|||||||
import { downloadAsset } from "@/utils/media.util";
|
import { downloadAsset } from "@/utils/media.util";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import FileZoomViewer from "@/components/generic/FileZoomViewer";
|
import FileZoomViewer from "@/components/generic/FileZoomViewer";
|
||||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||||
|
import DetailTabsHeader from "@/components/generic/DetailTabsHeader";
|
||||||
|
import { SectionCard, InfoRow } from "@/components/generic/DetailSectionCard";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
function MetaRow({ label, value }) {
|
{ key: "preview", label: "Preview", icon: Image },
|
||||||
if (!value && value !== 0) return null;
|
{ key: "info", label: "File Info", icon: Info },
|
||||||
return (
|
];
|
||||||
<div className="flex items-start gap-3 py-2">
|
|
||||||
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
|
||||||
<span className="text-sm font-medium break-all">{String(value)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ViewImageAsset() {
|
export default function ViewImageAsset() {
|
||||||
const { assetId } = useParams();
|
const { assetId } = useParams();
|
||||||
@@ -34,6 +29,7 @@ export default function ViewImageAsset() {
|
|||||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||||
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState("preview");
|
||||||
|
|
||||||
async function handleDownload() {
|
async function handleDownload() {
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
@@ -60,17 +56,17 @@ export default function ViewImageAsset() {
|
|||||||
const a = selectedAsset;
|
const a = selectedAsset;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||||
|
<DetailTabsHeader
|
||||||
{/* ── Header ── */}
|
icon={Image}
|
||||||
<div className="flex items-center gap-3">
|
title={a.display_name ?? a.original_name}
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
|
subtitle={a.mime_type}
|
||||||
<ArrowLeft className="h-4 w-4" />
|
onBack={() => navigate("/admin/assets")}
|
||||||
</Button>
|
tabs={TABS}
|
||||||
<div className="flex-1 min-w-0">
|
activeTab={activeTab}
|
||||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
onTabChange={setActiveTab}
|
||||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
actions={
|
||||||
</div>
|
<>
|
||||||
<Button variant="outline" className="hidden" onClick={handleDownload} disabled={downloading}>
|
<Button variant="outline" className="hidden" onClick={handleDownload} disabled={downloading}>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
{downloading ? "Downloading…" : "Download"}
|
{downloading ? "Downloading…" : "Download"}
|
||||||
@@ -79,61 +75,74 @@ export default function ViewImageAsset() {
|
|||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
Edit Info
|
Edit Info
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className={`lg:container lg:mx-auto lg:px-6 px-4 py-6 flex flex-col${activeTab === "preview" ? " flex-1 min-h-0" : ""}`}>
|
||||||
|
{activeTab === "preview" ? (
|
||||||
{/* ── Image preview ── */}
|
<div className="rounded-lg border overflow-hidden flex flex-col flex-1 min-h-0">
|
||||||
<div className="lg:col-span-3 rounded-lg border overflow-hidden">
|
|
||||||
<FileZoomViewer
|
<FileZoomViewer
|
||||||
src={streamUrl}
|
src={streamUrl}
|
||||||
mimeType={a.mime_type}
|
mimeType={a.mime_type}
|
||||||
fileName={a.display_name ?? a.original_name}
|
fileName={a.display_name ?? a.original_name}
|
||||||
loading={previewLoading}
|
loading={previewLoading}
|
||||||
|
canvasClassName="flex-1 min-h-[500px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||||
|
<SectionCard icon={Info} title="File Info">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InfoRow label="Original Name">{a.original_name}</InfoRow>
|
||||||
|
<InfoRow label="Extension">{a.extension}</InfoRow>
|
||||||
|
<InfoRow label="File Size">{formatFileSize(a.file_size)}</InfoRow>
|
||||||
|
<InfoRow label="Resolution">{a.resolution}</InfoRow>
|
||||||
|
<InfoRow label="Dimensions">{a.width && a.height ? `${a.width} × ${a.height}` : null}</InfoRow>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{/* ── Metadata panel ── */}
|
<SectionCard icon={Image} title="Storage">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="rounded-lg border bg-card p-4 space-y-1">
|
<InfoRow label="Provider">{a.storage_provider}</InfoRow>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
|
<InfoRow label="Bucket">{a.storage_bucket}</InfoRow>
|
||||||
<MetaRow label="Original Name" value={a.original_name} />
|
<InfoRow label="Key">{a.storage_key}</InfoRow>
|
||||||
<MetaRow label="Extension" value={a.extension} />
|
</div>
|
||||||
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
|
</SectionCard>
|
||||||
<MetaRow label="Resolution" value={a.resolution} />
|
|
||||||
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
<SectionCard icon={Lock} title="Access">
|
||||||
<Separator className="my-2" />
|
<div className="space-y-4">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
<div className="flex items-center gap-2">
|
||||||
<MetaRow label="Provider" value={a.storage_provider} />
|
|
||||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
|
||||||
<MetaRow label="Key" value={a.storage_key} />
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
|
||||||
<div className="flex items-center gap-2 py-1">
|
|
||||||
{a.is_public
|
{a.is_public
|
||||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<MetaRow label="Access Level" value={a.access_level} />
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
<InfoRow label="Access Level">{a.access_level}</InfoRow>
|
||||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
<InfoRow label="Owner Type">{a.owner_type}</InfoRow>
|
||||||
<Separator className="my-2" />
|
<InfoRow label="Owner ID">{a.owner_id}</InfoRow>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
|
||||||
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
|
|
||||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
|
||||||
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
|
|
||||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard icon={Info} title="Timestamps">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InfoRow label="Created By">{a.creator?.full_name ?? a.createdBy}</InfoRow>
|
||||||
|
<InfoRow label="Created">{a.createdAt ? fmtDateTime(a.createdAt) : null}</InfoRow>
|
||||||
|
<InfoRow label="Modified By">{a.updater?.full_name ?? a.updatedBy}</InfoRow>
|
||||||
|
<InfoRow label="Modified">{a.updatedAt ? fmtDateTime(a.updatedAt) : null}</InfoRow>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{a.description && (
|
{a.description && (
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<SectionCard icon={Info} title="Description">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
|
||||||
<p className="text-sm text-foreground">{a.description}</p>
|
<p className="text-sm text-foreground">{a.description}</p>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Globe, Pencil } from "lucide-react";
|
import { Lock, Globe, Pencil, Video, Info } from "lucide-react";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||||
import { formatFileSize, formatPlayerTime } from "@/utils/format.util";
|
import { formatFileSize, formatPlayerTime } from "@/utils/format.util";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { VideoBlock } from "@/components/generic/Blocks/Admin/VideoBlock";
|
import { VideoBlock } from "@/components/generic/Blocks/Admin/VideoBlock";
|
||||||
import { TranscodeStatusBanner } from "@/components/generic/TranscodeStatusBanner";
|
import { TranscodeStatusBanner } from "@/components/generic/TranscodeStatusBanner";
|
||||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||||
|
import DetailTabsHeader from "@/components/generic/DetailTabsHeader";
|
||||||
|
import { SectionCard, InfoRow } from "@/components/generic/DetailSectionCard";
|
||||||
|
|
||||||
function MetaRow({ label, value }) {
|
const TABS = [
|
||||||
if (!value && value !== 0) return null;
|
{ key: "preview", label: "Preview", icon: Video },
|
||||||
return (
|
{ key: "info", label: "File Info", icon: Info },
|
||||||
<div className="flex items-start gap-3 py-2">
|
];
|
||||||
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
|
||||||
<span className="text-sm font-medium break-all">{String(value)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ViewVideoAsset() {
|
export default function ViewVideoAsset() {
|
||||||
const { assetId } = useParams();
|
const { assetId } = useParams();
|
||||||
@@ -29,6 +26,7 @@ export default function ViewVideoAsset() {
|
|||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
|
|
||||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||||
|
const [activeTab, setActiveTab] = useState("preview");
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <AssetPageLoader />;
|
return <AssetPageLoader />;
|
||||||
@@ -46,27 +44,26 @@ export default function ViewVideoAsset() {
|
|||||||
const a = selectedAsset;
|
const a = selectedAsset;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||||
|
<DetailTabsHeader
|
||||||
{/* ── Header ── */}
|
icon={Video}
|
||||||
<div className="flex items-center gap-3">
|
title={a.display_name ?? a.original_name}
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
|
subtitle={a.mime_type}
|
||||||
<ArrowLeft className="h-4 w-4" />
|
onBack={() => navigate("/admin/assets")}
|
||||||
</Button>
|
tabs={TABS}
|
||||||
<div className="flex-1 min-w-0">
|
activeTab={activeTab}
|
||||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
onTabChange={setActiveTab}
|
||||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
actions={
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
Edit Info
|
Edit Info
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||||
|
{activeTab === "preview" ? (
|
||||||
{/* ── Video player ── */}
|
<div className="space-y-3">
|
||||||
<div className="lg:col-span-3 space-y-3">
|
|
||||||
<TranscodeStatusBanner status={a.transcode_status} />
|
<TranscodeStatusBanner status={a.transcode_status} />
|
||||||
<VideoBlock
|
<VideoBlock
|
||||||
readOnly
|
readOnly
|
||||||
@@ -95,53 +92,64 @@ export default function ViewVideoAsset() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||||
|
<SectionCard icon={Info} title="Video Info">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InfoRow label="Original Name">{a.original_name}</InfoRow>
|
||||||
|
<InfoRow label="Extension">{a.extension}</InfoRow>
|
||||||
|
<InfoRow label="File Size">{formatFileSize(a.file_size)}</InfoRow>
|
||||||
|
<InfoRow label="Resolution">{a.resolution}</InfoRow>
|
||||||
|
<InfoRow label="Dimensions">{a.width && a.height ? `${a.width} × ${a.height}` : null}</InfoRow>
|
||||||
|
<InfoRow label="Duration">{a.duration == null ? null : formatPlayerTime(a.duration)}</InfoRow>
|
||||||
|
<InfoRow label="Frame Rate">{a.frame_rate ? `${a.frame_rate} fps` : null}</InfoRow>
|
||||||
|
<InfoRow label="Bitrate">{a.bitrate ? `${a.bitrate} kbps` : null}</InfoRow>
|
||||||
|
<InfoRow label="Video Codec">{a.video_codec}</InfoRow>
|
||||||
|
<InfoRow label="Audio Codec">{a.audio_codec}</InfoRow>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{/* ── Metadata panel ── */}
|
<SectionCard icon={Video} title="Storage">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="rounded-lg border bg-card p-4 space-y-1">
|
<InfoRow label="Provider">{a.storage_provider}</InfoRow>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Video Info</p>
|
<InfoRow label="Bucket">{a.storage_bucket}</InfoRow>
|
||||||
<MetaRow label="Original Name" value={a.original_name} />
|
<InfoRow label="Key">{a.storage_key}</InfoRow>
|
||||||
<MetaRow label="Extension" value={a.extension} />
|
</div>
|
||||||
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
|
</SectionCard>
|
||||||
<MetaRow label="Resolution" value={a.resolution} />
|
|
||||||
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
<SectionCard icon={Lock} title="Access">
|
||||||
<MetaRow label="Duration" value={a.duration == null ? null : formatPlayerTime(a.duration)} />
|
<div className="space-y-4">
|
||||||
<MetaRow label="Frame Rate" value={a.frame_rate ? `${a.frame_rate} fps` : null} />
|
<div className="flex items-center gap-2">
|
||||||
<MetaRow label="Bitrate" value={a.bitrate ? `${a.bitrate} kbps` : null} />
|
|
||||||
<MetaRow label="Video Codec" value={a.video_codec} />
|
|
||||||
<MetaRow label="Audio Codec" value={a.audio_codec} />
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
|
||||||
<MetaRow label="Provider" value={a.storage_provider} />
|
|
||||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
|
||||||
<MetaRow label="Key" value={a.storage_key} />
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
|
||||||
<div className="flex items-center gap-2 py-1">
|
|
||||||
{a.is_public
|
{a.is_public
|
||||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<MetaRow label="Access Level" value={a.access_level} />
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
<InfoRow label="Access Level">{a.access_level}</InfoRow>
|
||||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
<InfoRow label="Owner Type">{a.owner_type}</InfoRow>
|
||||||
<Separator className="my-2" />
|
<InfoRow label="Owner ID">{a.owner_id}</InfoRow>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
|
||||||
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
|
|
||||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
|
||||||
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
|
|
||||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard icon={Info} title="Timestamps">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InfoRow label="Created By">{a.creator?.full_name ?? a.createdBy}</InfoRow>
|
||||||
|
<InfoRow label="Created">{a.createdAt ? fmtDateTime(a.createdAt) : null}</InfoRow>
|
||||||
|
<InfoRow label="Modified By">{a.updater?.full_name ?? a.updatedBy}</InfoRow>
|
||||||
|
<InfoRow label="Modified">{a.updatedAt ? fmtDateTime(a.updatedAt) : null}</InfoRow>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{a.description && (
|
{a.description && (
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<SectionCard icon={Info} title="Description">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
|
||||||
<p className="text-sm text-foreground">{a.description}</p>
|
<p className="text-sm text-foreground">{a.description}</p>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+3
-1
@@ -5,7 +5,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -n api,web -c blue,green \"pnpm --filter api dev\" \"pnpm --filter web dev\"",
|
"dev": "concurrently -n api,web -c blue,green \"pnpm --filter api dev\" \"pnpm --filter web dev\"",
|
||||||
"build": "pnpm --filter web build",
|
"build": "pnpm --filter web build",
|
||||||
"start": "pnpm --filter api start"
|
"start": "pnpm --filter api start",
|
||||||
|
"test": "pnpm --filter api test",
|
||||||
|
"test:ci": "pnpm --filter e2e test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1"
|
"concurrently": "^9.2.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user