Skip to content

Commit 0c37523

Browse files
committed
feat(users-table): add cache layer for user actions to use response of action endpoint
1 parent 855627c commit 0c37523

File tree

7 files changed

+391
-16
lines changed

7 files changed

+391
-16
lines changed

dashboard/public/statics/locales/fa.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1521,7 +1521,7 @@
15211521
"userDialog": {
15221522
"deleteSuccess": "کاربر «{{name}}» با موفقیت حذف شد.",
15231523
"resetUsageSuccess": "مصرف کاربر «{{name}}» بازنشانی شد.",
1524-
"revokeSubSuccess": "اشتراک کاربر «{{name}}» لغو شد.",
1524+
"revokeSubSuccess": "اشتراک کاربر «{{name}}» بازنشانی شد.",
15251525
"activeNextPlanSuccess": "پلن بعدی برای کاربر «{{name}}» فعال شد.",
15261526
"activeNextPlanError": "فعال‌سازی پلن بعدی برای کاربر «{{name}}» با شکست مواجه شد.",
15271527
"deleteConfirmTitle": "حذف کاربر",
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import { X } from 'lucide-react'
2+
import { useEffect, useState } from 'react'
3+
import { useTranslation } from 'react-i18next'
4+
import { Button } from '@/components/ui/button'
5+
import { cn } from '@/lib/utils'
6+
import { useTheme, type ColorTheme } from '@/components/common/theme-provider'
7+
import useDirDetection from '@/hooks/use-dir-detection'
8+
9+
const TOPBAR_AD_STORAGE_KEY = 'topbar_ad_closed'
10+
const HOURS_TO_HIDE = 24
11+
12+
interface TopbarAdConfig {
13+
enabled: boolean
14+
translations: {
15+
[key: string]: {
16+
enabled: boolean
17+
text: string
18+
textMobile: string
19+
linkText: string
20+
linkTextMobile: string
21+
linkUrl: string
22+
icon?: string
23+
}
24+
}
25+
}
26+
27+
const getGradientByColorTheme = (colorTheme: ColorTheme, isDark: boolean): string => {
28+
const gradients: Record<ColorTheme, { light: string; dark: string }> = {
29+
default: {
30+
light: 'bg-gradient-to-r from-blue-100/90 via-indigo-100/90 to-blue-100/90',
31+
dark: 'bg-gradient-to-r from-blue-950/50 via-indigo-950/50 to-blue-950/50',
32+
},
33+
red: {
34+
light: 'bg-gradient-to-r from-red-100/90 via-rose-100/90 to-red-100/90',
35+
dark: 'bg-gradient-to-r from-red-950/50 via-rose-950/50 to-red-950/50',
36+
},
37+
rose: {
38+
light: 'bg-gradient-to-r from-rose-100/90 via-pink-100/90 to-rose-100/90',
39+
dark: 'bg-gradient-to-r from-rose-950/50 via-pink-950/50 to-rose-950/50',
40+
},
41+
orange: {
42+
light: 'bg-gradient-to-r from-orange-100/90 via-amber-100/90 to-orange-100/90',
43+
dark: 'bg-gradient-to-r from-orange-950/50 via-amber-950/50 to-orange-950/50',
44+
},
45+
green: {
46+
light: 'bg-gradient-to-r from-green-100/90 via-emerald-100/90 to-green-100/90',
47+
dark: 'bg-gradient-to-r from-green-950/50 via-emerald-950/50 to-green-950/50',
48+
},
49+
blue: {
50+
light: 'bg-gradient-to-r from-blue-100/90 via-cyan-100/90 to-blue-100/90',
51+
dark: 'bg-gradient-to-r from-blue-950/50 via-cyan-950/50 to-blue-950/50',
52+
},
53+
yellow: {
54+
light: 'bg-gradient-to-r from-yellow-100/90 via-amber-100/90 to-yellow-100/90',
55+
dark: 'bg-gradient-to-r from-yellow-950/50 via-amber-950/50 to-yellow-950/50',
56+
},
57+
violet: {
58+
light: 'bg-gradient-to-r from-violet-100/90 via-purple-100/90 to-violet-100/90',
59+
dark: 'bg-gradient-to-r from-violet-950/50 via-purple-950/50 to-violet-950/50',
60+
},
61+
}
62+
63+
return isDark ? gradients[colorTheme].dark : gradients[colorTheme].light
64+
}
65+
66+
export default function TopbarAd() {
67+
const { i18n } = useTranslation()
68+
const { resolvedTheme, colorTheme } = useTheme()
69+
const dir = useDirDetection()
70+
const isRTL = dir === 'rtl'
71+
const [isVisible, setIsVisible] = useState(false)
72+
const [isClosing, setIsClosing] = useState(false)
73+
const [config, setConfig] = useState<TopbarAdConfig | null>(null)
74+
const [isLoading, setIsLoading] = useState(false)
75+
const [isAnimating, setIsAnimating] = useState(false)
76+
const [iconLoaded, setIconLoaded] = useState(false)
77+
const [iconError, setIconError] = useState(false)
78+
79+
useEffect(() => {
80+
const checkShouldFetch = () => {
81+
const closedTimestamp = localStorage.getItem(TOPBAR_AD_STORAGE_KEY)
82+
83+
if (!closedTimestamp) {
84+
return true
85+
}
86+
87+
const closedTime = parseInt(closedTimestamp, 10)
88+
const now = Date.now()
89+
const hoursSinceClose = (now - closedTime) / (1000 * 60 * 60)
90+
91+
return hoursSinceClose >= HOURS_TO_HIDE
92+
}
93+
94+
if (!checkShouldFetch()) {
95+
setIsLoading(false)
96+
return
97+
}
98+
99+
const loadConfig = async () => {
100+
try {
101+
const githubApiUrl = 'https://api.github.com/repos/pasarguard/ads/contents/config'
102+
const response = await fetch(githubApiUrl, {
103+
cache: 'no-cache',
104+
})
105+
if (response.ok) {
106+
const apiData = await response.json()
107+
if (apiData.content && apiData.encoding === 'base64') {
108+
const base64Content = apiData.content.replace(/\n/g, '')
109+
const binaryString = atob(base64Content)
110+
const utf8String = decodeURIComponent(
111+
Array.from(binaryString, (char) => '%' + ('00' + char.charCodeAt(0).toString(16)).slice(-2)).join('')
112+
)
113+
const data = JSON.parse(utf8String)
114+
setConfig(data)
115+
} else {
116+
setConfig(null)
117+
}
118+
} else {
119+
setConfig(null)
120+
}
121+
} catch (error) {
122+
setConfig(null)
123+
} finally {
124+
setIsLoading(false)
125+
}
126+
}
127+
128+
if ('requestIdleCallback' in window) {
129+
requestIdleCallback(() => {
130+
setIsLoading(true)
131+
loadConfig()
132+
}, { timeout: 2000 })
133+
} else {
134+
setTimeout(() => {
135+
setIsLoading(true)
136+
loadConfig()
137+
}, 500)
138+
}
139+
}, [])
140+
141+
useEffect(() => {
142+
if (isLoading || !config || !config.enabled) {
143+
setIsVisible(false)
144+
setIsAnimating(false)
145+
return
146+
}
147+
148+
const checkShouldShow = () => {
149+
const closedTimestamp = localStorage.getItem(TOPBAR_AD_STORAGE_KEY)
150+
151+
if (!closedTimestamp) {
152+
setIsVisible(true)
153+
setTimeout(() => {
154+
setIsAnimating(true)
155+
}, 100)
156+
return
157+
}
158+
159+
const closedTime = parseInt(closedTimestamp, 10)
160+
const now = Date.now()
161+
const hoursSinceClose = (now - closedTime) / (1000 * 60 * 60)
162+
163+
if (hoursSinceClose >= HOURS_TO_HIDE) {
164+
setIsVisible(true)
165+
setTimeout(() => {
166+
setIsAnimating(true)
167+
}, 100)
168+
}
169+
}
170+
171+
checkShouldShow()
172+
}, [config, isLoading])
173+
174+
const handleClose = (e: React.MouseEvent) => {
175+
e.preventDefault()
176+
e.stopPropagation()
177+
setIsClosing(true)
178+
localStorage.setItem(TOPBAR_AD_STORAGE_KEY, Date.now().toString())
179+
setTimeout(() => {
180+
setIsVisible(false)
181+
}, 300)
182+
}
183+
184+
const currentLang = i18n.language || 'en'
185+
const langCode = currentLang.split('-')[0]
186+
const translations = config?.translations?.[langCode] || config?.translations?.en
187+
const iconUrl = translations?.icon
188+
189+
useEffect(() => {
190+
if (iconUrl) {
191+
setIconLoaded(false)
192+
setIconError(false)
193+
} else {
194+
setIconError(true)
195+
}
196+
}, [iconUrl])
197+
198+
if (isLoading || !config || !config.enabled) return null
199+
if (!isVisible) return null
200+
if (!translations?.enabled) return null
201+
202+
const handleTopbarClick = (e: React.MouseEvent) => {
203+
if (translations.linkUrl === '#') {
204+
e.preventDefault()
205+
}
206+
}
207+
208+
const isDark = resolvedTheme === 'dark'
209+
const gradientBg = getGradientByColorTheme(colorTheme, isDark)
210+
211+
return (
212+
<div
213+
className={cn(
214+
'relative z-[25] lg:z-30 w-full border-b border-border/40 backdrop-blur-sm',
215+
gradientBg,
216+
'overflow-hidden',
217+
isClosing
218+
? 'max-h-0 opacity-0 -translate-y-2 border-0 py-0'
219+
: 'max-h-32 sm:max-h-36'
220+
)}
221+
style={{
222+
transition: isClosing
223+
? 'max-height 300ms ease-in-out, opacity 300ms ease-in-out, transform 300ms ease-in-out, border 300ms ease-in-out, padding 300ms ease-in-out'
224+
: 'opacity 400ms ease-out, transform 400ms ease-out',
225+
opacity: isClosing ? 0 : isAnimating ? 1 : 0,
226+
transform: isClosing ? 'translateY(-8px)' : isAnimating ? 'translateY(0)' : 'translateY(-8px)',
227+
}}
228+
>
229+
<a
230+
href={translations.linkUrl}
231+
onClick={handleTopbarClick}
232+
target="_blank"
233+
rel="noopener noreferrer"
234+
className={cn(
235+
'block w-full cursor-pointer transition-all duration-200 ease-in-out hover:opacity-95 hover:brightness-[1.02]',
236+
isRTL ? 'pl-10 sm:pl-12' : 'pr-10 sm:pr-12'
237+
)}
238+
>
239+
<div className={cn(
240+
'mx-auto flex max-w-[1920px] items-center gap-2.5 px-3 py-2.5 sm:px-4',
241+
isRTL ? 'justify-between' : 'justify-center'
242+
)}>
243+
<div className={cn(
244+
'flex flex-1 items-center gap-2 sm:gap-3 text-xs sm:text-sm min-w-0',
245+
isRTL ? 'justify-start' : 'justify-center'
246+
)}>
247+
{iconUrl && !iconError && (
248+
<img
249+
src={iconUrl}
250+
alt=""
251+
className="h-5 w-5 shrink-0 object-contain rounded text-foreground/75"
252+
onLoad={() => setIconLoaded(true)}
253+
onError={() => setIconError(true)}
254+
style={{ display: iconLoaded ? 'block' : 'none' }}
255+
/>
256+
)}
257+
<span className={cn(
258+
'text-foreground/75 line-clamp-2 flex-1',
259+
isRTL ? 'text-center sm:text-right' : 'text-center sm:text-left'
260+
)}>
261+
<span className="hidden sm:inline">{translations.text}</span>
262+
<span className="sm:hidden">{translations.textMobile}</span>
263+
</span>
264+
</div>
265+
<div className="flex items-center shrink-0">
266+
<span className={cn(
267+
'shrink-0 hidden sm:inline whitespace-nowrap',
268+
'px-2.5 py-1 rounded-md text-xs font-medium',
269+
'bg-primary text-primary-foreground hover:bg-primary/90',
270+
'transition-colors duration-200 ease-in-out',
271+
'shadow-sm hover:shadow'
272+
)}>
273+
{translations.linkText}
274+
</span>
275+
<span className={cn(
276+
'shrink-0 sm:hidden whitespace-nowrap',
277+
'px-2 py-0.5 rounded-md text-xs font-medium',
278+
'bg-primary text-primary-foreground hover:bg-primary/90',
279+
'transition-colors duration-200 ease-in-out',
280+
'shadow-sm hover:shadow'
281+
)}>
282+
{translations.linkTextMobile}
283+
</span>
284+
</div>
285+
</div>
286+
</a>
287+
288+
<Button
289+
variant="ghost"
290+
size="icon"
291+
onClick={handleClose}
292+
className={cn(
293+
'absolute top-1/2 -translate-y-1/2',
294+
isRTL ? 'left-3 sm:left-4' : 'right-3 sm:right-4',
295+
'h-7 w-7 shrink-0 rounded hover:bg-muted/40 transition-all z-10',
296+
'text-muted-foreground/70 hover:text-foreground',
297+
'touch-manipulation'
298+
)}
299+
aria-label="Close ad"
300+
>
301+
<X className="h-4 w-4" />
302+
</Button>
303+
</div>
304+
)
305+
}
306+

dashboard/src/components/dialogs/set-owner-modal.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
44
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
55
import { Loader2 } from 'lucide-react'
66
import { useTranslation } from 'react-i18next'
7-
import { useSetOwner } from '@/service/api'
7+
import { useSetOwner, UserResponse } from '@/service/api'
88
import { toast } from 'sonner'
99
import useDynamicErrorHandler from '@/hooks/use-dynamic-errors'
1010

@@ -13,7 +13,7 @@ interface SetOwnerModalProps {
1313
onClose: () => void
1414
username: string
1515
currentOwner?: string | null
16-
onSuccess?: () => void
16+
onSuccess?: (user?: UserResponse) => void
1717
}
1818

1919
export default function SetOwnerModal({ open, onClose, username, currentOwner, onSuccess }: SetOwnerModalProps) {
@@ -24,7 +24,15 @@ export default function SetOwnerModal({ open, onClose, username, currentOwner, o
2424
const [admins, setAdmins] = useState<any[]>([])
2525
const [isLoading, setIsLoading] = useState(false)
2626
const [isError, setIsError] = useState(false)
27-
const setOwnerMutation = useSetOwner({})
27+
const setOwnerMutation = useSetOwner({
28+
mutation: {
29+
onSuccess: (updatedUser) => {
30+
if (onSuccess && updatedUser) {
31+
onSuccess(updatedUser)
32+
}
33+
},
34+
},
35+
})
2836
const handleDynamicError = useDynamicErrorHandler()
2937

3038
useEffect(() => {
@@ -65,7 +73,6 @@ export default function SetOwnerModal({ open, onClose, username, currentOwner, o
6573
await setOwnerMutation.mutateAsync({ username, params: { admin_username: selectedAdmin } })
6674
toast.success(t('setOwnerModal.success', { username, admin: selectedAdmin }))
6775
onClose()
68-
if (onSuccess) onSuccess()
6976
} catch (error: any) {
7077
handleDynamicError({
7178
error,

0 commit comments

Comments
 (0)