Browse Source

fix(sponsor): keep long sponsor copy inside every slot

The Sponsors page card sized its body to max-content because the title
never wrapped, so a long title spilled out of the card onto the next one.
The compact card (sidebar, login) put logo, copy and close button in one
row, leaving the copy ~110px of the 220px sidebar and clamping it to two
lines; the dashboard banner cut its title to a single line.

Titles and descriptions now wrap, breaking unbroken strings too. The
compact card stacks logo and tag above copy that spans the full card
width, keeping a bounded height in the sidebar with a 2-line title and
4-line description clamp and the full copy in the link tooltip.
MHSanaei 12 hours ago
parent
commit
319e693276

+ 29 - 20
frontend/src/components/sponsor/SponsorCard.css

@@ -61,7 +61,7 @@
 
 
 .sponsor-head {
 .sponsor-head {
   display: flex;
   display: flex;
-  align-items: center;
+  align-items: baseline;
   gap: 8px;
   gap: 8px;
   min-width: 0;
   min-width: 0;
 }
 }
@@ -77,15 +77,14 @@
 }
 }
 
 
 .sponsor-title {
 .sponsor-title {
-  overflow: hidden;
   font-weight: 600;
   font-weight: 600;
-  white-space: nowrap;
-  text-overflow: ellipsis;
+  overflow-wrap: anywhere;
 }
 }
 
 
 .sponsor-text {
 .sponsor-text {
   font-size: 13px;
   font-size: 13px;
   color: var(--ant-color-text-secondary);
   color: var(--ant-color-text-secondary);
+  overflow-wrap: anywhere;
 }
 }
 
 
 .sponsor-visit {
 .sponsor-visit {
@@ -129,42 +128,52 @@
 }
 }
 
 
 .sponsor-card-compact .sponsor-main {
 .sponsor-card-compact .sponsor-main {
-  gap: 10px;
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr);
+  align-items: center;
+  gap: 4px 8px;
   padding: 8px 10px;
   padding: 8px 10px;
 }
 }
 
 
+.sponsor-card-compact .sponsor-body,
+.sponsor-card-compact .sponsor-head {
+  display: contents;
+}
+
 .sponsor-card-compact .sponsor-logo {
 .sponsor-card-compact .sponsor-logo {
-  width: 32px;
-  height: 32px;
+  width: 28px;
+  height: 28px;
 }
 }
 
 
-.sponsor-card-compact .sponsor-head {
-  flex-direction: column;
-  align-items: flex-start;
-  gap: 2px;
+.sponsor-card-compact .sponsor-tag {
+  justify-self: start;
 }
 }
 
 
-.sponsor-card-compact .sponsor-title {
+.sponsor-card-compact .sponsor-title,
+.sponsor-card-compact .sponsor-text {
   display: -webkit-box;
   display: -webkit-box;
-  max-width: 100%;
+  grid-column: 1 / -1;
+  overflow: hidden;
+  -webkit-box-orient: vertical;
+}
+
+.sponsor-card-compact .sponsor-title {
   font-size: 13px;
   font-size: 13px;
-  white-space: normal;
   -webkit-line-clamp: 2;
   -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
 }
 }
 
 
 .sponsor-card-compact .sponsor-text {
 .sponsor-card-compact .sponsor-text {
-  display: -webkit-box;
-  overflow: hidden;
   font-size: 12px;
   font-size: 12px;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 4;
 }
 }
 
 
 .sponsor-card-compact .sponsor-close {
 .sponsor-card-compact .sponsor-close {
+  position: absolute;
+  top: 4px;
+  inset-inline-end: 4px;
   width: 22px;
   width: 22px;
   height: 22px;
   height: 22px;
-  margin: 4px 4px 0 0;
+  margin: 0;
   font-size: 11px;
   font-size: 11px;
 }
 }
 
 

+ 84 - 0
frontend/src/components/sponsor/SponsorCard.stories.tsx

@@ -14,6 +14,34 @@ const sponsor: Sponsor = {
   link: 'https://acme.example/?utm_source=3x-ui',
   link: 'https://acme.example/?utm_source=3x-ui',
 };
 };
 
 
+// Copy as long as the real sponsors.json entries; each story renders it at its slot's width.
+const longSponsor: Sponsor = {
+  ...sponsor,
+  title: { en: 'Advertise here — reach 3X-UI server admins' },
+  text: {
+    en: 'Put your VPS, hosting or domain brand in front of the admins who use 3X-UI every day. Message us for slots and pricing.',
+  },
+};
+
+function part(root: HTMLElement, selector: string): HTMLElement {
+  const el = root.querySelector<HTMLElement>(selector);
+  if (!el) throw new Error(`missing ${selector}`);
+  return el;
+}
+
+async function expectInsideCard(root: HTMLElement, selector: string) {
+  const card = part(root, '.sponsor-card').getBoundingClientRect();
+  const box = part(root, selector).getBoundingClientRect();
+  await expect(box.left).toBeGreaterThanOrEqual(card.left - 0.5);
+  await expect(box.right).toBeLessThanOrEqual(card.right + 0.5);
+}
+
+async function expectNotClipped(root: HTMLElement, selector: string) {
+  const el = part(root, selector);
+  await expect(el.scrollWidth).toBeLessThanOrEqual(el.clientWidth + 1);
+  await expect(el.scrollHeight).toBeLessThanOrEqual(el.clientHeight + 1);
+}
+
 const meta = {
 const meta = {
   title: 'Sponsor/SponsorCard',
   title: 'Sponsor/SponsorCard',
   component: SponsorCard,
   component: SponsorCard,
@@ -68,6 +96,62 @@ export const Card: Story = {
   ),
   ),
 };
 };
 
 
+export const BannerLongCopy: Story = {
+  args: { variant: 'banner', sponsor: longSponsor, onClose: () => {} },
+  render: (args) => (
+    <div style={{ width: 360 }}>
+      <SponsorCard {...args} />
+    </div>
+  ),
+  play: async ({ canvasElement }) => {
+    await expectNotClipped(canvasElement, '.sponsor-title');
+  },
+};
+
+export const SidebarLongCopy: Story = {
+  args: { variant: 'compact', sponsor: longSponsor, onClose: () => {} },
+  render: (args) => (
+    <div style={{ width: 204 }}>
+      <SponsorCard {...args} />
+    </div>
+  ),
+  play: async ({ canvasElement }) => {
+    await expectNotClipped(canvasElement, '.sponsor-title');
+    await expectInsideCard(canvasElement, '.sponsor-text');
+    await expect(part(canvasElement, '.sponsor-main')).toHaveAttribute(
+      'title',
+      `${longSponsor.title.en}\n${longSponsor.text.en}`,
+    );
+  },
+};
+
+export const LoginLongCopy: Story = {
+  args: { variant: 'compact', sponsor: longSponsor, onClose: () => {} },
+  render: (args) => (
+    <div style={{ width: 334 }}>
+      <SponsorCard {...args} />
+    </div>
+  ),
+  play: async ({ canvasElement }) => {
+    await expectNotClipped(canvasElement, '.sponsor-title');
+    await expectNotClipped(canvasElement, '.sponsor-text');
+  },
+};
+
+export const PageLongCopy: Story = {
+  args: { variant: 'card', sponsor: longSponsor },
+  render: (args) => (
+    <div style={{ width: 320 }}>
+      <SponsorCard {...args} />
+    </div>
+  ),
+  play: async ({ canvasElement }) => {
+    await expectInsideCard(canvasElement, '.sponsor-title');
+    await expectInsideCard(canvasElement, '.sponsor-text');
+    await expectNotClipped(canvasElement, '.sponsor-title');
+  },
+};
+
 export const IconOnly: Story = {
 export const IconOnly: Story = {
   args: { iconOnly: true },
   args: { iconOnly: true },
 };
 };

+ 1 - 0
frontend/src/components/sponsor/SponsorCard.tsx

@@ -70,6 +70,7 @@ export default function SponsorCard({
         href={sponsor.link}
         href={sponsor.link}
         target="_blank"
         target="_blank"
         rel="noopener noreferrer sponsored"
         rel="noopener noreferrer sponsored"
+        title={variant === 'compact' ? [title, text].filter(Boolean).join('\n') : undefined}
       >
       >
         <SponsorLogo sponsor={sponsor} />
         <SponsorLogo sponsor={sponsor} />
         <span className="sponsor-body" dir="auto">
         <span className="sponsor-body" dir="auto">