Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 102 additions & 16 deletions app/components/Article/Contents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,75 @@ const footnoteHTML = (el: HTMLDivElement, e: HTMLAnchorElement): string | null =
return elem.firstElementChild?.innerHTML || null
}

const addPopup = (e: HTMLElement, id: string, contents: string, mobile: boolean): HTMLElement => {
// TODO: Potential memory leak - event listeners are not cleaned up.
// Consider refactoring to React components with proper cleanup in useEffect.
const addPopup = (
e: HTMLElement,
id: string,
contents: string,
mobile: boolean,
layout?: string
): void => {
const preexisting = document.getElementById(id)
if (preexisting) return preexisting
if (preexisting) return

const popup = document.createElement('div')
popup.className = 'link-popup bordered small background'
if (layout) {
popup.classList.add(`${layout}-image-layout`)
}
popup.innerHTML = contents
popup.id = id

e.insertAdjacentElement('afterend', popup)

// Timeout management for show/hide delays
let showTimeout: number | null = null
let hideTimeout: number | null = null

const clearTimeouts = () => {
if (showTimeout) {
clearTimeout(showTimeout)
showTimeout = null
}
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
}

const toggle = () => popup.classList.toggle('shown')
const show = () => popup.classList.add('shown')
const hide = () => popup.classList.remove('shown')

const show = () => {
clearTimeouts()
showTimeout = window.setTimeout(() => {
// Position popup above if it would not fit in viewport
const elementRect = e.getBoundingClientRect()
const viewportHeight = window.innerHeight

// Check if popup would fit below the element
const estimatedPopupHeight = 250
const wouldFitBelow = elementRect.bottom + estimatedPopupHeight <= viewportHeight - 50

// If it doesn't fit below, position it above
if (!wouldFitBelow) {
popup.classList.add('position-above')
} else {
popup.classList.remove('position-above')
}

popup.classList.add('shown')
showTimeout = null
}, 500)
}

const hide = () => {
clearTimeouts()
hideTimeout = window.setTimeout(() => {
popup.classList.remove('shown')
hideTimeout = null
}, 100)
}

if (!mobile) {
e.addEventListener('mouseover', show)
Expand All @@ -50,8 +105,6 @@ const addPopup = (e: HTMLElement, id: string, contents: string, mobile: boolean)
e.addEventListener('click', togglePopup(toggle, e))
popup.children[0].addEventListener('click', (e) => e.stopPropagation())
}

return popup
}

/*
Expand Down Expand Up @@ -193,23 +246,56 @@ const insertGlossary = (
linkedQuestionIsViewable &&
`<a href="${questionUrl(entry)}" target="_blank" rel="noopener noreferrer" class="button secondary">View full definition</a>`
const isGoogleDrive = entry.image && entry.image.includes('drive.google.com/file/d/')
const image = entry.image
const imageHtml = entry.image
? isGoogleDrive
? `<iframe src="${entry.image.replace(/\/view$/, '/preview')}" style="width:100%; border:none;" allowFullScreen></iframe>`
: `<img src="${entry.image}"/>`
: ''

// Determine layout based on pre-computed dimensions
let layout: 'right' | 'top' = 'right'
if (entry.image && !isGoogleDrive && entry.imageDimensions) {
const aspectRatio = entry.imageDimensions.width / entry.imageDimensions.height
layout = aspectRatio > 2.0 ? 'top' : 'right'
}

// Create popup with pre-calculated layout
const popupContent = imageHtml
? layout === 'top'
? `<div class="glossary-popup top-image-layout black small">
<div class="image-container">
${imageHtml}
</div>
<div class="text-content">
<div class="small-bold text-no-wrap">${entry.term}</div>
<div class="definition small">${entry.contents}</div>
${link || ''}
</div>
</div>`
: `<div class="glossary-popup right-image-layout black small">
<div class="text-content">
<div class="small-bold text-no-wrap">${entry.term}</div>
<div class="definition small">${entry.contents}</div>
${link || ''}
</div>
<div class="image-container">
${imageHtml}
</div>
</div>`
: `<div class="glossary-popup black small">
<div class="text-content full-width">
<div class="small-bold text-no-wrap">${entry.term}</div>
<div class="definition small">${entry.contents}</div>
${link || ''}
</div>
</div>`

addPopup(
e as HTMLSpanElement,
`glossary-${entry.term}`,
`<div class="glossary-popup flex-container black small">
<div class="contents ${image ? '' : 'full-width'}">
<div class="small-bold text-no-wrap">${entry.term}</div>
<div class="definition small">${entry.contents}</div>
${link || ''}
</div>
${image || ''}
</div>`,
mobile
popupContent,
mobile,
imageHtml ? layout : undefined
)
})

Expand Down
158 changes: 105 additions & 53 deletions app/components/Article/article.css
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,86 @@ article .glossary-entry {
article .link-popup {
visibility: hidden;
z-index: 4;
width: 512px;
width: auto;
min-width: 350px;
}

/* Right image layout - image on the right side */
article .glossary-popup.right-image-layout {
display: flex;
flex-direction: row;
min-width: 530px; /* Minimum width to contain text + image (350 + 180) */
width: fit-content;
}

/* Top image layout - image on top */
article .glossary-popup.top-image-layout {
display: flex;
flex-direction: column;
}

/* Right image layout - image container */
article .glossary-popup.right-image-layout .image-container {
flex: 0 0 180px;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-8);
overflow: hidden;
}

/* Top image layout - image container */
article .glossary-popup.top-image-layout .image-container {
flex: 0 0 100px; /* Fixed height for top images */
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-8) var(--spacing-16);
}

/* Right image layout - text content */
article .glossary-popup.right-image-layout .text-content {
flex: 0 0 350px;
padding: var(--spacing-24);
}

/* Top image layout - text content */
article .glossary-popup.top-image-layout .text-content {
flex: 1;
padding: var(--spacing-24);
}

/* Image styling for all glossary popup images */
article .glossary-popup .image-container img {
border-radius: var(--border-radius);
}

/* Right image layout - resize images to fit in fixed container */
article .glossary-popup.right-image-layout .image-container img {
width: auto;
height: auto;
max-width: 164px;
max-height: 250px;
}

/* Top image layout - resize images to fit in full width container */
article .glossary-popup.top-image-layout .image-container img {
max-height: 100px;
width: auto;
max-width: 100%;
object-fit: scale-down;
}

/* Text content for popups without images */
article .glossary-popup .text-content.full-width {
padding: var(--spacing-24);
}

/* Remove margins from buttons in glossary popups */
article .glossary-popup .button {
margin: 0;
margin-top: var(--spacing-8);
}

article .link-popup .footnote {
Expand All @@ -179,6 +258,7 @@ article .footnote-ref {
article .glossary-popup {
border-radius: var(--border-radius);
overflow: hidden;

height: 304px;
/* Reset any inherited formatting from the glossary term */
font-style: normal !important;
Expand All @@ -193,15 +273,15 @@ article .link-popup .glossary-popup > .contents {
padding: var(--spacing-24) var(--spacing-40) var(--spacing-24);
}
article .definition {
height: 140px;
max-height: 300px;
display: -webkit-box;
/* These are webkit specific things, so might not work in all browsers (firefox handles them fine) */
-webkit-box-orient: vertical;
-webkit-line-clamp: 5; /* Number of lines you want to display */
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: var(--spacing-32);
}

article .link-popup .glossary-popup > img {
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
Expand Down Expand Up @@ -289,14 +369,33 @@ article blockquote + p {
@media (min-width: 1136px) {
article .link-popup {
position: absolute;
max-width: 480px;
max-width: 400px;
display: inline-block;
z-index: 2;
left: calc(50% - 200px);
transform: translateY(var(--spacing-40));
transition:
visibility 0s 300ms,
opacity cubic-bezier(1, 0, 1, 1) 300ms;
visibility 0s 100ms,
opacity cubic-bezier(1, 0, 1, 1) 100ms;
}

/* Right-image layout popups - wider to accommodate image */
article .link-popup.right-image-layout {
max-width: 530px; /* 350px text + 180px image */
left: calc(50% - 265px);
transform: translateY(var(--spacing-40));
}

/* Top-image layout popups - standard width */
article .link-popup.top-image-layout {
max-width: 400px; /* Same as text-only popups */
left: calc(50% - 200px);
transform: translateY(var(--spacing-40));
}

/* Position above adjustments */
article .link-popup.position-above {
transform: translateY(-100%) translateY(-40px);
}
}

Expand Down Expand Up @@ -373,50 +472,3 @@ article .contents tbody tr:nth-child(odd) {
article .contents tbody tr:nth-child(even) {
background-color: var(--background);
}

/* Share dropdown menu */
.share-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: var(--spacing-8);
background-color: var(--card-background);
border: 1px solid var(--border);
border-radius: var(--spacing-8);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 160px;
overflow: hidden;
}

html.dark .share-dropdown {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}

.share-menu-item {
display: flex;
align-items: center;
gap: var(--spacing-12);
padding: var(--spacing-12) var(--spacing-16);
color: var(--text);
text-decoration: none;
font-size: 14px;
border: none;
background: none;
width: 100%;
text-align: left;
cursor: pointer;
font-family: inherit;
}

.share-menu-item:hover {
background-color: var(--hover-background);
}

.share-menu-item-icon {
display: flex;
align-items: center;
width: var(--spacing-16);
height: var(--spacing-16);
color: var(--text-secondary);
}
8 changes: 6 additions & 2 deletions app/server-utils/stampy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type GlossaryEntry = {
pageid: PageId
contents: string
image: string
imageDimensions?: {width: number; height: number}
}
export type Glossary = {
[key: string]: GlossaryEntry
Expand Down Expand Up @@ -163,7 +164,7 @@ type GlossaryRow = CodaRowCommon & {
phrase: string
aliases: string
'UI ID': string
image: Entity
image: string
}
}
type BannersRow = CodaRowCommon & {
Expand Down Expand Up @@ -375,10 +376,13 @@ export const loadGlossary = withCache('loadGlossary', async () => {
.map((v) => v.trim())
.filter(Boolean),
]
const img = values.image && JSON.parse(values.image)
const item = {
pageid,
term: extractText(values.phrase),
image: values.image?.url,
image: img?.url,
...(img?.width &&
img?.height && {imageDimensions: {width: img.width, height: img.height}}),
contents: renderText(pageid, extractText(values.definition)).html,
}
return phrases
Expand Down