Skip to content

Commit af84693

Browse files
committed
proxy image config and fix avatars, datetime properly
1 parent 78fcaa9 commit af84693

File tree

12 files changed

+150
-75
lines changed

12 files changed

+150
-75
lines changed

apps/self-hosted/config.template.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"dateFormat": "YYYY-MM-DD",
1010
"timeFormat": "HH:mm:ss",
1111
"dateTimeFormat": "YYYY-MM-DD HH:mm:ss",
12+
"imageProxy": "https://images.ecency.com",
1213
"styles": {
1314
"background": "bg-gradient-to-br from-[#d8e5fa] to-[#fcfdfe]"
1415
}

apps/self-hosted/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212
},
1313
"dependencies": {
1414
"@ecency/render-helper": "workspace:*",
15-
"@ecency/renderer": "workspace:*",
1615
"@ecency/sdk": "workspace:*",
1716
"@ecency/wallets": "workspace:*",
1817
"@tanstack/react-query": "^5.90.2",
1918
"@tanstack/react-router": "^1.132.27",
2019
"@tooni/iconscout-unicons-react": "^1.0.1",
2120
"clsx": "^2.1.1",
2221
"date-fns": "^4.1.0",
22+
"date-fns-tz": "^3.2.0",
2323
"framer-motion": "^11.18.2",
2424
"motion": "^12.23.22",
2525
"react": "^19.1.1",

apps/self-hosted/src/core/date-formatter.ts

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { format, formatDistanceToNow, formatRelative } from 'date-fns';
2+
import { toZonedTime } from 'date-fns-tz';
3+
import type { Locale } from 'date-fns';
24
import {
35
de,
46
enUS,
@@ -47,13 +49,36 @@ function convertFormatPattern(pattern: string): string {
4749
.replace(/D/g, 'd');
4850
}
4951

52+
/**
53+
* Parse a date string from Hive blockchain (UTC) into a Date object
54+
* Hive dates are in format "2024-01-16T10:00:00" without timezone indicator
55+
* They should be treated as UTC
56+
*/
57+
function parseHiveDate(date: Date | string | number): Date {
58+
if (date instanceof Date) return date;
59+
if (typeof date === 'number') return new Date(date);
60+
61+
// Hive dates don't have timezone indicator, treat as UTC
62+
const dateStr = String(date);
63+
if (!dateStr.endsWith('Z') && !dateStr.includes('+') && !dateStr.includes('-', 10)) {
64+
return new Date(dateStr + 'Z');
65+
}
66+
return new Date(dateStr);
67+
}
68+
5069
function getLocale(): Locale {
5170
const language = InstanceConfigManager.getConfigValue(
5271
({ configuration }) => configuration.general.language,
5372
);
5473
return localeMap[language] || enUS;
5574
}
5675

76+
function getTimezone(): string {
77+
return InstanceConfigManager.getConfigValue(
78+
({ configuration }) => configuration.general.timezone || 'UTC',
79+
);
80+
}
81+
5782
function getDateFormat(): string {
5883
const configFormat = InstanceConfigManager.getConfigValue(
5984
({ configuration }) => configuration.general.dateFormat,
@@ -76,83 +101,73 @@ function getDateTimeFormat(): string {
76101
}
77102

78103
/**
79-
* Format a date using the configured date format
104+
* Format a date using the configured date format and timezone
80105
*/
81106
export function formatDate(date: Date | string | number): string {
82-
const dateObj =
83-
typeof date === 'string' || typeof date === 'number'
84-
? new Date(date)
85-
: date;
86-
return format(dateObj, getDateFormat(), { locale: getLocale() });
107+
const utcDate = parseHiveDate(date);
108+
const zonedDate = toZonedTime(utcDate, getTimezone());
109+
return format(zonedDate, getDateFormat(), { locale: getLocale() });
87110
}
88111

89112
/**
90-
* Format a time using the configured time format
113+
* Format a time using the configured time format and timezone
91114
*/
92115
export function formatTime(date: Date | string | number): string {
93-
const dateObj =
94-
typeof date === 'string' || typeof date === 'number'
95-
? new Date(date)
96-
: date;
97-
return format(dateObj, getTimeFormat(), { locale: getLocale() });
116+
const utcDate = parseHiveDate(date);
117+
const zonedDate = toZonedTime(utcDate, getTimezone());
118+
return format(zonedDate, getTimeFormat(), { locale: getLocale() });
98119
}
99120

100121
/**
101-
* Format a date and time using the configured datetime format
122+
* Format a date and time using the configured datetime format and timezone
102123
*/
103124
export function formatDateTime(date: Date | string | number): string {
104-
const dateObj =
105-
typeof date === 'string' || typeof date === 'number'
106-
? new Date(date)
107-
: date;
108-
return format(dateObj, getDateTimeFormat(), { locale: getLocale() });
125+
const utcDate = parseHiveDate(date);
126+
const zonedDate = toZonedTime(utcDate, getTimezone());
127+
return format(zonedDate, getDateTimeFormat(), { locale: getLocale() });
109128
}
110129

111130
/**
112131
* Format a date as relative time (e.g., "2 hours ago")
132+
* This compares UTC times correctly regardless of browser timezone
113133
*/
114134
export function formatRelativeTime(date: Date | string | number): string {
115-
const dateObj =
116-
typeof date === 'string' || typeof date === 'number'
117-
? new Date(date)
118-
: date;
119-
return formatDistanceToNow(dateObj, { addSuffix: true, locale: getLocale() });
135+
const utcDate = parseHiveDate(date);
136+
// formatDistanceToNow compares UTC timestamps internally, so we just need
137+
// to ensure the input date is parsed as UTC (which parseHiveDate does)
138+
return formatDistanceToNow(utcDate, { addSuffix: true, locale: getLocale() });
120139
}
121140

122141
/**
123142
* Format a date relative to now (e.g., "yesterday at 3:00 PM")
124143
*/
125144
export function formatRelativeDate(date: Date | string | number): string {
126-
const dateObj =
127-
typeof date === 'string' || typeof date === 'number'
128-
? new Date(date)
129-
: date;
130-
return formatRelative(dateObj, new Date(), { locale: getLocale() });
145+
const utcDate = parseHiveDate(date);
146+
const timezone = getTimezone();
147+
const zonedDate = toZonedTime(utcDate, timezone);
148+
const zonedNow = toZonedTime(new Date(), timezone);
149+
return formatRelative(zonedDate, zonedNow, { locale: getLocale() });
131150
}
132151

133152
/**
134153
* Format a date for display (month and year only)
135154
*/
136155
export function formatMonthYear(date: Date | string | number): string {
137-
const dateObj =
138-
typeof date === 'string' || typeof date === 'number'
139-
? new Date(date)
140-
: date;
141-
return format(dateObj, 'MMMM yyyy', { locale: getLocale() });
156+
const utcDate = parseHiveDate(date);
157+
const zonedDate = toZonedTime(utcDate, getTimezone());
158+
return format(zonedDate, 'MMMM yyyy', { locale: getLocale() });
142159
}
143160

144161
/**
145-
* Custom format with locale support
162+
* Custom format with locale support and timezone
146163
*/
147164
export function formatCustom(
148165
date: Date | string | number,
149166
formatStr: string,
150167
): string {
151-
const dateObj =
152-
typeof date === 'string' || typeof date === 'number'
153-
? new Date(date)
154-
: date;
155-
return format(dateObj, convertFormatPattern(formatStr), {
168+
const utcDate = parseHiveDate(date);
169+
const zonedDate = toZonedTime(utcDate, getTimezone());
170+
return format(zonedDate, convertFormatPattern(formatStr), {
156171
locale: getLocale(),
157172
});
158173
}

apps/self-hosted/src/features/blog/components/blog-discussion-item.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
'use client';
22

3-
import { EcencyRenderer } from '@ecency/renderer';
3+
import { renderPostBody } from '@ecency/render-helper';
44
import type { Entry } from '@ecency/sdk';
55
import { UilComment, UilHeart } from '@tooni/iconscout-unicons-react';
6-
import { memo, useMemo, useState } from 'react';
6+
import { useMemo, useState } from 'react';
77
import { formatRelativeTime } from '@/core';
88
import { UserAvatar } from '@/features/shared/user-avatar';
99
import { BlogDiscussionList } from './blog-discussion-list';
1010

11-
const MemoEcencyRenderer = memo(EcencyRenderer);
12-
1311
interface Props {
1412
entry: Entry;
1513
discussionList: Entry[];
@@ -79,9 +77,12 @@ export function BlogDiscussionItem({
7977
{entry.body}
8078
</pre>
8179
) : (
82-
<div className="markdown-body text-sm! max-w-none">
83-
<MemoEcencyRenderer value={entry.body} />
84-
</div>
80+
<div
81+
className="markdown-body text-sm! max-w-none entry-body"
82+
dangerouslySetInnerHTML={{
83+
__html: renderPostBody(entry.body, false, true),
84+
}}
85+
/>
8586
)}
8687
</div>
8788

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
'use client';
22

3-
import { EcencyRenderer } from '@ecency/renderer';
3+
import { renderPostBody } from '@ecency/render-helper';
44
import type { Entry } from '@ecency/sdk';
5-
import { memo } from 'react';
6-
7-
const MemoEcencyRenderer = memo(EcencyRenderer);
5+
import { useMemo } from 'react';
86

97
interface Props {
108
entry: Entry;
@@ -14,6 +12,11 @@ interface Props {
1412
export function BlogPostBody({ entry, isRawContent }: Props) {
1513
const entryData = entry.original_entry || entry;
1614

15+
const renderedBody = useMemo(
16+
() => renderPostBody(entryData.body, false, true),
17+
[entryData.body],
18+
);
19+
1720
if (isRawContent) {
1821
return (
1922
<div className="mb-6 sm:mb-8">
@@ -26,9 +29,10 @@ export function BlogPostBody({ entry, isRawContent }: Props) {
2629

2730
return (
2831
<div className="mb-6 sm:mb-8">
29-
<div className="markdown-body text-sm! max-w-none">
30-
<MemoEcencyRenderer value={entryData.body} />
31-
</div>
32+
<div
33+
className="markdown-body text-sm! max-w-none entry-body"
34+
dangerouslySetInnerHTML={{ __html: renderedBody }}
35+
/>
3236
</div>
3337
);
3438
}

apps/self-hosted/src/features/blog/components/blog-post-item.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { catchPostImage, postBodySummary } from '@ecency/render-helper';
2-
import { EcencyRenderer } from '@ecency/renderer';
1+
import {
2+
catchPostImage,
3+
postBodySummary,
4+
renderPostBody,
5+
} from '@ecency/render-helper';
36
import type { Entry } from '@ecency/sdk';
47
import {
58
UilComment,
@@ -8,11 +11,9 @@ import {
811
} from '@tooni/iconscout-unicons-react';
912
import clsx from 'clsx';
1013
import { motion } from 'framer-motion';
11-
import { memo, useMemo } from 'react';
14+
import { useMemo } from 'react';
1215
import { formatDate, InstanceConfigManager } from '@/core';
1316

14-
const MemoEcencyRenderer = memo(EcencyRenderer);
15-
1617
interface Props {
1718
entry: Entry;
1819
index?: number;
@@ -121,9 +122,12 @@ export function BlogPostItem({ entry, index = 0 }: Props) {
121122
)}
122123

123124
<div className="mb-4">
124-
<div className="markdown-body text-sm sm:text-base max-w-none body-theme">
125-
<MemoEcencyRenderer value={summary} />
126-
</div>
125+
<div
126+
className="markdown-body text-sm sm:text-base max-w-none body-theme entry-body"
127+
dangerouslySetInnerHTML={{
128+
__html: renderPostBody(summary, false, true),
129+
}}
130+
/>
127131
</div>
128132

129133
{tags.length > 0 && (

apps/self-hosted/src/features/blog/layout/blog-navigation.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,19 @@ export function BlogNavigation() {
4343
// Use community title if available and in community mode
4444
const displayTitle = isCommunityMode && community?.title ? community.title : blogTitle;
4545

46-
// Use community avatar if available and no custom logo
47-
const displayLogo = blogLogo || (isCommunityMode && community?.avatar_url ? community.avatar_url : null);
46+
// Use community avatar from image proxy if no custom logo
47+
const displayLogo = useMemo(() => {
48+
if (blogLogo) return blogLogo;
49+
if (isCommunityMode && community?.name) {
50+
const proxyBase = InstanceConfigManager.getConfigValue(
51+
({ configuration }) =>
52+
(configuration.general as Record<string, unknown>).imageProxy as string ||
53+
'https://images.ecency.com',
54+
);
55+
return `${proxyBase}/u/${community.name}/avatar/medium`;
56+
}
57+
return null;
58+
}, [blogLogo, isCommunityMode, community?.name]);
4859

4960
// Blog filters
5061
const blogFilterLabels: Record<string, string> = {

apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,18 @@ function BlogSidebarContent({ username }: { username: string }) {
110110
function CommunitySidebar() {
111111
const { data: community, isLoading } = useCommunityData();
112112

113+
// Get the community avatar URL from the image proxy
114+
const communityAvatarUrl = useMemo(() => {
115+
if (!community?.name) return null;
116+
const proxyBase = InstanceConfigManager.getConfigValue(
117+
({ configuration }) =>
118+
(configuration.general as Record<string, unknown>).imageProxy as string ||
119+
'https://images.ecency.com',
120+
);
121+
// Community avatars use the same pattern as user avatars
122+
return `${proxyBase}/u/${community.name}/avatar/medium`;
123+
}, [community?.name]);
124+
113125
if (isLoading) {
114126
return (
115127
<div className="lg:sticky lg:top-0 border-b lg:border-b-0 lg:border-l border-theme p-4 sm:p-6 lg:h-screen lg:overflow-y-auto">
@@ -133,9 +145,9 @@ function CommunitySidebar() {
133145
return (
134146
<div className="lg:sticky lg:top-0 border-b lg:border-b-0 lg:border-l border-theme p-4 sm:p-6 lg:h-screen lg:overflow-y-auto">
135147
<div className="flex items-center gap-3 mb-4">
136-
{community.avatar_url ? (
148+
{communityAvatarUrl ? (
137149
<img
138-
src={community.avatar_url}
150+
src={communityAvatarUrl}
139151
alt={community.title}
140152
className="w-12 h-12 sm:w-16 sm:h-16 rounded-full object-cover"
141153
/>

apps/self-hosted/src/features/floating-menu/config-fields.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,11 @@ export const configFieldsMap: Record<string, ConfigField> = {
285285
type: 'string',
286286
description: 'Date and time format pattern',
287287
},
288+
imageProxy: {
289+
label: 'Image Proxy URL',
290+
type: 'string',
291+
description: 'Image proxy base URL (e.g., https://images.ecency.com)',
292+
},
288293
styles: {
289294
label: 'Styles',
290295
type: 'section',

apps/self-hosted/src/features/shared/user-avatar.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
'use client';
22

3-
import { proxifyImageSrc, setProxyBase } from '@ecency/render-helper';
3+
import { proxifyImageSrc } from '@ecency/render-helper';
44
import clsx from 'clsx';
55
import { useEffect, useMemo, useState } from 'react';
6-
7-
// Set proxy base for images
8-
setProxyBase('https://images.ecency.com');
6+
import { InstanceConfigManager } from '@/core';
97

108
interface Props {
119
username: string;
@@ -70,7 +68,12 @@ export function UserAvatar({
7068
// Always return a URL, even during SSR/hydration
7169
// This ensures the avatar shows immediately
7270
const format = hasMounted && canUseWebp ? 'webp' : 'match';
73-
const fallbackUrl = `https://images.ecency.com${
71+
const proxyBase = InstanceConfigManager.getConfigValue(
72+
({ configuration }) =>
73+
(configuration.general as Record<string, unknown>).imageProxy as string ||
74+
'https://images.ecency.com',
75+
);
76+
const fallbackUrl = `${proxyBase}${
7477
format === 'webp' ? '/webp' : ''
7578
}/u/${username}/avatar/${imgSize}`;
7679

0 commit comments

Comments
 (0)