diff --git a/.env.example b/.env.example index abc11cd5..ed7a19df 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # Database Congiguration DB_USERNAME=godot-asset-library DB_PASSWORD=root +DB_HOST=mongo DB_PORT=27017 DB_NAME=godot-asset-library @@ -19,3 +20,16 @@ IMGPROXY_SALT= IMGPROXY_HOST=https://img.godotassetlibrary.com IMGPROXY_ENABLED=true PROJECT_BASE_URL=https://godotassetlibrary.com + +# Mongo pool / concurrency tuning (optional) +# The driver always pools connections, but the pool is lazy: connections are +# created on demand (minPoolSize 0), capped at MONGO_MAX_POOL, and idle ones +# are pruned after MONGO_MAX_IDLE_MS. +# MONGO_MAX_POOL=1000 # max connections per client (default 1000; high ceiling = "spin up as needed") +# MONGO_MIN_POOL=0 # pre-warmed connections kept ready (default 0 = none; pure lazy) +# MONGO_MAX_IDLE_MS=300000 # close idle connections after this many ms (default 5 min) +# MAX_CONCURRENT_REQUESTS=100 +# ARGON2_MAX_CONCURRENCY=2 + +# Import window (comma separated Godot versions the mirror imports) +# IMPORT_GODOT_VERSIONS=2.2,3.9,4.9 diff --git a/.eslintignore b/.eslintignore index 0051b4b2..d1e30ddd 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ node_modules # don't lint build output (make sure it's set to your correct build folder name) dist +dist-test diff --git a/.gitignore b/.gitignore index 9e8c5b69..2dddf87f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/* +dist-test/ .env diff --git a/README.md b/README.md index 218baada..fd0ae94c 100644 --- a/README.md +++ b/README.md @@ -35,20 +35,35 @@ npm run devel For linting: ``` -npm run lint +npm run lint:check ``` -### Indexes -Searching the catalog of assets relies on MongoDBs `text` search, so we need to create a text asset on the `asset` collection. To do so is pretty easy, just log into the MongoDB shell and run: +### Verification ``` -db.assets.createIndex({ - description: "text", - quick_description: "text", - title: "text", - author: "text", -}) +npm run typecheck # TypeScript validation (use this, not `build`, for types) +npm run lint:check # ESLint enforcement (npm run lint exits 0 by design) +npm test # Compiles and runs the node:test suite (host Node 18+) +npm run build # Webpack bundle + per-page Sass +``` + +### Indexes & migrations +Search relies on MongoDB text search and several derived fields. Migrations +are applied with a single command (they record completion in the `migrations` +collection, so they are idempotent): +``` +npm run migrate +``` +Migrations create/verify: +* `0001` — the weighted text index (`title` 10, `quick_description` 7, `author` 7, `description` 1). MongoDB only allows one text index per collection, so a conflicting legacy index must be dropped manually first. +* `0002` — backfills the confidence-adjusted `rating_score` (95% Wilson lower bound) used by "Highest rated" sorting. +* `0003` — backfills the normalized `modify_date_at` used by "Recently updated" sorting. +* `0004` — deduplicates reviews by `(user_id, asset_id)` and creates the unique index. + +Operational maintenance (run against a snapshot first): +``` +npm run reconcile:ratings # Recompute vote counters + rating_score from reviews +npm run audit:catalog # Read-only catalog health audit ``` -In future versions we may do this automatically if it detects the index doesn't already exist, but for now its a manual process. ## Folder Structure ``` diff --git a/package.json b/package.json index 4b6d0fa1..698d33ad 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,13 @@ "build": "mkdir -p dist/ && node src/BuildTaskRunner.js --production --build-only", "start": "node dist/bundle.js", "lint": "eslint . --ext .js,.ts ; exit 0", + "lint:check": "eslint . --ext .js,.ts", + "typecheck": "tsc --noEmit", + "audit:catalog": "ts-node --transpile-only -r tsconfig-paths/register src/core/maintenance/runCatalogAudit.ts", + "reconcile:ratings": "ts-node --transpile-only -r tsconfig-paths/register src/core/maintenance/runReconcileRatings.ts", + "migrate": "ts-node --transpile-only -r tsconfig-paths/register src/core/migrations/runMigrations.ts", + "test:build": "tsc -p tsconfig.test.json", + "test": "npm run test:build && node --test --require ./tests/bootstrap.cjs \"dist-test/tests/*.test.js\"", "prod": "npm run build && npm start" }, "eslintConfig": { diff --git a/src/app/code/asset/controllers/AssetController.ts b/src/app/code/asset/controllers/AssetController.ts index a863efd8..35f34ff6 100644 --- a/src/app/code/asset/controllers/AssetController.ts +++ b/src/app/code/asset/controllers/AssetController.ts @@ -2,6 +2,7 @@ import { Controller, Get, Middleware, Post, Patch } from '@overnightjs/core' import { Request, Response } from 'express' import rateLimit from 'express-rate-limit' import { CheckIfUserExistAndSendError } from 'core/modules/authentication/middleware/CheckIfUserExistAndSendError' +import { rateLimitHandler } from 'core/utils/rateLimitHandler' import { AssetService } from '../services/AssetService' const reviewAssetRateLimit = rateLimit({ @@ -25,7 +26,7 @@ const reportReviewRateLimit = rateLimit({ const renderAssetRateLimit = rateLimit({ windowMs: 1000 * 60 * 15, // 15 minutes max: 60, // start blocking after x requests - message: JSON.stringify({ error: 'You\'re doing that too often, please try again later' }) + handler: rateLimitHandler('You\'re doing that too often, please try again later') }) @Controller('asset') diff --git a/src/app/code/asset/models/GET/GetAssetDisplayInformation.ts b/src/app/code/asset/models/GET/GetAssetDisplayInformation.ts index 5b441697..83642e8b 100644 --- a/src/app/code/asset/models/GET/GetAssetDisplayInformation.ts +++ b/src/app/code/asset/models/GET/GetAssetDisplayInformation.ts @@ -8,9 +8,9 @@ interface ReturnedAsset extends WithId, assetSchema {} * Get asset information for display page * * @param {string} assetId - * @returns {ReturnedAsset} + * @returns {Promise} the asset, or null when it does not exist */ -export async function GetAssetDisplayInformation (assetId: String): Promise { +export async function GetAssetDisplayInformation (assetId: String): Promise { const mongo = MongoHelper.getDatabase() const operationObject = await mongo.collection('assets').findOne({ asset_id: assetId }, { projection: { @@ -22,11 +22,7 @@ export async function GetAssetDisplayInformation (assetId: String): Promise { + const mongo = MongoHelper.getDatabase() + return await mongo.collection('reviews').countDocuments({ asset_id: assetId }) +} diff --git a/src/app/code/asset/models/GET/GetAssetReviewsById.ts b/src/app/code/asset/models/GET/GetAssetReviewsById.ts index 0cf0850e..6ebccee5 100644 --- a/src/app/code/asset/models/GET/GetAssetReviewsById.ts +++ b/src/app/code/asset/models/GET/GetAssetReviewsById.ts @@ -5,14 +5,26 @@ import { reviews } from 'app/utilities/fetchFromGodot/schema/reviews' interface ReturnedReviews extends WithId, reviews {} /** - * Get all reviews for asset + * Get reviews for asset, newest first, deterministically ordered and bounded + * to a page of results. * * @param {string} assetId + * @param {number} limit max reviews to return + * @param {number} skip how many newest reviews to skip * @returns {ReturnedReviews} */ -export async function GetAssetReviewsById (assetId: String): Promise { +export async function GetAssetReviewsById ( + assetId: String, + limit: number = 10, + skip: number = 0 +): Promise { const mongo = MongoHelper.getDatabase() - const operationObject = await mongo.collection('reviews').find({ asset_id: assetId }).limit(10).toArray() as ReturnedReviews[] + const operationObject = await mongo.collection('reviews') + .find({ asset_id: assetId }) + .sort({ date: -1, _id: -1 }) + .limit(limit) + .skip(skip) + .toArray() as ReturnedReviews[] return operationObject } diff --git a/src/app/code/asset/models/GET/GetRelatedAssets.ts b/src/app/code/asset/models/GET/GetRelatedAssets.ts new file mode 100644 index 00000000..ba50563a --- /dev/null +++ b/src/app/code/asset/models/GET/GetRelatedAssets.ts @@ -0,0 +1,78 @@ +import { Document, WithId } from 'mongodb' +import { MongoHelper } from 'core/MongoHelper' +import { assetGridSchema } from 'app/components/partials/catalog-grid/asset-grd-schema' + +interface ReturnedAssets extends WithId, assetGridSchema {} + +/** + * Related assets with compatibility-aware fallback tiers. A bounded pool of + * same-category assets is ranked by how close each peer is to the current + * asset: exact Godot version first, then same major version, then same type, + * then by confidence-adjusted rating and recency. + */ +export async function GetRelatedAssets ( + category: string, + godotVersion: string | undefined, + assetType: string | undefined, + excludeAssetId: string +): Promise { + const mongo = MongoHelper.getDatabase() + + const pool = await mongo.collection('assets').find( + { + asset_id: { $ne: excludeAssetId }, + category: category + }, + { + limit: 12, + sort: { rating_score: -1, upvotes: -1, asset_id: 1 }, + projection: { + category: 1, + godot_version: 1, + author: 1, + title: 1, + quick_description: 1, + icon_url: 1, + upvotes: 1, + downvotes: 1, + rating_score: 1, + featured: 1, + asset_id: 1, + previews: 1, + card_banner: 1, + modify_date: 1, + modify_date_at: 1, + added_date: 1, + version_string: 1, + type: 1, + support_level: 1 + } + } + ).toArray() as ReturnedAssets[] + + const major = godotVersion?.split('.')[0] + const ranked = pool + .map(asset => { + let tier = 3 + if (godotVersion !== undefined && godotVersion !== '' && asset.godot_version === godotVersion) { + tier = 0 + } else if (major !== undefined && major !== '' && String(asset.godot_version ?? '').split('.')[0] === major) { + tier = 1 + } else if (assetType !== undefined && assetType !== '' && asset.type === assetType) { + tier = 2 + } + return { asset, tier } + }) + .sort((a, b) => { + if (a.tier !== b.tier) return a.tier - b.tier + const aScore = Number(a.asset.rating_score ?? 0) + const bScore = Number(b.asset.rating_score ?? 0) + if (aScore !== bScore) return bScore - aScore + const aUp = Number(a.asset.upvotes ?? 0) + const bUp = Number(b.asset.upvotes ?? 0) + if (aUp !== bUp) return bUp - aUp + return String(a.asset.asset_id).localeCompare(String(b.asset.asset_id)) + }) + + return ranked.slice(0, 4).map(entry => entry.asset) +} diff --git a/src/app/code/asset/models/UPDATE/RefreshAssetRating.ts b/src/app/code/asset/models/UPDATE/RefreshAssetRating.ts new file mode 100644 index 00000000..535d495c --- /dev/null +++ b/src/app/code/asset/models/UPDATE/RefreshAssetRating.ts @@ -0,0 +1,38 @@ +import { MongoHelper } from 'core/MongoHelper' +import { wilsonScore } from 'core/utils/ratingScore' + +/** + * Recompute an asset's upvotes/downvotes/rating_score from its canonical + * review documents. This is the single source of truth for the counters and + * keeps them consistent with what cards display, even if an earlier write + * failed partway through. + */ +export async function RefreshAssetRating (assetId: string): Promise { + const mongo = MongoHelper.getDatabase() + const reviews = mongo.collection('reviews') + + const [row] = await reviews.aggregate([ + { $match: { asset_id: assetId } }, + { + $group: { + _id: '$asset_id', + upvotes: { $sum: { $cond: [{ $eq: ['$review_type', 'positive'] }, 1, 0] } }, + downvotes: { $sum: { $cond: [{ $eq: ['$review_type', 'negative'] }, 1, 0] } } + } + } + ]).maxTimeMS(5000).toArray() + + const upvotes = row?.upvotes ?? 0 + const downvotes = row?.downvotes ?? 0 + + await mongo.collection('assets').updateOne( + { asset_id: assetId }, + { + $set: { + upvotes, + downvotes, + rating_score: wilsonScore(upvotes, downvotes) + } + } + ) +} diff --git a/src/app/code/asset/services/AssetService.ts b/src/app/code/asset/services/AssetService.ts index 5fab7ae3..131951f8 100644 --- a/src/app/code/asset/services/AssetService.ts +++ b/src/app/code/asset/services/AssetService.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express' +import { StatusCodes } from 'http-status-codes' import { logger } from 'core/utils/logger' import { GetAssetDisplayInformation } from '../models/GET/GetAssetDisplayInformation' import { GetDoesPostExistById } from '../models/GET/GetDoesPostExistById' @@ -19,10 +20,18 @@ import { GetUserSavedAssets } from 'app/code/dashboard/models/GET/GetUserSavedAs import { GetSiteRestrictions } from 'app/code/admin/models/GET/GetSiteRestrictions' import { GetIsAccountDisabledByToken } from '../models/GET/GetIsAccountDisabledByToken' import { InsertReviewReport } from '../models/INSERT/InsertReviewReport' +import { GetRelatedAssets } from '../models/GET/GetRelatedAssets' +import { GetAssetReviewCount } from '../models/GET/GetAssetReviewCount' +import { RefreshAssetRating } from '../models/UPDATE/RefreshAssetRating' import fromNow from 'fromnow' import striptags from 'striptags' import { getFallbackImage, normalizePreviews } from 'core/utils/mediaHelpers' import { renderReadme } from 'core/utils/readmeRenderer' +import { isSafeHttpUrl } from 'core/utils/safeUrl' +import { escapeHtml } from 'core/utils/escapeHtml' +import { buildAssetUrl } from 'core/utils/assetUrl' +import { BadRequestError } from 'core/utils/httpError' +import { attachCardExtras } from 'core/utils/cardView' export class AssetService { /** @@ -36,16 +45,68 @@ export class AssetService { const assetId = striptags(req.params.id ?? '') const authToken = striptags(req.cookies['auth-token'] ?? '') + // "Back to results" must stay local to discovery routes to avoid open redirects. + const fromParam = striptags(String(req.query.from ?? '')) + const VALID_BACK_PREFIXES = ['/search/', '/category/', '/engine/'] + const isSafeBackLink = VALID_BACK_PREFIXES.some(prefix => fromParam.startsWith(prefix)) && + !fromParam.includes('://') && + !fromParam.includes('..') + const backToResults = isSafeBackLink ? fromParam : '' + if (assetId === '') { throw new Error('Missing asset ID') } try { const assetInfo = await GetAssetDisplayInformation(assetId) - const comments = await GetAssetReviewsById(assetId) + + if (assetInfo === null) { + return res.status(StatusCodes.NOT_FOUND).render('templates/pages/lost/not-found', { + pageBanner: { + title: 'Asset not found', + info: 'We couldn\'t find an asset with that ID' + } + }) + } + + // Imported asset data is untrusted. Only http(s) URLs may be rendered as + // links, otherwise non-HTTP schemes (javascript:, data:, ...) would reach + // the browser through the download/repository/issues controls. + for (const field of ['download_url', 'browse_url', 'issues_url'] as const) { + if (!isSafeHttpUrl(assetInfo[field])) assetInfo[field] = '' + } + + const REVIEWS_PER_PAGE = 10 + const parsedReviewsPage = Number.parseInt(striptags(String(req.query.reviews_page ?? '')), 10) + const reviewsPage = Number.isNaN(parsedReviewsPage) + ? 0 + : Math.max(0, Math.min(100, parsedReviewsPage)) + + const comments = await GetAssetReviewsById(assetId, REVIEWS_PER_PAGE, reviewsPage * REVIEWS_PER_PAGE) let hasUserReviewedAsset = false let usersAssetReview = {} + let reviewCount = comments.length + let relatedAssets: Awaited> = [] + + try { + reviewCount = await GetAssetReviewCount(assetId) + } catch (e) { + // ignore + } + + try { + relatedAssets = await GetRelatedAssets( + assetInfo.category, + assetInfo.godot_version, + assetInfo.type, + assetInfo.asset_id + ) + } catch (e) { + // ignore + } + attachCardExtras(relatedAssets) + assetInfo.modify_date_pretty = fromNow(new Date(assetInfo.modify_date), { suffix: true, zero: false, @@ -78,24 +139,55 @@ export class AssetService { const pageBanner = { title: assetInfo.title, - info: `An asset by ${assetInfo.author}` + info: `An asset by ${escapeHtml(assetInfo.author)}`, + backLink: backToResults !== '' + ? { url: backToResults, label: 'Back to results', title: 'Back to previous results' } + : null, + breadcrumb: [ + { label: 'Home', url: '/' }, + { + label: assetInfo.category ?? 'Assets', + url: assetInfo.category_lowercase != null ? `/category/${assetInfo.category_lowercase}` : '' + }, + { label: assetInfo.title ?? 'Asset', url: '' } + ] } const mediaItems = normalizePreviews(assetInfo.previews) + const galleryMedia = mediaItems.filter(item => item.type !== 'external') const fallbackImage = getFallbackImage(assetInfo) + const reviewsHasMore = (reviewsPage * REVIEWS_PER_PAGE) + comments.length < reviewCount + const reviewsNextPage = reviewsPage + 1 + const nextReviewsQuery = new URLSearchParams() + nextReviewsQuery.set('reviews_page', String(reviewsNextPage)) + if (backToResults !== '') nextReviewsQuery.set('from', backToResults) + return res.render('templates/pages/asset/view', { info: assetInfo, comments: comments, + relatedAssets: relatedAssets, + reviewCount: reviewCount, + reviewsShown: comments.length, + reviewsPage: reviewsPage, + reviewsHasMore: reviewsHasMore, + reviewsNextUrl: `${buildAssetUrl(assetId, assetInfo.title)}?${nextReviewsQuery.toString()}`, + backToResults: backToResults, hasUserReviewedAsset: hasUserReviewedAsset, usersAssetReview: usersAssetReview, pageBanner: pageBanner, - mediaItems: mediaItems, - primaryMedia: mediaItems[0] ?? null, + mediaItems: galleryMedia, + primaryMedia: galleryMedia[0] ?? null, + noindex: assetInfo.source_status === 'unavailable', fallbackImage: fallbackImage }) } catch (e: any) { logger.log('error', `Failed to load asset page: ${assetId}, ${e?.message}`, [e]) - return res.send({ error: 'Sorry, we\'re having issues loading this page right now' }) + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).render('templates/pages/lost/server-error', { + pageBanner: { + title: 'Something went wrong', + info: 'Sorry, we\'re having issues loading this page right now' + } + }) } } @@ -122,43 +214,44 @@ export class AssetService { } if (siteRestrictions?.disable_new_comments === true || isAccountDisabled) { - throw new Error('Posting new reviews has been temporarily disabled') + throw new BadRequestError('Posting new reviews has been temporarily disabled') } if (assetId === '') { - throw new Error('Missing post ID') + throw new BadRequestError('Missing post ID') } if (authToken === undefined || authToken === '') { - throw new Error('Missing auth token. Are you logged in?') + throw new BadRequestError('Missing auth token. Are you logged in?') } if (rating === '' || (rating !== 'positive' && rating !== 'negative')) { - throw new Error('Missing or invalid rating selection, expected "positive" or "negative"') + throw new BadRequestError('Missing or invalid rating selection, expected "positive" or "negative"') } if (review.length > 500) { - throw new Error('Review text is too long, must be less than 500 characters') + throw new BadRequestError('Review text is too long, must be less than 500 characters') } if (review.length > 0 && review.length < 5) { - throw new Error('Review text too short, must be at least 5 characters') + throw new BadRequestError('Review text too short, must be at least 5 characters') } if (headline.length > 50) { - throw new Error('Headline text is too long, must be less than 50 characters') + throw new BadRequestError('Headline text is too long, must be less than 50 characters') } if (headline.length > 0 && headline.length < 3) { - throw new Error('Headline too short, must be at least 3 characters') + throw new BadRequestError('Headline too short, must be at least 3 characters') } - if (review.length > 5 && headline.length < 3) { - throw new Error('If you add a review you need a headline, too') + // A review of exactly 5 characters is still a review and needs a headline. + if (review.length >= 5 && headline.length < 3) { + throw new BadRequestError('If you add a review you need a headline, too') } if (!(await GetDoesPostExistById(assetId))) { - throw new Error('Asset not found') + throw new BadRequestError('Asset not found') } const userId = await GetUserIdByToken(authToken) @@ -172,7 +265,14 @@ export class AssetService { } await UpdateUserReviewedAssets(authToken, assetId) - await InsertReviewForAsset(userId, username, assetId, rating, striptags(review), striptags(headline)) + try { + await InsertReviewForAsset(userId, username, assetId, rating, striptags(review), striptags(headline)) + } catch (e: any) { + // The unique (user_id, asset_id) index makes duplicate inserts + // impossible. A concurrent request may have won the race, so treat + // this as an update rather than surfacing a server error. + await UpdateReviewForAsset(userId, assetId, rating, striptags(review), striptags(headline)) + } } else { const oldReview = await GetAssetReviewByUserId(assetId, userId) @@ -187,32 +287,38 @@ export class AssetService { await UpdateReviewForAsset(userId, assetId, rating, striptags(review), striptags(headline)) } + // Recompute counters from the canonical reviews so upvotes/downvotes and + // the confidence-adjusted rating_score always match what cards display. + await RefreshAssetRating(assetId) + res.send() } public async reportReview (req: Request, res: Response): Promise { const reasons = [ 'spam', - 'harrasement', + 'harassment', 'illegal', 'other' ] - const reason = striptags(req.body.reason) + const rawReason = striptags(req.body.reason) + // Normalize the legacy misspelling so older clients keep working + const reason = rawReason === 'harrasement' ? 'harassment' : rawReason const notes = striptags(req.body.notes ?? '') const reviewId = striptags(req.params.id ?? '') const authToken = striptags(req.cookies['auth-token'] ?? '') if (reviewId.length === 0) { - throw new Error('Missing comment ID') + throw new BadRequestError('Missing comment ID') } if (notes.length > 200) { - throw new Error('Notes too long, please keep it under 500 characters') + throw new BadRequestError('Notes too long, please keep it under 200 characters') } if (reason === undefined || !reasons.includes(reason)) { - throw new Error('Invalid or missing reason') + throw new BadRequestError('Invalid or missing reason') } let userId = 'not-logged-in' diff --git a/src/app/code/asset/views/styles/styles.scss b/src/app/code/asset/views/styles/styles.scss index fd44e4a8..773ccacd 100644 --- a/src/app/code/asset/views/styles/styles.scss +++ b/src/app/code/asset/views/styles/styles.scss @@ -25,6 +25,64 @@ margin-top: 0; } + .reviews-count { + @include inter-like-font(); + font-size: 12px; + font-weight: 500; + color: $text-gray-darker; + margin-bottom: 8px; + } + + .review-date { + @include inter-like-font(); + font-size: 12px; + color: $text-gray-darker; + margin-left: 6px; + } + + .related-assets { + grid-column: 1 / -1; + margin-top: 40px; + + .section-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 15px; + + h2 { + margin: 0; + } + + .view-all { + @include inter-like-font(); + font-size: 13px; + font-weight: 600; + color: #2081db; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + .related-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + column-gap: 20px; + row-gap: 20px; + + @include tablet-styles { + grid-template-columns: repeat(2, 1fr); + } + + @include mobile-styles { + grid-template-columns: 1fr; + } + } + } + .desktop-quick-info { @include mobile-styles { display: none; @@ -38,11 +96,6 @@ display: block; } - .rating, - button { - display: none; - } - .card { .banner { padding: 0px; @@ -139,6 +192,11 @@ display: block; } } + + .plain-description { + white-space: pre-wrap; + word-break: break-word; + } } .inline-dropdown { @@ -239,19 +297,79 @@ right: 0; left: auto; width: auto; + + button { + width: 100%; + text-align: left; + background: transparent; + border: none; + margin-top: 0; + border-radius: 0; + padding: 7px 13px; + font-family: "Inter"; + font-weight: 600; + cursor: pointer; + color: $text-dark-black; + + &:hover { + background: $text-light-lightest; + } + } } } } + .docs .dropdown .options button, + .inline-dropdown .dropdown .options button { + width: 100%; + text-align: left; + background: transparent; + border: none; + margin-top: 0; + border-radius: 0; + padding: 7px 13px; + font-family: "Inter"; + font-weight: 600; + cursor: pointer; + color: $text-dark-black; + + &:hover { + background: $text-light-lightest; + } + } + + .docs .dropdown .value { + text-transform: none; + } + .write-review { margin-top: 25px; + .rating-fieldset { + border: none; + margin: 0 0 10px; + padding: 0; + } + [type="radio"] { - display: none; + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + &:checked ~ .label, &:hover ~ .label { color: black; } + + &:focus-visible ~ .label { + outline: 2px solid #2081db; + outline-offset: 2px; + border-radius: 3px; + } } .rating-select { @@ -305,6 +423,50 @@ .quick-info { position: sticky; top: 95px; + + .actions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 15px; + + .action { + @include inter-like-font(); + display: flex; + align-items: center; + justify-content: center; + min-height: 44px; + width: 100%; + box-sizing: border-box; + border-radius: 6px; + font-weight: 600; + text-align: center; + text-decoration: none; + cursor: pointer; + padding: 12px 27px; + margin-top: 0; + border: 1px solid transparent; + + &.primary { + background: $button-very-dark; + color: #ffffff; + + &:hover { + background: $text-dark-darkest; + } + } + + &.secondary { + background: #ffffff; + color: $text-dark-darkest; + border-color: $text-light-gray; + + &:hover { + background: $color-lighter-gray; + } + } + } + } } .tabs { @@ -412,8 +574,20 @@ position: relative; overflow: hidden; } - } + .media-image-button { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 0; + border: none; + background: none; + cursor: zoom-in; + min-height: 0; + } + } img, iframe { position: absolute; @@ -424,4 +598,152 @@ object-fit: cover; } } + + /* Minimal local replacement for the previously CDN-loaded + github-markdown-css stylesheet. */ + .markdown-body { + @include poppins-like-font; + font-size: 15px; + line-height: $line-height-normal; + color: $text-primary; + word-wrap: break-word; + + h1, h2, h3, h4, h5, h6 { + @include inter-like-font(); + margin-top: $space-5; + margin-bottom: $space-2; + line-height: $line-height-tight; + } + + h1 { font-size: 1.7em; } + h2 { font-size: 1.4em; } + h3 { font-size: 1.2em; } + h4 { font-size: 1.05em; } + + h1:first-child, + h2:first-child { + margin-top: 0; + } + + p { + margin: $space-2 0; + } + + a { + color: $link-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + ul, ol { + padding-left: $space-6; + margin: $space-2 0; + } + + li { + margin: $space-1 0; + } + + blockquote { + margin: $space-4 0; + padding: $space-1 $space-4; + border-left: 4px solid $border-color-strong; + color: $text-muted; + } + + code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9em; + background: $color-lighter-gray; + padding: 2px 6px; + border-radius: $border-radius-sm; + } + + pre { + background: $surface-inverse; + color: $text-on-inverse; + padding: $space-4; + border-radius: $border-radius-md; + overflow-x: auto; + margin: $space-4 0; + + code { + background: transparent; + color: inherit; + padding: 0; + } + } + + table { + border-collapse: collapse; + margin: $space-4 0; + width: 100%; + + th, td { + border: 1px solid $border-color; + padding: $space-2 $space-3; + text-align: left; + } + + th { + background: $color-lighter-gray; + } + } + + img { + max-width: 100%; + height: auto; + border-radius: $border-radius-md; + } + + hr { + border: none; + border-top: 1px solid $border-color; + margin: $space-6 0; + } + + strong { + font-weight: 700; + } + } +} + +.page-asset { + /* Video play badge on gallery thumbnails. */ + .thumbnail-btn .thumb-play { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 26px; + height: 26px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 11px; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + padding-left: 3px; + pointer-events: none; + } + + /* Reviews anchor from the quick-info sidebar. */ + .reviews-anchor { + @include inter-like-font(); + display: inline-block; + margin: 8px 0 12px; + font-size: 13px; + font-weight: 600; + color: $link-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } } diff --git a/src/app/code/asset/views/templates/quick-info.eta b/src/app/code/asset/views/templates/quick-info.eta index ec21c134..dbc0fbb6 100644 --- a/src/app/code/asset/views/templates/quick-info.eta +++ b/src/app/code/asset/views/templates/quick-info.eta @@ -5,6 +5,11 @@ var generateProxyUrl = it?._locals?.functions?.generateProxyUrl

Quick Information

<%~ includeFile('templates/components/partials/stars/stars.eta', it) %> + <% if (it?.reviewCount != null && it.reviewCount > 0) { %> + + See <%= it.reviewCount %> review<%= it.reviewCount === 1 ? '' : 's' %> + + <% } %>
@@ -97,30 +95,37 @@