Skip to content

Commit ca6f086

Browse files
WEBDEV-7419 Support review display mode (#28)
* Build out review rendering logic * Add truncation support * Support loading in review via displayMode * Support prefilling extra-long review * Tidy up following CR
1 parent 1918956 commit ca6f086

6 files changed

Lines changed: 588 additions & 17 deletions

File tree

demo/app-root.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,43 @@ import { css, html, LitElement, nothing } from 'lit';
22
import { customElement, state } from 'lit/decorators.js';
33

44
import { Review } from '@internetarchive/metadata-service';
5-
import '../src/review-form';
65
import {
76
RecaptchaManager,
87
RecaptchaManagerInterface,
98
} from '@internetarchive/recaptcha-manager';
109

10+
import type { ReviewForRender } from '../src/review';
11+
import '../src/review-form';
12+
import '../src/review';
13+
1114
@customElement('app-root')
1215
export class AppRoot extends LitElement {
13-
private mockOldReview = new Review({
14-
stars: 3,
16+
private mockOldReview: ReviewForRender = {
17+
rawValue: new Review({ stars: 5 }),
18+
stars: 5,
1519
reviewtitle: 'What a cool book!',
1620
reviewbody: 'I loved it.',
17-
reviewer: 'foo-bar',
18-
reviewdate: '2025-03-03 18:13:36',
19-
createdate: '2025-02-25 14:28:19',
20-
});
21+
reviewer: 'Foo Bar',
22+
reviewdate: new Date('03/20/2025'),
23+
createdate: new Date('02/07/2025'),
24+
screenname: 'Foo Bar',
25+
itemname: 'foo-bar',
26+
domId: '12345',
27+
};
28+
29+
private longReview: ReviewForRender = {
30+
rawValue: new Review({ stars: 5 }),
31+
stars: 5,
32+
reviewtitle:
33+
'What a cool book! What a cool book! What a cool book! What a cool book! What a cool book! What a cool book! What a cool book! What a cool book! What a cool book! What a cool book! ',
34+
reviewbody: new Array(100).fill('I loved it.').join(' '),
35+
reviewer: 'Foo Bar',
36+
reviewdate: new Date('03/20/2025'),
37+
createdate: new Date('02/07/2025'),
38+
screenname: 'Foo Bar',
39+
itemname: 'foo-bar',
40+
domId: '12345',
41+
};
2142

2243
private goodRecaptchaManager: RecaptchaManagerInterface =
2344
new RecaptchaManager({
@@ -47,6 +68,12 @@ export class AppRoot extends LitElement {
4768
@state()
4869
private useCharCounts: boolean = true;
4970

71+
@state()
72+
private useReviewDisplay: boolean = false;
73+
74+
@state()
75+
private useLongReview: boolean = false;
76+
5077
render() {
5178
return html`${!this.recaptchaManager
5279
? html`<button
@@ -69,14 +96,23 @@ export class AppRoot extends LitElement {
6996
<button @click=${() => (this.useCharCounts = !this.useCharCounts)}>
7097
${this.useCharCounts ? 'Remove' : 'Use'} char count limits
7198
</button>
99+
<button @click=${() => (this.useReviewDisplay = !this.useReviewDisplay)}>
100+
Switch to ${this.useReviewDisplay ? 'form view' : 'review view'}
101+
</button>
102+
<button @click=${() => (this.useLongReview = !this.useLongReview)}>
103+
Prefill ${this.useLongReview ? 'normal review' : 'too-long review'}
104+
</button>
72105
<div class="container">
73106
<ia-review-form
74107
.identifier=${'goody'}
75-
.oldReview=${this.mockOldReview}
108+
.oldReview=${this.useLongReview
109+
? this.longReview
110+
: this.mockOldReview}
76111
.recaptchaManager=${this.recaptchaManager}
77112
.prefilledErrors=${this.showErrors ? this.errors : []}
78113
.maxSubjectLength=${this.useCharCounts ? 100 : undefined}
79114
.maxBodyLength=${this.useCharCounts ? 1000 : undefined}
115+
.displayMode=${this.useReviewDisplay ? 'review' : 'form'}
80116
?bypassRecaptcha=${this.bypassRecaptcha}
81117
></ia-review-form>
82118
</div>`;

src/assets/star-basic.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { svg } from 'lit';
2+
3+
export default svg`
4+
<svg class="star-basic" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
5+
<path
6+
d="m81.0388846 100-30.9636029-22.5595033-30.7410319 22.5595033 10.6670595-37.3922042-30.0013093-25.2155916h37.5556428l12.5196389-37.3922042 12.3690754 37.3922042h37.5556429l-29.7034563 25.2155916z"
7+
fill="currentColor"
8+
/>
9+
</svg>`;

src/review-form.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@ import {
1010
import { property, customElement, state, query } from 'lit/decorators.js';
1111
import { msg } from '@lit/localize';
1212

13-
import starSelected from './assets/star-selected';
14-
import starUnselected from './assets/star-unselected';
15-
1613
import { iaButtonStyles } from '@internetarchive/ia-styles';
17-
import type { Review } from '@internetarchive/metadata-service';
1814
import type {
1915
RecaptchaManagerInterface,
2016
RecaptchaWidgetInterface,
2117
} from '@internetarchive/recaptcha-manager';
2218

19+
import starSelected from './assets/star-selected';
20+
import starUnselected from './assets/star-unselected';
21+
22+
import type { ReviewForRender } from './review';
23+
import './review';
24+
2325
/**
2426
* Renders a form to edit a given IA review.
2527
*/
@@ -37,8 +39,11 @@ export class ReviewForm extends LitElement {
3739
/* The path for the endpoint we're submitting to */
3840
@property({ type: String }) endpointPath: string = '/write-review.php';
3941

42+
/* Whether to display the form or the editable review */
43+
@property({ type: String }) displayMode: 'review' | 'form' = 'form';
44+
4045
/* The previous review to pre-fill, if any */
41-
@property({ type: Object }) oldReview?: Review;
46+
@property({ type: Object }) oldReview?: ReviewForRender;
4247

4348
/* Errors to add to the form on first render, if any */
4449
@property({ type: Array }) prefilledErrors: string[] = [];
@@ -87,6 +92,8 @@ export class ReviewForm extends LitElement {
8792
private reviewForm!: HTMLFormElement;
8893

8994
render() {
95+
if (this.displayMode === 'review')
96+
return html`<ia-review .review=${this.oldReview}></ia-review>`;
9097
return html`<form
9198
id="review-form"
9299
action="${this.baseHost}${this.endpointPath}"

src/review.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import {
2+
html,
3+
css,
4+
LitElement,
5+
nothing,
6+
HTMLTemplateResult,
7+
CSSResultGroup,
8+
} from 'lit';
9+
import { property, customElement, state } from 'lit/decorators.js';
10+
import { msg } from '@lit/localize';
11+
12+
import { Review } from '@internetarchive/metadata-service';
13+
14+
import starBasic from './assets/star-basic';
15+
16+
/* Further properties for reviews added before render */
17+
export interface ReviewForRender extends Review {
18+
screenname: string;
19+
domId: string;
20+
itemname?: string;
21+
}
22+
23+
/**
24+
* Renders a single IA review
25+
*/
26+
@customElement('ia-review')
27+
export class IaReview extends LitElement {
28+
/* The review to be rendered */
29+
@property({ type: Object }) review?: ReviewForRender;
30+
31+
/* Maximum length for subject */
32+
@property({ type: Number }) maxSubjectLength = 100;
33+
34+
/* Maximum length for body */
35+
@property({ type: Number }) maxBodyLength = 1000;
36+
37+
/* Base for URLs */
38+
@property({ type: String }) baseHost = 'https://archive.org';
39+
40+
/* Whether to show truncated review title / body */
41+
@state()
42+
private showTruncatedContent: boolean = false;
43+
44+
render() {
45+
return !this.review
46+
? html`<div class="error">
47+
${msg('This review cannot be displayed at this time.')}
48+
</div>`
49+
: html`<article class="review" id=${this.review.domId}>
50+
<div class="top-line">
51+
<b>${msg('Reviewer:')} </b>${this.reviewerTemplate} -
52+
${this.starsTemplate}${this.createDateTemplate}
53+
</div>
54+
<div class="subject">
55+
<b>${msg('Subject: ')}</b>${this.subjectTemplate}
56+
</div>
57+
<div class="body">${this.bodyTemplate}</div>
58+
${this.truncationButtonsTemplate}
59+
</article>`;
60+
}
61+
62+
private get subjectTemplate(): string {
63+
if (!this.review?.reviewtitle) return '';
64+
65+
return this.review.reviewtitle.length <= this.maxSubjectLength ||
66+
this.showTruncatedContent
67+
? this.review.reviewtitle
68+
: this.review.reviewtitle.slice(0, this.maxSubjectLength).concat('...');
69+
}
70+
71+
private get bodyTemplate(): string {
72+
if (!this.review?.reviewbody) return '';
73+
74+
return this.review.reviewbody.length <= this.maxBodyLength ||
75+
this.showTruncatedContent
76+
? this.review.reviewbody
77+
: this.review.reviewbody.slice(0, this.maxBodyLength).concat('...');
78+
}
79+
80+
private get truncationButtonsTemplate(): HTMLTemplateResult | typeof nothing {
81+
if (!this.review?.reviewtitle || !this.review?.reviewbody) return nothing;
82+
83+
const noTruncationNeeded =
84+
this.review.reviewtitle.length <= this.maxSubjectLength &&
85+
this.review.reviewbody.length <= this.maxBodyLength;
86+
87+
if (noTruncationNeeded) return nothing;
88+
89+
return this.showTruncatedContent
90+
? this.lessButtonTemplate
91+
: this.moreButtonTemplate;
92+
}
93+
94+
private get moreButtonTemplate(): HTMLTemplateResult {
95+
return html`<button
96+
class="simple-link more-btn"
97+
@click=${() => (this.showTruncatedContent = true)}
98+
>
99+
${msg('More...')}
100+
</button>`;
101+
}
102+
103+
private get lessButtonTemplate(): HTMLTemplateResult {
104+
return html`<button
105+
class="simple-link less-btn"
106+
@click=${() => (this.showTruncatedContent = false)}
107+
>
108+
${msg('...Less')}
109+
</button>`;
110+
}
111+
112+
private get reviewerTemplate(): HTMLTemplateResult | typeof nothing {
113+
return !this.review
114+
? nothing
115+
: this.review.itemname
116+
? html`<a
117+
href="${this.baseHost}/details/${this.review.itemname}"
118+
class="reviewer-link simple-link"
119+
data-event-click-tracking="ItemReviews|ReviewerLink"
120+
>${this.review.screenname}</a
121+
>`
122+
: html`${this.review.screenname}`;
123+
}
124+
125+
private get starsTemplate(): HTMLTemplateResult | typeof nothing {
126+
if (!this.review || !this.review.stars) return nothing;
127+
return html`<div
128+
class="review-stars"
129+
title="${msg(`${this.review.stars} out of 5 stars`)}"
130+
>
131+
${new Array(this.review.stars)
132+
.fill(null)
133+
.map(() => html`<div class="review-star">${starBasic}</div>`)}
134+
</div>
135+
- `;
136+
}
137+
138+
private get createDateTemplate(): string | typeof nothing {
139+
if (!this.review) return nothing;
140+
const prettyDate = this.review.createdate?.toLocaleString('en-us', {
141+
month: 'long',
142+
day: 'numeric',
143+
year: 'numeric',
144+
});
145+
const editedMsg =
146+
this.review.reviewdate?.getTime() !== this.review.createdate?.getTime()
147+
? '(edited)'
148+
: '';
149+
150+
return msg(`${prettyDate} ${editedMsg}`);
151+
}
152+
153+
static get styles(): CSSResultGroup {
154+
return css`
155+
:host {
156+
font-family: var(
157+
--ia-font-stack,
158+
'Helvetica Neue',
159+
Helvetica,
160+
Arial,
161+
sans-serif
162+
);
163+
164+
font-size: inherit;
165+
}
166+
167+
.error {
168+
color: var(--ia-theme-error-color, #cc0000);
169+
}
170+
171+
.top-line {
172+
display: flex;
173+
flex-direction: row;
174+
gap: 3px;
175+
margin-bottom: 0.5rem;
176+
}
177+
178+
.review-star {
179+
width: 1rem;
180+
}
181+
182+
.review-stars {
183+
display: flex;
184+
flex-direction: row;
185+
}
186+
187+
.simple-link {
188+
color: var(--ia-link-color, #4b64ff);
189+
text-decoration: none;
190+
background: transparent;
191+
border: none;
192+
padding: 0px;
193+
}
194+
195+
.simple-link:hover {
196+
cursor: pointer;
197+
text-decoration: underline;
198+
}
199+
200+
.subject {
201+
margin-bottom: 0.5rem;
202+
}
203+
`;
204+
}
205+
}

0 commit comments

Comments
 (0)