@mdguggenbichler/slugbase-core 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frontend/index.tsx +3 -3
- package/frontend/public/favicon.svg +1 -0
- package/frontend/public/slugbase_icon_blue.svg +1 -0
- package/frontend/public/slugbase_icon_white.png +0 -0
- package/frontend/public/slugbase_icon_white.svg +1 -0
- package/frontend/src/App.tsx +179 -0
- package/frontend/src/api/client.ts +134 -0
- package/frontend/src/components/AppSidebar.tsx +214 -0
- package/frontend/src/components/EmptyState.tsx +33 -0
- package/frontend/src/components/Favicon.tsx +76 -0
- package/frontend/src/components/FilterChips.tsx +60 -0
- package/frontend/src/components/FolderIcon.tsx +207 -0
- package/frontend/src/components/GlobalSearch.tsx +275 -0
- package/frontend/src/components/Layout.tsx +60 -0
- package/frontend/src/components/PageHeader.tsx +31 -0
- package/frontend/src/components/ScopeSegmentedControl.tsx +42 -0
- package/frontend/src/components/SentryDebug.tsx +32 -0
- package/frontend/src/components/StatCard.tsx +66 -0
- package/frontend/src/components/TopBar.tsx +63 -0
- package/frontend/src/components/UserDropdown.tsx +86 -0
- package/frontend/src/components/admin/AdminAI.tsx +207 -0
- package/frontend/src/components/admin/AdminOIDCProviders.tsx +183 -0
- package/frontend/src/components/admin/AdminSettings.tsx +413 -0
- package/frontend/src/components/admin/AdminTeams.tsx +177 -0
- package/frontend/src/components/admin/AdminUsers.tsx +225 -0
- package/frontend/src/components/bookmarks/BookmarkCard.tsx +312 -0
- package/frontend/src/components/bookmarks/BookmarkListItem.tsx +159 -0
- package/frontend/src/components/bookmarks/BookmarkTableView.tsx +419 -0
- package/frontend/src/components/bookmarks/BulkActionModals.tsx +162 -0
- package/frontend/src/components/bookmarks/FilterChips.tsx +5 -0
- package/frontend/src/components/modals/BookmarkModal.tsx +493 -0
- package/frontend/src/components/modals/FolderModal.tsx +306 -0
- package/frontend/src/components/modals/ImportModal.tsx +232 -0
- package/frontend/src/components/modals/OIDCProviderModal.tsx +284 -0
- package/frontend/src/components/modals/SharingModal.tsx +96 -0
- package/frontend/src/components/modals/TagModal.tsx +101 -0
- package/frontend/src/components/modals/TeamAssignmentModal.tsx +354 -0
- package/frontend/src/components/modals/TeamModal.tsx +117 -0
- package/frontend/src/components/modals/UserModal.tsx +225 -0
- package/frontend/src/components/profile/CreateTokenModal.tsx +172 -0
- package/frontend/src/components/sharing/ShareResourceDialog.tsx +422 -0
- package/frontend/src/components/ui/Autocomplete.tsx +155 -0
- package/frontend/src/components/ui/Button.tsx +68 -0
- package/frontend/src/components/ui/ConfirmDialog.tsx +79 -0
- package/frontend/src/components/ui/FormFieldWrapper.tsx +36 -0
- package/frontend/src/components/ui/ModalFooterActions.tsx +49 -0
- package/frontend/src/components/ui/ModalSection.tsx +34 -0
- package/frontend/src/components/ui/PageLoadingSkeleton.tsx +24 -0
- package/frontend/src/components/ui/Select.tsx +61 -0
- package/frontend/src/components/ui/SharingField.tsx +298 -0
- package/frontend/src/components/ui/Toast.tsx +47 -0
- package/frontend/src/components/ui/Tooltip.tsx +21 -0
- package/frontend/src/components/ui/alert-dialog.tsx +139 -0
- package/frontend/src/components/ui/badge.tsx +36 -0
- package/frontend/src/components/ui/button-base.tsx +57 -0
- package/frontend/src/components/ui/card.tsx +76 -0
- package/frontend/src/components/ui/command.tsx +161 -0
- package/frontend/src/components/ui/dialog.tsx +120 -0
- package/frontend/src/components/ui/dropdown-menu.tsx +199 -0
- package/frontend/src/components/ui/input.tsx +22 -0
- package/frontend/src/components/ui/label.tsx +24 -0
- package/frontend/src/components/ui/popover.tsx +33 -0
- package/frontend/src/components/ui/progress.tsx +26 -0
- package/frontend/src/components/ui/scroll-area.tsx +48 -0
- package/frontend/src/components/ui/select-base.tsx +159 -0
- package/frontend/src/components/ui/separator.tsx +29 -0
- package/frontend/src/components/ui/sheet.tsx +140 -0
- package/frontend/src/components/ui/sidebar.tsx +783 -0
- package/frontend/src/components/ui/skeleton.tsx +15 -0
- package/frontend/src/components/ui/sonner.tsx +46 -0
- package/frontend/src/components/ui/switch.tsx +28 -0
- package/frontend/src/components/ui/table.tsx +120 -0
- package/frontend/src/components/ui/tooltip-base.tsx +30 -0
- package/frontend/src/config/api.ts +16 -0
- package/frontend/src/config/mode.ts +6 -0
- package/frontend/src/contexts/AppConfigContext.tsx +39 -0
- package/frontend/src/contexts/AuthContext.tsx +137 -0
- package/frontend/src/contexts/SearchCommandContext.tsx +28 -0
- package/frontend/src/hooks/use-mobile.tsx +19 -0
- package/frontend/src/hooks/useConfirmDialog.ts +63 -0
- package/frontend/src/hooks/useMarketingTheme.ts +47 -0
- package/frontend/src/i18n.ts +39 -0
- package/frontend/src/index.css +117 -0
- package/frontend/src/instrument.ts +20 -0
- package/frontend/src/lib/utils.ts +6 -0
- package/frontend/src/locales/de.json +899 -0
- package/frontend/src/locales/en.json +937 -0
- package/frontend/src/locales/es.json +884 -0
- package/frontend/src/locales/fr.json +550 -0
- package/frontend/src/locales/it.json +535 -0
- package/frontend/src/locales/ja.json +535 -0
- package/frontend/src/locales/nl.json +550 -0
- package/frontend/src/locales/pl.json +535 -0
- package/frontend/src/locales/pt.json +535 -0
- package/frontend/src/locales/ru.json +535 -0
- package/frontend/src/locales/zh.json +535 -0
- package/frontend/src/main.tsx +44 -0
- package/frontend/src/pages/Bookmarks.tsx +1004 -0
- package/frontend/src/pages/Dashboard.tsx +427 -0
- package/frontend/src/pages/Folders.tsx +578 -0
- package/frontend/src/pages/GoPreferences.tsx +134 -0
- package/frontend/src/pages/Login.tsx +196 -0
- package/frontend/src/pages/PasswordReset.tsx +242 -0
- package/frontend/src/pages/Profile.tsx +593 -0
- package/frontend/src/pages/SearchEngineGuide.tsx +135 -0
- package/frontend/src/pages/Setup.tsx +210 -0
- package/frontend/src/pages/Signup.tsx +199 -0
- package/frontend/src/pages/Tags.tsx +421 -0
- package/frontend/src/pages/VerifyEmail.tsx +254 -0
- package/frontend/src/pages/admin/AdminAIPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminLayout.tsx +40 -0
- package/frontend/src/pages/admin/AdminMembersPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminOIDCPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminSettingsPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminTeamsPage.tsx +5 -0
- package/frontend/src/utils/favicon.ts +36 -0
- package/frontend/src/utils/formatRelativeTime.ts +37 -0
- package/frontend/src/utils/safeHref.ts +31 -0
- package/frontend/src/vite-env.d.ts +10 -0
- package/package.json +9 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils"
|
|
2
|
+
|
|
3
|
+
function Skeleton({
|
|
4
|
+
className,
|
|
5
|
+
...props
|
|
6
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
|
10
|
+
{...props}
|
|
11
|
+
/>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { Skeleton }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { Toaster as Sonner } from "sonner"
|
|
3
|
+
|
|
4
|
+
type ToasterProps = React.ComponentProps<typeof Sonner>
|
|
5
|
+
|
|
6
|
+
function useTheme() {
|
|
7
|
+
const [theme, setTheme] = React.useState<"light" | "dark" | "system">("system")
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
const el = document.documentElement
|
|
10
|
+
const check = () =>
|
|
11
|
+
setTheme(el.classList.contains("dark") ? "dark" : "light")
|
|
12
|
+
check()
|
|
13
|
+
const observer = new MutationObserver(check)
|
|
14
|
+
observer.observe(el, {
|
|
15
|
+
attributes: true,
|
|
16
|
+
attributeFilter: ["class"],
|
|
17
|
+
})
|
|
18
|
+
return () => observer.disconnect()
|
|
19
|
+
}, [])
|
|
20
|
+
return { theme }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const Toaster = ({ ...props }: ToasterProps) => {
|
|
24
|
+
const { theme = "system" } = useTheme()
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Sonner
|
|
28
|
+
theme={theme as ToasterProps["theme"]}
|
|
29
|
+
className="toaster group"
|
|
30
|
+
toastOptions={{
|
|
31
|
+
classNames: {
|
|
32
|
+
toast:
|
|
33
|
+
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
|
34
|
+
description: "group-[.toast]:text-muted-foreground",
|
|
35
|
+
actionButton:
|
|
36
|
+
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
|
37
|
+
cancelButton:
|
|
38
|
+
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
|
39
|
+
},
|
|
40
|
+
}}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { Toaster }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
const Switch = React.forwardRef<
|
|
8
|
+
React.ElementRef<typeof SwitchPrimitives.Root>,
|
|
9
|
+
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
|
10
|
+
>(({ className, ...props }, ref) => (
|
|
11
|
+
<SwitchPrimitives.Root
|
|
12
|
+
className={cn(
|
|
13
|
+
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
|
14
|
+
className
|
|
15
|
+
)}
|
|
16
|
+
{...props}
|
|
17
|
+
ref={ref}
|
|
18
|
+
>
|
|
19
|
+
<SwitchPrimitives.Thumb
|
|
20
|
+
className={cn(
|
|
21
|
+
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
|
22
|
+
)}
|
|
23
|
+
/>
|
|
24
|
+
</SwitchPrimitives.Root>
|
|
25
|
+
))
|
|
26
|
+
Switch.displayName = SwitchPrimitives.Root.displayName
|
|
27
|
+
|
|
28
|
+
export { Switch }
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
const Table = React.forwardRef<
|
|
6
|
+
HTMLTableElement,
|
|
7
|
+
React.HTMLAttributes<HTMLTableElement>
|
|
8
|
+
>(({ className, ...props }, ref) => (
|
|
9
|
+
<div className="relative w-full overflow-auto">
|
|
10
|
+
<table
|
|
11
|
+
ref={ref}
|
|
12
|
+
className={cn("w-full caption-bottom text-sm", className)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
</div>
|
|
16
|
+
))
|
|
17
|
+
Table.displayName = "Table"
|
|
18
|
+
|
|
19
|
+
const TableHeader = React.forwardRef<
|
|
20
|
+
HTMLTableSectionElement,
|
|
21
|
+
React.HTMLAttributes<HTMLTableSectionElement>
|
|
22
|
+
>(({ className, ...props }, ref) => (
|
|
23
|
+
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
|
24
|
+
))
|
|
25
|
+
TableHeader.displayName = "TableHeader"
|
|
26
|
+
|
|
27
|
+
const TableBody = React.forwardRef<
|
|
28
|
+
HTMLTableSectionElement,
|
|
29
|
+
React.HTMLAttributes<HTMLTableSectionElement>
|
|
30
|
+
>(({ className, ...props }, ref) => (
|
|
31
|
+
<tbody
|
|
32
|
+
ref={ref}
|
|
33
|
+
className={cn("[&_tr:last-child]:border-0", className)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
))
|
|
37
|
+
TableBody.displayName = "TableBody"
|
|
38
|
+
|
|
39
|
+
const TableFooter = React.forwardRef<
|
|
40
|
+
HTMLTableSectionElement,
|
|
41
|
+
React.HTMLAttributes<HTMLTableSectionElement>
|
|
42
|
+
>(({ className, ...props }, ref) => (
|
|
43
|
+
<tfoot
|
|
44
|
+
ref={ref}
|
|
45
|
+
className={cn(
|
|
46
|
+
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
|
47
|
+
className
|
|
48
|
+
)}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
))
|
|
52
|
+
TableFooter.displayName = "TableFooter"
|
|
53
|
+
|
|
54
|
+
const TableRow = React.forwardRef<
|
|
55
|
+
HTMLTableRowElement,
|
|
56
|
+
React.HTMLAttributes<HTMLTableRowElement>
|
|
57
|
+
>(({ className, ...props }, ref) => (
|
|
58
|
+
<tr
|
|
59
|
+
ref={ref}
|
|
60
|
+
className={cn(
|
|
61
|
+
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
|
62
|
+
className
|
|
63
|
+
)}
|
|
64
|
+
{...props}
|
|
65
|
+
/>
|
|
66
|
+
))
|
|
67
|
+
TableRow.displayName = "TableRow"
|
|
68
|
+
|
|
69
|
+
const TableHead = React.forwardRef<
|
|
70
|
+
HTMLTableCellElement,
|
|
71
|
+
React.ThHTMLAttributes<HTMLTableCellElement>
|
|
72
|
+
>(({ className, ...props }, ref) => (
|
|
73
|
+
<th
|
|
74
|
+
ref={ref}
|
|
75
|
+
className={cn(
|
|
76
|
+
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
77
|
+
className
|
|
78
|
+
)}
|
|
79
|
+
{...props}
|
|
80
|
+
/>
|
|
81
|
+
))
|
|
82
|
+
TableHead.displayName = "TableHead"
|
|
83
|
+
|
|
84
|
+
const TableCell = React.forwardRef<
|
|
85
|
+
HTMLTableCellElement,
|
|
86
|
+
React.TdHTMLAttributes<HTMLTableCellElement>
|
|
87
|
+
>(({ className, ...props }, ref) => (
|
|
88
|
+
<td
|
|
89
|
+
ref={ref}
|
|
90
|
+
className={cn(
|
|
91
|
+
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
92
|
+
className
|
|
93
|
+
)}
|
|
94
|
+
{...props}
|
|
95
|
+
/>
|
|
96
|
+
))
|
|
97
|
+
TableCell.displayName = "TableCell"
|
|
98
|
+
|
|
99
|
+
const TableCaption = React.forwardRef<
|
|
100
|
+
HTMLTableCaptionElement,
|
|
101
|
+
React.HTMLAttributes<HTMLTableCaptionElement>
|
|
102
|
+
>(({ className, ...props }, ref) => (
|
|
103
|
+
<caption
|
|
104
|
+
ref={ref}
|
|
105
|
+
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
106
|
+
{...props}
|
|
107
|
+
/>
|
|
108
|
+
))
|
|
109
|
+
TableCaption.displayName = "TableCaption"
|
|
110
|
+
|
|
111
|
+
export {
|
|
112
|
+
Table,
|
|
113
|
+
TableHeader,
|
|
114
|
+
TableBody,
|
|
115
|
+
TableFooter,
|
|
116
|
+
TableHead,
|
|
117
|
+
TableRow,
|
|
118
|
+
TableCell,
|
|
119
|
+
TableCaption,
|
|
120
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
const TooltipProvider = TooltipPrimitive.Provider
|
|
7
|
+
|
|
8
|
+
const Tooltip = TooltipPrimitive.Root
|
|
9
|
+
|
|
10
|
+
const TooltipTrigger = TooltipPrimitive.Trigger
|
|
11
|
+
|
|
12
|
+
const TooltipContent = React.forwardRef<
|
|
13
|
+
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
14
|
+
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
15
|
+
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
16
|
+
<TooltipPrimitive.Portal>
|
|
17
|
+
<TooltipPrimitive.Content
|
|
18
|
+
ref={ref}
|
|
19
|
+
sideOffset={sideOffset}
|
|
20
|
+
className={cn(
|
|
21
|
+
"z-50 overflow-hidden rounded-md bg-tooltip px-3 py-1.5 text-xs text-tooltip-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
|
22
|
+
className
|
|
23
|
+
)}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
</TooltipPrimitive.Portal>
|
|
27
|
+
))
|
|
28
|
+
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
|
29
|
+
|
|
30
|
+
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-hosted API config always uses relative paths by default.
|
|
3
|
+
*/
|
|
4
|
+
export const apiBaseUrl: string = '';
|
|
5
|
+
|
|
6
|
+
/** Full URL to start OIDC login for a provider. Pass apiBaseUrl when using from context (e.g. useAppConfig().apiBaseUrl). */
|
|
7
|
+
export function getAuthProviderUrl(providerKey: string, baseUrl?: string): string {
|
|
8
|
+
const url = baseUrl ?? apiBaseUrl;
|
|
9
|
+
return url ? `${url.replace(/\/$/, '')}/api/auth/${providerKey}` : `/api/auth/${providerKey}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Base path for app routes. */
|
|
13
|
+
export const appBasePath: string = '';
|
|
14
|
+
|
|
15
|
+
/** Root path for the app (dashboard). */
|
|
16
|
+
export const appRootPath: string = '/';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react';
|
|
2
|
+
import { appBasePath as defaultAppBasePath, apiBaseUrl as defaultApiBaseUrl, appRootPath as defaultAppRootPath } from '../config/api';
|
|
3
|
+
|
|
4
|
+
export interface AppConfig {
|
|
5
|
+
appBasePath: string;
|
|
6
|
+
apiBaseUrl: string;
|
|
7
|
+
appRootPath: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const defaultConfig: AppConfig = {
|
|
11
|
+
appBasePath: defaultAppBasePath,
|
|
12
|
+
apiBaseUrl: defaultApiBaseUrl,
|
|
13
|
+
appRootPath: defaultAppRootPath,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const AppConfigContext = createContext<AppConfig>(defaultConfig);
|
|
17
|
+
|
|
18
|
+
export function AppConfigProvider({
|
|
19
|
+
children,
|
|
20
|
+
appBasePath,
|
|
21
|
+
apiBaseUrl,
|
|
22
|
+
appRootPath,
|
|
23
|
+
}: {
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
appBasePath?: string;
|
|
26
|
+
apiBaseUrl?: string;
|
|
27
|
+
appRootPath?: string;
|
|
28
|
+
}) {
|
|
29
|
+
const value: AppConfig = {
|
|
30
|
+
appBasePath: appBasePath ?? defaultConfig.appBasePath,
|
|
31
|
+
apiBaseUrl: apiBaseUrl ?? defaultConfig.apiBaseUrl,
|
|
32
|
+
appRootPath: appRootPath ?? defaultConfig.appRootPath,
|
|
33
|
+
};
|
|
34
|
+
return <AppConfigContext.Provider value={value}>{children}</AppConfigContext.Provider>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useAppConfig(): AppConfig {
|
|
38
|
+
return useContext(AppConfigContext);
|
|
39
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, useEffect } from 'react';
|
|
2
|
+
import api from '../api/client';
|
|
3
|
+
import { getAuthProviderUrl } from '../config/api';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { useAppConfig } from './AppConfigContext';
|
|
6
|
+
|
|
7
|
+
export interface User {
|
|
8
|
+
id: string;
|
|
9
|
+
email: string;
|
|
10
|
+
name: string;
|
|
11
|
+
user_key: string;
|
|
12
|
+
is_admin: boolean;
|
|
13
|
+
language: string;
|
|
14
|
+
theme: string;
|
|
15
|
+
ai_suggestions_enabled?: boolean;
|
|
16
|
+
email_pending?: string | null;
|
|
17
|
+
oidc_provider?: string | null;
|
|
18
|
+
oidc_sub?: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface AuthContextType {
|
|
22
|
+
user: User | null;
|
|
23
|
+
loading: boolean;
|
|
24
|
+
login: (provider: string) => void;
|
|
25
|
+
logout: () => Promise<void>;
|
|
26
|
+
updateUser: (updates: Partial<User>) => Promise<void>;
|
|
27
|
+
checkAuth: () => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
31
|
+
|
|
32
|
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
33
|
+
const [user, setUser] = useState<User | null>(null);
|
|
34
|
+
const [loading, setLoading] = useState(true);
|
|
35
|
+
const { i18n } = useTranslation();
|
|
36
|
+
const { apiBaseUrl } = useAppConfig();
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
// Apply initial theme based on browser preference before checking auth
|
|
40
|
+
// This ensures dark mode works on login page and other public pages
|
|
41
|
+
// Only apply if user hasn't set a preference yet
|
|
42
|
+
const root = document.documentElement;
|
|
43
|
+
if (!root.dataset.userTheme) {
|
|
44
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
45
|
+
if (prefersDark) {
|
|
46
|
+
root.classList.add('dark');
|
|
47
|
+
} else {
|
|
48
|
+
root.classList.remove('dark');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
checkAuth();
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (user) {
|
|
57
|
+
i18n.changeLanguage(user.language);
|
|
58
|
+
applyTheme(user.theme);
|
|
59
|
+
}
|
|
60
|
+
}, [user, i18n]);
|
|
61
|
+
|
|
62
|
+
async function checkAuth() {
|
|
63
|
+
try {
|
|
64
|
+
const response = await api.get('/auth/me');
|
|
65
|
+
setUser(response.data);
|
|
66
|
+
} catch {
|
|
67
|
+
setUser(null);
|
|
68
|
+
} finally {
|
|
69
|
+
setLoading(false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function login(provider: string) {
|
|
74
|
+
window.location.href = getAuthProviderUrl(provider, apiBaseUrl);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function logout() {
|
|
78
|
+
try {
|
|
79
|
+
await api.post('/auth/logout');
|
|
80
|
+
setUser(null);
|
|
81
|
+
window.location.href = '/';
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error('Logout error:', error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function updateUser(updates: Partial<User>): Promise<any> {
|
|
88
|
+
try {
|
|
89
|
+
const response = await api.put('/users/me', updates);
|
|
90
|
+
// Only update user if email wasn't changed (email verification required)
|
|
91
|
+
if (!response.data.emailVerificationRequired) {
|
|
92
|
+
setUser(response.data);
|
|
93
|
+
} else {
|
|
94
|
+
// Refresh user to get email_pending
|
|
95
|
+
await checkAuth();
|
|
96
|
+
}
|
|
97
|
+
if (updates.language) {
|
|
98
|
+
i18n.changeLanguage(updates.language);
|
|
99
|
+
}
|
|
100
|
+
if (updates.theme) {
|
|
101
|
+
applyTheme(updates.theme);
|
|
102
|
+
}
|
|
103
|
+
return response.data;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error('Update user error:', error);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function applyTheme(theme: string) {
|
|
111
|
+
const root = document.documentElement;
|
|
112
|
+
// Mark that user has set a theme preference
|
|
113
|
+
root.dataset.userTheme = 'true';
|
|
114
|
+
|
|
115
|
+
if (theme === 'dark' || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
116
|
+
root.classList.add('dark');
|
|
117
|
+
} else {
|
|
118
|
+
root.classList.remove('dark');
|
|
119
|
+
}
|
|
120
|
+
// Sync to localStorage for consistency when user logs out
|
|
121
|
+
localStorage.setItem('slugbase_theme', theme);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<AuthContext.Provider value={{ user, loading, login, logout, updateUser, checkAuth }}>
|
|
126
|
+
{children}
|
|
127
|
+
</AuthContext.Provider>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function useAuth() {
|
|
132
|
+
const context = useContext(AuthContext);
|
|
133
|
+
if (context === undefined) {
|
|
134
|
+
throw new Error('useAuth must be used within an AuthProvider');
|
|
135
|
+
}
|
|
136
|
+
return context;
|
|
137
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
interface SearchCommandContextValue {
|
|
4
|
+
open: boolean;
|
|
5
|
+
setOpen: (open: boolean) => void;
|
|
6
|
+
openSearch: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const SearchCommandContext = createContext<SearchCommandContextValue | null>(null);
|
|
10
|
+
|
|
11
|
+
export function SearchCommandProvider({ children }: { children: ReactNode }) {
|
|
12
|
+
const [open, setOpen] = useState(false);
|
|
13
|
+
const openSearch = useCallback(() => setOpen(true), []);
|
|
14
|
+
const value: SearchCommandContextValue = { open, setOpen, openSearch };
|
|
15
|
+
return (
|
|
16
|
+
<SearchCommandContext.Provider value={value}>
|
|
17
|
+
{children}
|
|
18
|
+
</SearchCommandContext.Provider>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useSearchCommand(): SearchCommandContextValue {
|
|
23
|
+
const ctx = useContext(SearchCommandContext);
|
|
24
|
+
if (!ctx) {
|
|
25
|
+
throw new Error('useSearchCommand must be used within SearchCommandProvider');
|
|
26
|
+
}
|
|
27
|
+
return ctx;
|
|
28
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 1024 // lg - matches Layout mobile breakpoint
|
|
4
|
+
|
|
5
|
+
export function useIsMobile() {
|
|
6
|
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
7
|
+
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
10
|
+
const onChange = () => {
|
|
11
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
12
|
+
}
|
|
13
|
+
mql.addEventListener("change", onChange)
|
|
14
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
15
|
+
return () => mql.removeEventListener("change", onChange)
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
return !!isMobile
|
|
19
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ConfirmDialogState {
|
|
4
|
+
isOpen: boolean;
|
|
5
|
+
title: string;
|
|
6
|
+
message: string;
|
|
7
|
+
itemName?: string;
|
|
8
|
+
confirmText?: string;
|
|
9
|
+
cancelText?: string;
|
|
10
|
+
variant?: 'danger' | 'warning' | 'default';
|
|
11
|
+
onConfirm: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useConfirmDialog() {
|
|
15
|
+
const [dialogState, setDialogState] = useState<ConfirmDialogState>({
|
|
16
|
+
isOpen: false,
|
|
17
|
+
title: '',
|
|
18
|
+
message: '',
|
|
19
|
+
onConfirm: () => {},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const showConfirm = useCallback((
|
|
23
|
+
title: string,
|
|
24
|
+
message: string,
|
|
25
|
+
onConfirm: () => void,
|
|
26
|
+
options?: {
|
|
27
|
+
itemName?: string;
|
|
28
|
+
confirmText?: string;
|
|
29
|
+
cancelText?: string;
|
|
30
|
+
variant?: 'danger' | 'warning' | 'default';
|
|
31
|
+
}
|
|
32
|
+
) => {
|
|
33
|
+
setDialogState({
|
|
34
|
+
isOpen: true,
|
|
35
|
+
title,
|
|
36
|
+
message,
|
|
37
|
+
onConfirm,
|
|
38
|
+
itemName: options?.itemName,
|
|
39
|
+
confirmText: options?.confirmText,
|
|
40
|
+
cancelText: options?.cancelText,
|
|
41
|
+
variant: options?.variant || 'default',
|
|
42
|
+
});
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const hideConfirm = useCallback(() => {
|
|
46
|
+
setDialogState((prev) => ({ ...prev, isOpen: false }));
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const handleConfirm = useCallback(() => {
|
|
50
|
+
dialogState.onConfirm();
|
|
51
|
+
hideConfirm();
|
|
52
|
+
}, [dialogState, hideConfirm]);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
showConfirm,
|
|
56
|
+
hideConfirm,
|
|
57
|
+
dialogState: {
|
|
58
|
+
...dialogState,
|
|
59
|
+
onConfirm: handleConfirm,
|
|
60
|
+
onCancel: hideConfirm,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useAuth } from '../contexts/AuthContext';
|
|
3
|
+
|
|
4
|
+
const THEME_STORAGE_KEY = 'slugbase_theme';
|
|
5
|
+
|
|
6
|
+
function getIsDark(): boolean {
|
|
7
|
+
return document.documentElement.classList.contains('dark');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function applyThemeToDocument(theme: 'light' | 'dark') {
|
|
11
|
+
const root = document.documentElement;
|
|
12
|
+
root.dataset.userTheme = 'true';
|
|
13
|
+
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
14
|
+
if (theme === 'dark') {
|
|
15
|
+
root.classList.add('dark');
|
|
16
|
+
} else {
|
|
17
|
+
root.classList.remove('dark');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useMarketingTheme() {
|
|
22
|
+
const { user, updateUser } = useAuth();
|
|
23
|
+
const [isDark, setIsDark] = useState(getIsDark);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const root = document.documentElement;
|
|
27
|
+
const observer = new MutationObserver(() => setIsDark(getIsDark()));
|
|
28
|
+
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
|
|
29
|
+
return () => observer.disconnect();
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
function setTheme(theme: 'light' | 'dark') {
|
|
33
|
+
if (user) {
|
|
34
|
+
updateUser({ theme });
|
|
35
|
+
} else {
|
|
36
|
+
applyThemeToDocument(theme);
|
|
37
|
+
setIsDark(theme === 'dark');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toggleTheme() {
|
|
42
|
+
const nextTheme: 'light' | 'dark' = isDark ? 'light' : 'dark';
|
|
43
|
+
setTheme(nextTheme);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { isDark, setTheme, toggleTheme };
|
|
47
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import i18n from 'i18next';
|
|
2
|
+
import { initReactI18next } from 'react-i18next';
|
|
3
|
+
import LanguageDetector from 'i18next-browser-languagedetector';
|
|
4
|
+
import enTranslations from '@/locales/en.json';
|
|
5
|
+
import deTranslations from '@/locales/de.json';
|
|
6
|
+
import frTranslations from '@/locales/fr.json';
|
|
7
|
+
import esTranslations from '@/locales/es.json';
|
|
8
|
+
import itTranslations from '@/locales/it.json';
|
|
9
|
+
import ptTranslations from '@/locales/pt.json';
|
|
10
|
+
import nlTranslations from '@/locales/nl.json';
|
|
11
|
+
import ruTranslations from '@/locales/ru.json';
|
|
12
|
+
import jaTranslations from '@/locales/ja.json';
|
|
13
|
+
import zhTranslations from '@/locales/zh.json';
|
|
14
|
+
import plTranslations from '@/locales/pl.json';
|
|
15
|
+
|
|
16
|
+
i18n
|
|
17
|
+
.use(LanguageDetector)
|
|
18
|
+
.use(initReactI18next)
|
|
19
|
+
.init({
|
|
20
|
+
resources: {
|
|
21
|
+
en: { translation: enTranslations },
|
|
22
|
+
de: { translation: deTranslations },
|
|
23
|
+
fr: { translation: frTranslations },
|
|
24
|
+
es: { translation: esTranslations },
|
|
25
|
+
it: { translation: itTranslations },
|
|
26
|
+
pt: { translation: ptTranslations },
|
|
27
|
+
nl: { translation: nlTranslations },
|
|
28
|
+
ru: { translation: ruTranslations },
|
|
29
|
+
ja: { translation: jaTranslations },
|
|
30
|
+
zh: { translation: zhTranslations },
|
|
31
|
+
pl: { translation: plTranslations },
|
|
32
|
+
},
|
|
33
|
+
fallbackLng: 'en',
|
|
34
|
+
interpolation: {
|
|
35
|
+
escapeValue: false,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export default i18n;
|