Merge branch 'develop'

This commit is contained in:
Arthur Belleville 2025-07-19 15:18:49 +02:00
commit 88b3dcb4d1
No known key found for this signature in database
54 changed files with 6182 additions and 1925 deletions

2
.gitignore vendored
View file

@ -7,7 +7,7 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
*node_modules
dist-ssr
*.local

View file

@ -7,6 +7,11 @@ export type Json =
| Json[]
export type Database = {
// Allows to automatically instanciate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "12.2.3 (519615d)"
}
public: {
Tables: {
devis: {
@ -367,21 +372,25 @@ export type Database = {
}
}
type DefaultSchema = Database[Extract<keyof Database, "public">]
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
@ -399,14 +408,16 @@ export type Tables<
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
@ -422,14 +433,16 @@ export type TablesInsert<
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
@ -445,14 +458,16 @@ export type TablesUpdate<
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
@ -460,14 +475,16 @@ export type Enums<
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never

View file

@ -20,4 +20,13 @@ dev:
just _api-dev & (just _frontend-dev)
update-types:
npx supabase gen types typescript --project-id "mhcafqvzbrrwvahpvvzd" --schema public > ui/src/types/database.types.ts && cp ui/src/types/database.types.ts api/src/database.types.ts
npx supabase gen types typescript --project-id "mhcafqvzbrrwvahpvvzd" --schema public > ui/src/types/database.types.ts && cp ui/src/types/database.types.ts api/src/database.types.ts && cp ui/src/types/database.types.ts xtablo-expo/lib/database.types.ts
expo-install-all:
cd xtablo-expo && npx expo install -- --legacy-peer-deps
expo-install +package:
cd xtablo-expo && npx expo install {{package}} -- --legacy-peer-deps
expo-start *args:
cd xtablo-expo && npx expo start {{args}}

View file

@ -11,7 +11,7 @@
"build:staging": "tsc -b && vite build --mode staging",
"build:prod": "tsc -b && vite build --mode production",
"deploy:staging": "pnpm run build:staging && wrangler deploy",
"deploy:prod": "pnpm run build:prod && wrangler deploy",
"deploy:prod": "pnpm run build:prod && wrangler deploy",
"cf-typegen": "wrangler types",
"test": "vitest run --mode dev",
"test:watch": "vitest watch",
@ -61,6 +61,8 @@
"wrangler": "^4.24.3"
},
"dependencies": {
"@datadog/browser-rum": "^6.13.0",
"@datadog/browser-rum-react": "^6.13.0",
"@react-stately/calendar": "^3.7.1",
"@supabase/supabase-js": "^2.49.3",
"@tailwindcss/vite": "^4.0.14",

View file

@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@datadog/browser-rum':
specifier: ^6.13.0
version: 6.13.0
'@datadog/browser-rum-react':
specifier: ^6.13.0
version: 6.13.0(@datadog/browser-rum@6.13.0)(react-router-dom@7.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
'@react-stately/calendar':
specifier: ^3.7.1
version: 3.7.1(react@19.0.0)
@ -430,6 +436,37 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@datadog/browser-core@6.13.0':
resolution: {integrity: sha512-wWhQ22F8pDjh7kGtvBetJhX5luyKsIVzJQTisjM/6p+A2nchDuoOiZ0GdjajRA5LlPa9pj/PlOoe/g+B9AMELg==}
'@datadog/browser-rum-core@6.13.0':
resolution: {integrity: sha512-+gp9MEDc24xB5dwvVYsOPoRueujTOy0GNW2Q3XNyWMstzuSQIwXaIuDIRVhCoi8+i4UioUCd7ZO30yEB01Ajwg==}
'@datadog/browser-rum-react@6.13.0':
resolution: {integrity: sha512-r53c4soTqiAVK96raOQVaci163lhX6/DVXXGZteHW36q+OMTbIIEaz/jyNX+4JrXORFNrz8b1dWELt49CkDX5w==}
peerDependencies:
'@datadog/browser-rum': '*'
'@datadog/browser-rum-slim': '*'
react: 18 || 19
react-router-dom: 6 || 7
peerDependenciesMeta:
'@datadog/browser-rum':
optional: true
'@datadog/browser-rum-slim':
optional: true
react:
optional: true
react-router-dom:
optional: true
'@datadog/browser-rum@6.13.0':
resolution: {integrity: sha512-VIVpoD+A2WLIskB0PXzO0Gh3bZs7MkOrzhnDpamWMrcbGH/UaX6BUmTlorqtT374Jrre6frzN2IEKGQs7kVamQ==}
peerDependencies:
'@datadog/browser-logs': 6.13.0
peerDependenciesMeta:
'@datadog/browser-logs':
optional: true
'@emnapi/runtime@1.4.4':
resolution: {integrity: sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==}
@ -5528,6 +5565,26 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@datadog/browser-core@6.13.0': {}
'@datadog/browser-rum-core@6.13.0':
dependencies:
'@datadog/browser-core': 6.13.0
'@datadog/browser-rum-react@6.13.0(@datadog/browser-rum@6.13.0)(react-router-dom@7.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)':
dependencies:
'@datadog/browser-core': 6.13.0
'@datadog/browser-rum-core': 6.13.0
optionalDependencies:
'@datadog/browser-rum': 6.13.0
react: 19.0.0
react-router-dom: 7.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@datadog/browser-rum@6.13.0':
dependencies:
'@datadog/browser-core': 6.13.0
'@datadog/browser-rum-core': 6.13.0
'@emnapi/runtime@1.4.4':
dependencies:
tslib: 2.8.1

View file

@ -1,116 +1,49 @@
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import { LoginPage } from "./pages/login";
import { SignUpPage } from "./pages/signup";
import { ThemeProvider } from "./contexts/ThemeContext";
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
import { ThemeProvider } from "@ui/contexts/ThemeContext";
import { twMerge } from "tailwind-merge";
import { ResetPasswordPage } from "./pages/reset-password";
import { LandingPage } from "./pages/landing";
import { PublicRoute } from "./components/PublicRoute";
import { TabloPage } from "./pages/tablo";
import { SessionProvider } from "./contexts/SessionContext";
import { OAuthSigninPage } from "./pages/oauth-signin";
import { NotFoundPage } from "./pages/NotFoundPage";
import { Layout } from "./components/Layout";
import { DevisPage } from "./pages/devis";
import { FacturesPage } from "./pages/factures";
import { PlanningPage } from "./pages/planning";
import { ChantiersPage } from "./pages/chantiers";
import { ChatPage } from "./pages/chat";
import { FeedbackPage } from "./pages/feedback";
import { SupportPage } from "./pages/support";
import { SessionProvider } from "@ui/contexts/SessionContext";
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
import ChatProvider from "./providers/ChatProvider";
import { UserStoreProvider } from "./providers/UserStoreProvider";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { JoinPage } from "@ui/pages/join";
import { CreateEventModal } from "./components/CreateEventModal";
import { isProd } from "./utils/helpers";
import { UserStoreProvider } from "@ui/providers/UserStoreProvider";
import { isProd } from "@ui/utils/helpers";
import { DatadogRumProvider } from "@ui/providers/DatadogRumProvider";
import { routes } from "@ui/lib/routes";
// Register all Community features
ModuleRegistry.registerModules([AllCommunityModule]);
const AppRoutes = () => {
const element = useRoutes(routes);
return element;
};
export const App = () => {
return (
<ThemeProvider>
<SessionProvider>
<UserStoreProvider>
<Router>
<div
className={twMerge(
"min-h-screen",
!isProd ? "bg-orange-50" : "bg-white",
"dark:bg-gray-900"
)}
>
<Routes>
<Route path="/" element={<ProtectedRoute fallback="/login" />}>
<Route element={<Layout />}>
<Route index element={<TabloPage />} />
<Route path="tablo" element={<TabloPage />} />
<Route path="devis" element={<DevisPage />} />
<Route path="factures" element={<FacturesPage />} />
<Route path="planning" element={<PlanningPage />}>
<Route index />
<Route path=":tablo_id" />
<Route path="create" element={<CreateEventModal />} />
</Route>
<Route path="kanban" element={<NotFoundPage />}>
<Route index />
<Route path=":tablo_id" />
</Route>
<Route path="chantiers" element={<ChantiersPage />} />
<Route
path="chat"
element={
<ChatProvider>
{(client) => <ChatPage client={client} />}
</ChatProvider>
}
>
<Route index />
<Route path=":channelId" />
</Route>
<Route path="feedback" element={<FeedbackPage />} />
<Route path="support" element={<SupportPage />} />
</Route>
</Route>
<Route
element={
<ProtectedRoute
fallback="/login"
shouldRedirectToCurrentPage
/>
<DatadogRumProvider>
<div
className={twMerge(
"min-h-screen",
!isProd ? "bg-orange-50" : "bg-white",
"dark:bg-gray-900"
)}
>
<AppRoutes />
<style>
{`
@keyframes slide {
0% { transform: translateX(-100vw); }
100% { transform: translateX(100vw); }
}
>
<Route element={<Layout />}>
<Route path="join/:tablo_name" element={<JoinPage />} />
</Route>
</Route>
<Route path="login-with-oauth" element={<OAuthSigninPage />} />
<Route path="landing" element={<LandingPage />} />
<Route element={<PublicRoute />}>
<Route path="login" element={<LoginPage />} />
<Route path="signup" element={<SignUpPage />} />
<Route
path="reset-password"
element={<ResetPasswordPage />}
/>
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
<style>
{`
@keyframes slide {
0% { transform: translateX(-100vw); }
100% { transform: translateX(100vw); }
}
.animate-slide {
animation: slide 24s linear infinite;
}
`}
</style>
</div>
.animate-slide {
animation: slide 24s linear infinite;
}
`}
</style>
</div>
</DatadogRumProvider>
</Router>
</UserStoreProvider>
</SessionProvider>

139
ui/src/lib/routes.tsx Normal file
View file

@ -0,0 +1,139 @@
import { RouteObject } from "react-router-dom";
import { ProtectedRoute } from "@ui/components/ProtectedRoute";
import { Layout } from "@ui/components/Layout";
import { TabloPage } from "@ui/pages/tablo";
import { DevisPage } from "@ui/pages/devis";
import { FacturesPage } from "@ui/pages/factures";
import { PlanningPage } from "@ui/pages/planning";
import { NotFoundPage } from "@ui/pages/NotFoundPage";
import { JoinPage } from "@ui/pages/join";
import { OAuthSigninPage } from "@ui/pages/oauth-signin";
import { LandingPage } from "@ui/pages/landing";
import { LoginPage } from "@ui/pages/login";
import { SignUpPage } from "@ui/pages/signup";
import { ResetPasswordPage } from "@ui/pages/reset-password";
import { PublicRoute } from "@ui/components/PublicRoute";
import ChatProvider from "@ui/providers/ChatProvider";
import { CreateEventModal } from "@ui/components/CreateEventModal";
import { ChantiersPage } from "@ui/pages/chantiers";
import { ChatPage } from "@ui/pages/chat";
import { FeedbackPage } from "@ui/pages/feedback";
import { SupportPage } from "@ui/pages/support";
export const routes: RouteObject[] = [
// Protected routes
{
path: "/",
element: <ProtectedRoute fallback="/login" />,
children: [
{
path: "",
element: <Layout />,
children: [
{
index: true,
element: <TabloPage />,
},
{
path: "tablo",
element: <TabloPage />,
},
{
path: "devis",
element: <DevisPage />,
},
{
path: "factures",
element: <FacturesPage />,
},
{
path: "planning",
element: <PlanningPage />,
children: [
{ index: true },
{ path: ":tablo_id" },
{ path: "create", element: <CreateEventModal /> },
],
},
{
path: "kanban",
element: <NotFoundPage />,
children: [{ index: true }, { path: ":tablo_id" }],
},
{
path: "chantiers",
element: <ChantiersPage />,
},
{
path: "chat",
element: (
<ChatProvider>
<ChatPage />
</ChatProvider>
),
children: [{ index: true }, { path: ":channelId" }],
},
{
path: "feedback",
element: <FeedbackPage />,
},
{
path: "support",
element: <SupportPage />,
},
],
},
],
},
// Protected routes with redirect to current page
{
path: "/join/:tablo_name",
element: <ProtectedRoute fallback="/login" shouldRedirectToCurrentPage />,
children: [
{
path: "",
element: <Layout />,
children: [
{
index: true,
element: <JoinPage />,
},
],
},
],
},
// OAuth signin route
{
path: "/login-with-oauth",
element: <OAuthSigninPage />,
},
// Landing page
{
path: "/landing",
element: <LandingPage />,
},
// Public routes (authentication pages)
{
path: "/",
element: <PublicRoute />,
children: [
{
path: "login",
element: <LoginPage />,
},
{
path: "signup",
element: <SignUpPage />,
},
{
path: "reset-password",
element: <ResetPasswordPage />,
},
],
},
// Catch-all route for 404
{
path: "*",
element: <NotFoundPage />,
},
];

21
ui/src/lib/rum.ts Normal file
View file

@ -0,0 +1,21 @@
import { datadogRum } from "@datadog/browser-rum";
import { reactPlugin } from "@datadog/browser-rum-react";
export const initRum = () => {
datadogRum.init({
applicationId: "8e268e1a-1be0-44c6-b12a-978530d497c7",
clientToken: "pub1761af09ab04e215cc90d34da6ac576b",
site: "datadoghq.com",
service: "xtablo-ui",
env: import.meta.env.MODE,
// Specify a version number to identify the deployed version of your application in Datadog
// version: '1.0.0',
sessionSampleRate: 100,
sessionReplaySampleRate: 80,
defaultPrivacyLevel: "mask-user-input",
plugins: [reactPlugin({ router: true })],
trackViewsManually: true,
startSessionReplayRecordingManually: false,
});
};

View file

@ -8,20 +8,18 @@ import {
} from "stream-chat-react";
import { useUser } from "@ui/providers/UserStoreProvider";
import { ChannelPreview } from "@ui/components/ChannelPreview";
import { StreamChat } from "stream-chat";
import { useChannelFromUrl } from "@ui/hooks/channel";
import { useTablosList } from "@ui/hooks/tablos";
import { CustomChannelHeader } from "@ui/components/CustomChannelHeader";
import { useEffect, useState } from "react";
export function ChatPage({ client }: { client: StreamChat }) {
export function ChatPage() {
const user = useUser();
const filters = { members: { $in: [user.id] }, type: "messaging" };
const { client, channel, setActiveChannel } = useChatContext();
const { channel: channelFromUrl, isChannelInUrl } = useChannelFromUrl(client);
const { channel, setActiveChannel } = useChatContext();
const { data: tablos } = useTablosList();
const [isChannelListExpanded, setIsChannelListExpanded] = useState(false);

View file

@ -39,6 +39,28 @@ export function LoginPage() {
)}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6">
<Link
to="/landing"
className="inline-flex items-center text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors"
>
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Retour à l&apos;accueil
</Link>
</div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-8 text-center">
Se connecter
</h1>

View file

@ -86,6 +86,28 @@ export function SignUpPage() {
)}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6">
<Link
to="/landing"
className="inline-flex items-center text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors"
>
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Retour à l&apos;accueil
</Link>
</div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-8 text-center">
Créer un compte
</h1>

View file

@ -1,12 +1,11 @@
import { Chat, useCreateChatClient } from "stream-chat-react";
import { useUser } from "./UserStoreProvider";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { StreamChat } from "stream-chat";
export default function ChatProvider({
children,
}: {
children: (client: StreamChat) => React.ReactNode;
children: React.ReactNode;
}) {
const apiKey = import.meta.env.VITE_STREAM_CHAT_API_KEY as string;
const user = useUser();
@ -42,7 +41,7 @@ export default function ChatProvider({
return (
<Chat client={client} theme="team light">
{children(client)}
{children}
</Chat>
);
}

View file

@ -0,0 +1,47 @@
import { datadogRum } from "@datadog/browser-rum";
import { routes } from "@ui/lib/routes";
import { initRum } from "@ui/lib/rum";
import { useEffect } from "react";
import { matchRoutes, RouteMatch, useLocation } from "react-router-dom";
function computeViewName(routeMatches: RouteMatch[]) {
let viewName = "";
for (let index = 0; index < routeMatches.length; index++) {
const routeMatch = routeMatches[index];
const path = routeMatch.route.path;
// Skip pathless routes
if (!path) {
continue;
}
if (path.startsWith("/")) {
// Handle absolute child route paths
viewName = path;
} else {
// Handle route paths ending with "/"
viewName += viewName.endsWith("/") ? path : `/${path}`;
}
}
return viewName || "/";
}
export const DatadogRumProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const location = useLocation();
useEffect(() => {
initRum();
}, []);
useEffect(() => {
const routeMatches = matchRoutes(routes, location.pathname);
const viewName = routeMatches && computeViewName(routeMatches);
if (viewName) {
datadogRum.startView({ name: viewName });
}
}, [location.pathname]);
return <>{children}</>;
};

View file

@ -7,6 +7,11 @@ export type Json =
| Json[]
export type Database = {
// Allows to automatically instanciate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "12.2.3 (519615d)"
}
public: {
Tables: {
devis: {
@ -367,21 +372,25 @@ export type Database = {
}
}
type DefaultSchema = Database[Extract<keyof Database, "public">]
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
@ -399,14 +408,16 @@ export type Tables<
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
@ -422,14 +433,16 @@ export type TablesInsert<
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
@ -445,14 +458,16 @@ export type TablesUpdate<
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
@ -460,14 +475,16 @@ export type Enums<
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never

View file

@ -1,3 +1,6 @@
EXPO_PUBLIC_STREAM_CHAT_API_KEY="t5vvvddteapa"
EXPO_PUBLIC_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM
EXPO_PUBLIC_API_URL=http://192.168.1.110:8080

View file

@ -36,3 +36,5 @@ yarn-error.*
*.tsbuildinfo
app-example
ios

View file

@ -1,21 +1,26 @@
{
"expo": {
"name": "xtablo-expo",
"slug": "xtablo-expo",
"name": "xtablo",
"slug": "xtablo",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"scheme": "com.xtablo.app",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "com.xtablo.app",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"package": "com.xtablo.app"
},
"web": {
"bundler": "metro",
@ -32,10 +37,17 @@
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
]
],
"expo-secure-store"
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {},
"eas": {
"projectId": "a3acc825-7c18-4cd4-83c7-60836639decc"
}
}
}
}

View file

@ -1,26 +1,25 @@
import { Redirect, Slot, Stack } from "expo-router";
import { Redirect, Slot } from "expo-router";
import { useAuth } from "@/stores/auth";
import { ActivityIndicator, AppState } from "react-native";
import { supabase } from "@/lib/supabase";
// Tells Supabase Auth to continuously refresh the session automatically if
// the app is in the foreground. When this is added, you will continue to receive
// `onAuthStateChange` events with the `TOKEN_REFRESHED` or `SIGNED_OUT` event
// if the user's session is terminated. This should only be registered once.
AppState.addEventListener("change", (state) => {
if (state === "active") {
supabase.auth.startAutoRefresh();
} else {
supabase.auth.stopAutoRefresh();
}
});
import { ActivityIndicator } from "react-native";
import { useGetUser } from "@/hooks/user";
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
export default function AuthLayout() {
const { session, loading } = useAuth();
if (loading) {
const { loading, initialize } = useAuth();
const queryClient = useQueryClient();
const { user, isLoading: isUserLoading } = useGetUser();
const isLoading = loading || isUserLoading;
useEffect(() => {
initialize(queryClient);
}, []);
if (isLoading) {
return <ActivityIndicator />;
}
if (session) {
if (user) {
return <Redirect href="/(home)/(tabs)" />;
}
return <Slot />;

View file

@ -1,8 +1,11 @@
import React, { useState } from "react";
import { StyleSheet, View, Text } from "react-native";
import { StyleSheet, View, Text, Image } from "react-native";
import { Button, Input } from "@rn-vui/themed";
import { useAuth } from "@/stores/auth";
import { Link } from "expo-router";
import { Mail, Lock } from "lucide-react-native";
import { GoogleLoginButton } from "@/components/GoogleLoginButton";
import { AppleLoginButton } from "@/components/AppleLoginButton";
export default function Auth() {
const [email, setEmail] = useState("");
@ -10,45 +13,58 @@ export default function Auth() {
const login = useAuth((state) => state.login);
const authLoading = useAuth((state) => state.loading);
const performOAuth = useAuth((state) => state.performOAuth);
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome Back!</Text>
<Text style={styles.subtitle}>Sign in to your account</Text>
<View style={[styles.verticallySpaced, styles.mt40]}>
<Image source={require("@/assets/images/logo.png")} style={styles.logo} />
<Text style={styles.title}>Connexion XTablo</Text>
<Text style={styles.subtitle}>Connectez-vous à votre compte</Text>
<View style={[styles.verticallySpaced, styles.mt10]}>
<Input
label="Email"
leftIcon={{ type: "font-awesome", name: "envelope" }}
label="Adresse email"
leftIcon={<Mail size={20} color="#666" />}
onChangeText={(text) => setEmail(text)}
value={email}
placeholder="email@address.com"
placeholder="jean@dupont.com"
autoCapitalize={"none"}
/>
</View>
<View style={styles.verticallySpaced}>
<Input
label="Password"
leftIcon={{ type: "font-awesome", name: "lock" }}
label="Mot de passe"
leftIcon={<Lock size={20} color="#666" />}
onChangeText={(text) => setPassword(text)}
value={password}
secureTextEntry={true}
placeholder="Password"
placeholder="Mot de passe"
autoCapitalize={"none"}
/>
</View>
<View style={[styles.verticallySpaced, styles.mt20]}>
<Button
title="Sign in"
title="Se connecter"
disabled={authLoading}
onPress={() => login(email, password)}
buttonStyle={styles.button}
titleStyle={styles.buttonTitle}
/>
</View>
<View style={styles.separatorContainer}>
<View style={styles.separator} />
<Text style={styles.separatorText}>ou</Text>
<View style={styles.separator} />
</View>
<View style={styles.verticallySpaced}>
<GoogleLoginButton onPress={() => performOAuth("google")} />
</View>
<View style={[styles.verticallySpaced, styles.mt5]}>
<AppleLoginButton onPress={() => performOAuth("apple")} />
</View>
<View style={styles.linkContainer}>
<Text style={styles.linkText}>Don't have an account? </Text>
<Text style={styles.linkText}>Pas encore de compte ? </Text>
<Link href="/signup" style={styles.link}>
Sign Up
S'inscrire
</Link>
</View>
</View>
@ -59,33 +75,46 @@ const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
padding: 24,
padding: 16,
backgroundColor: "#f5f5f5", // Light grey background
},
logo: {
width: 80,
height: 80,
alignSelf: "center",
marginBottom: 8,
borderRadius: 40,
},
title: {
fontSize: 32,
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 10,
marginBottom: 3,
color: "#333",
},
subtitle: {
fontSize: 16,
fontSize: 14,
textAlign: "center",
marginBottom: 40,
marginBottom: 16,
color: "#666",
},
verticallySpaced: {
paddingTop: 8, // Increased padding
paddingBottom: 8, // Increased padding
paddingTop: 2,
paddingBottom: 2,
alignSelf: "stretch",
},
mt20: {
marginTop: 20,
marginTop: 12,
},
mt40: {
marginTop: 40, // Increased top margin for the first input
},
mt10: {
marginTop: 6,
},
mt5: {
marginTop: 3,
},
button: {
paddingVertical: 12,
borderRadius: 8, // Rounded corners
@ -95,10 +124,25 @@ const styles = StyleSheet.create({
fontWeight: "bold",
color: "#fff", // Ensure text is visible on colored button
},
separatorContainer: {
flexDirection: "row",
alignItems: "center",
marginVertical: 12,
},
separator: {
flex: 1,
height: 1,
backgroundColor: "#ddd",
},
separatorText: {
marginHorizontal: 15,
color: "#666",
fontSize: 14,
},
linkContainer: {
flexDirection: "row",
justifyContent: "center",
marginTop: 20,
marginTop: 12,
},
linkText: {
color: "#666",

View file

@ -1,10 +1,14 @@
import React, { useState } from "react";
import { StyleSheet, View, Text } from "react-native";
import { StyleSheet, View, Text, Image } from "react-native";
import { Button, Input } from "@rn-vui/themed";
import { useAuth } from "@/stores/auth";
import { Link } from "expo-router";
import { Mail, Lock, User, Building2 } from "lucide-react-native";
export default function SignUp() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [companyName, setCompanyName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@ -13,42 +17,76 @@ export default function SignUp() {
return (
<View style={styles.container}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join us!</Text>
<View style={[styles.verticallySpaced, styles.mt40]}>
<Image source={require("@/assets/images/logo.png")} style={styles.logo} />
<Text style={styles.title}>Créer un compte XTablo</Text>
<Text style={styles.subtitle}>Rejoignez-nous !</Text>
<View style={[styles.verticallySpaced, styles.mt10]}>
<Input
label="Email"
leftIcon={{ type: "font-awesome", name: "envelope" }}
onChangeText={(text) => setEmail(text)}
value={email}
placeholder="email@address.com"
autoCapitalize={"none"}
label="Prénom"
leftIcon={<User size={20} color="#666" />}
onChangeText={(text) => setFirstName(text)}
value={firstName}
placeholder="Jean"
autoCapitalize={"words"}
/>
</View>
<View style={styles.verticallySpaced}>
<Input
label="Password"
leftIcon={{ type: "font-awesome", name: "lock" }}
label="Nom"
leftIcon={<User size={20} color="#666" />}
onChangeText={(text) => setLastName(text)}
value={lastName}
placeholder="Dupont"
autoCapitalize="words"
/>
</View>
<View style={styles.verticallySpaced}>
<Input
label="Nom de l'entreprise"
leftIcon={<Building2 size={20} color="#666" />}
onChangeText={(text) => setCompanyName(text)}
value={companyName}
placeholder="Mon Entreprise"
autoCapitalize="words"
/>
</View>
<View style={styles.verticallySpaced}>
<Input
label="Adresse email"
leftIcon={<Mail size={20} color="#666" />}
onChangeText={(text) => setEmail(text)}
value={email}
placeholder="jean@dupont.com"
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
<View style={styles.verticallySpaced}>
<Input
label="Mot de passe"
leftIcon={<Lock size={20} color="#666" />}
onChangeText={(text) => setPassword(text)}
value={password}
secureTextEntry={true}
placeholder="Password"
autoCapitalize={"none"}
secureTextEntry
placeholder="Mot de passe"
autoCapitalize="none"
/>
</View>
<View style={[styles.verticallySpaced, styles.mt20]}>
<Button
title="Sign up"
title="S'inscrire"
disabled={authLoading}
onPress={() => signUp(email, password)}
onPress={() =>
signUp(email, password, firstName, lastName, companyName)
}
buttonStyle={styles.button}
titleStyle={styles.buttonTitle}
/>
</View>
<View style={styles.linkContainer}>
<Text style={styles.linkText}>Already have an account? </Text>
<Text style={styles.linkText}>Vous avez déjà un compte ? </Text>
<Link href="/login" style={styles.link}>
Sign In
Se connecter
</Link>
</View>
</View>
@ -60,33 +98,46 @@ const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
padding: 24,
padding: 16,
backgroundColor: "#f5f5f5", // Light grey background
},
logo: {
width: 80,
height: 80,
alignSelf: "center",
marginBottom: 8,
borderRadius: 40,
},
title: {
fontSize: 32,
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 10,
marginBottom: 3,
color: "#333",
},
subtitle: {
fontSize: 16,
fontSize: 14,
textAlign: "center",
marginBottom: 40,
marginBottom: 16,
color: "#666",
},
verticallySpaced: {
paddingTop: 8,
paddingBottom: 8,
paddingTop: 2,
paddingBottom: 2,
alignSelf: "stretch",
},
mt20: {
marginTop: 20,
marginTop: 12,
},
mt40: {
marginTop: 40,
},
mt10: {
marginTop: 6,
},
mt5: {
marginTop: 3,
},
button: {
paddingVertical: 12,
borderRadius: 8,
@ -96,10 +147,25 @@ const styles = StyleSheet.create({
fontWeight: "bold",
color: "#fff",
},
separatorContainer: {
flexDirection: "row",
alignItems: "center",
marginVertical: 12,
},
separator: {
flex: 1,
height: 1,
backgroundColor: "#ddd",
},
separatorText: {
marginHorizontal: 15,
color: "#666",
fontSize: 14,
},
linkContainer: {
flexDirection: "row",
justifyContent: "center",
marginTop: 20,
marginTop: 12,
},
linkText: {
color: "#666",

View file

@ -1,41 +1,111 @@
import { Tabs } from "expo-router";
import React from "react";
import { Platform } from "react-native";
import { HapticTab } from "@/components/HapticTab";
import { IconSymbol } from "@/components/ui/IconSymbol";
import TabBarBackground from "@/components/ui/TabBarBackground";
import { Colors } from "@/constants/colors";
import { useColorScheme } from "@/hooks/useColorScheme";
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
import {
MessageCircle,
Calendar,
List,
Home,
Grid3X3,
} from "lucide-react-native";
export default function TabLayout() {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
headerShown: true,
// Colors and theming
tabBarActiveTintColor: "#3b82f6", // Modern blue color
tabBarInactiveTintColor: isDark ? "#9CA3AF" : "#6B7280",
headerShown: false,
tabBarButton: HapticTab,
tabBarBackground: TabBarBackground,
// Enhanced styling
tabBarStyle: {
backgroundColor: isDark ? "#1F2937" : "#FFFFFF",
borderTopWidth: 0,
elevation: 20,
shadowColor: "#000000",
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 20,
height: Platform.OS === "ios" ? 88 : 70,
paddingTop: 8,
paddingBottom: Platform.OS === "ios" ? 16 : 8,
paddingHorizontal: 12,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
position: "absolute",
overflow: "hidden",
},
// Label styling
tabBarLabelStyle: {
fontSize: 12,
fontWeight: "600",
marginTop: 4,
marginBottom: Platform.OS === "ios" ? 0 : 4,
},
// Icon styling
tabBarIconStyle: {
marginTop: 2,
},
// Animation and interaction
tabBarHideOnKeyboard: true,
tabBarAllowFontScaling: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ size, color }) => (
<IconSymbol size={size} name="house.fill" color={color} />
title: "Discussions",
tabBarIcon: ({ focused, color, size }) => (
<MessageCircle
size={focused ? 24 : 22}
color={color}
strokeWidth={focused ? 2.2 : 2}
/>
),
tabBarLabel: "Discussions",
}}
/>
<Tabs.Screen
name="profile"
name="planning"
options={{
title: "Profile",
tabBarIcon: ({ size, color }) => (
<MaterialCommunityIcons name="account" size={size} color={color} />
title: "Planning",
tabBarIcon: ({ focused, color, size }) => (
<Calendar
size={focused ? 24 : 22}
color={color}
strokeWidth={focused ? 2.2 : 2}
/>
),
tabBarLabel: "Planning",
}}
/>
<Tabs.Screen
name="tablos"
options={{
title: "Tablos",
tabBarIcon: ({ focused, color, size }) => (
<Grid3X3
size={focused ? 24 : 22}
color={color}
strokeWidth={focused ? 2.2 : 2}
/>
),
tabBarLabel: "Tablos",
// Optional: Add a badge for notifications
tabBarBadge: undefined, // You can set this to a number for notifications
}}
/>
</Tabs>

View file

@ -1,12 +1,396 @@
import { router } from "expo-router";
import { ChannelList } from "stream-chat-expo";
import { ChannelSort } from "stream-chat";
import { useUser } from "@/providers/UserProvider";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
StatusBar,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { Search } from "lucide-react-native";
import React from "react";
import { useTablosList } from "@/hooks/tablos";
import { ColorMap } from "@/constants/colors";
import { UserTablo } from "@/types/tablos.types";
// Custom Avatar Component for Channel List
const CustomChannelAvatar = ({
channel,
tablos,
}: {
channel: any;
tablos: UserTablo[];
}) => {
const tabloId = channel?.id || "";
const tablo = tablos?.find((t) => t.id === tabloId);
const tabloColor = tablo?.color || "bg-blue-500";
const tabloName = tablo?.name || channel?.data?.name || "Tablo";
// Get members info
const members = channel?.state?.members || {};
const memberCount = Object.keys(members).length;
// Generate initials from tablo name
const getInitials = (name: string) => {
return name
.split(" ")
.map((word) => word.charAt(0))
.join("")
.toUpperCase()
.slice(0, 2);
};
// // Create gradient colors based on tablo color
const getTabloGradientColors = (colorKey: string): [string, string] => {
const baseColor = ColorMap[colorKey] || ColorMap["bg-blue-500"];
// Create a lighter version for gradient effect
const lightenColor = (hex: string, percent: number): string => {
const num = parseInt(hex.replace("#", ""), 16);
const amt = Math.round(2.55 * percent);
const R = Math.min(255, Math.max(0, (num >> 16) + amt));
const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt));
const B = Math.min(255, Math.max(0, (num & 0x0000ff) + amt));
return "#" + ((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1);
};
// Create a darker version for gradient effect
const darkenColor = (hex: string, percent: number): string => {
const num = parseInt(hex.replace("#", ""), 16);
const amt = Math.round(2.55 * percent);
const R = Math.min(255, Math.max(0, (num >> 16) - amt));
const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) - amt));
const B = Math.min(255, Math.max(0, (num & 0x0000ff) - amt));
return "#" + ((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1);
};
const lightColor = lightenColor(baseColor, 15);
const darkColor = darkenColor(baseColor, 10);
return [lightColor, darkColor];
};
const initials = getInitials(tabloName);
const gradientColors = getTabloGradientColors(tabloColor);
return (
<View style={styles.avatarContainer}>
<LinearGradient
colors={gradientColors}
style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.avatarInitials}>{initials}</Text>
{/* Member count indicator for group channels */}
{memberCount > 2 && (
<View style={styles.memberCountBadge}>
<Text style={styles.memberCountText}>{memberCount}</Text>
</View>
)}
</LinearGradient>
{/* Decorative ring */}
<View
style={[
styles.avatarRing,
{ borderColor: `${ColorMap[tabloColor]}30` },
]}
/>
{/* Status indicator (online/active) */}
<View style={styles.statusIndicator} />
</View>
);
};
export default function HomeScreen() {
const user = useUser();
const { data: tablos } = useTablosList();
const filters = {
members: { $in: [user.id] },
type: "messaging",
};
const sort: ChannelSort = { last_updated: -1 };
const options = {
state: true,
watch: true,
};
// Create a wrapper component for the avatar that has access to tablos data
const AvatarWithTablos = ({ channel }: { channel: any }) => (
<CustomChannelAvatar channel={channel} tablos={tablos || []} />
);
return (
<ChannelList
onSelect={(channel) => {
router.push(`/channel/${channel.cid}`);
}}
/>
<View style={styles.container}>
<StatusBar barStyle="light-content" />
{/* Beautiful Header */}
<LinearGradient
colors={["#1e3a8a", "#3b82f6", "#60a5fa"]}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.headerContent}>
<View style={styles.headerBottom}>
<View style={styles.titleContainer}>
<Text style={styles.headerTitle}>Discussions</Text>
<Text style={styles.headerSubtitle}>
Gérez les conversations de vos tablos
</Text>
</View>
<TouchableOpacity style={styles.searchButton}>
<Search size={20} color="#3b82f6" />
</TouchableOpacity>
</View>
</View>
{/* Decorative Elements
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} /> */}
</LinearGradient>
{/* Channel List */}
<View style={styles.channelListContainer}>
<ChannelList
filters={filters}
onSelect={(channel) => {
router.push(`/channel/${channel.cid}`);
}}
sort={sort}
options={options}
PreviewAvatar={AvatarWithTablos}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f8fafc",
},
headerGradient: {
paddingTop: 50,
paddingBottom: 25,
paddingHorizontal: 20,
position: "relative",
overflow: "hidden",
},
headerContent: {
zIndex: 10,
},
headerTop: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 20,
},
userInfo: {
flexDirection: "row",
alignItems: "center",
flex: 1,
},
avatar: {
marginRight: 12,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 5,
borderWidth: 3,
borderColor: "rgba(255, 255, 255, 0.3)",
},
greetingContainer: {
flex: 1,
},
greeting: {
fontSize: 16,
color: "rgba(255, 255, 255, 0.9)",
fontWeight: "500",
},
userName: {
fontSize: 20,
color: "white",
fontWeight: "bold",
marginTop: 2,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: 15,
},
actionButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "rgba(255, 255, 255, 0.2)",
justifyContent: "center",
alignItems: "center",
position: "relative",
},
notificationBadge: {
position: "absolute",
top: -2,
right: -2,
backgroundColor: "#ef4444",
borderRadius: 10,
minWidth: 20,
height: 20,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "white",
},
badgeText: {
color: "white",
fontSize: 12,
fontWeight: "bold",
},
headerBottom: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
},
titleContainer: {
flex: 1,
},
headerTitle: {
fontSize: 28,
color: "white",
fontWeight: "bold",
marginBottom: 4,
},
headerSubtitle: {
fontSize: 16,
color: "rgba(255, 255, 255, 0.8)",
fontWeight: "400",
},
searchButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "white",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 4,
},
decorativeCircle1: {
position: "absolute",
top: -50,
right: -30,
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
decorativeCircle2: {
position: "absolute",
bottom: -20,
left: -20,
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: "rgba(255, 255, 255, 0.08)",
},
channelListContainer: {
flex: 1,
backgroundColor: "#f8fafc",
marginTop: -10,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
paddingTop: 10,
},
// Custom Avatar Styles
avatarContainer: {
position: "relative",
width: 56,
height: 56,
marginRight: 12,
},
avatarGradient: {
width: 56,
height: 56,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 6,
position: "relative",
},
avatarInitials: {
fontSize: 18,
fontWeight: "bold",
color: "white",
textShadowColor: "rgba(0, 0, 0, 0.3)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
avatarRing: {
position: "absolute",
top: -2,
left: -2,
width: 60,
height: 60,
borderRadius: 18,
borderWidth: 2,
borderColor: "rgba(59, 130, 246, 0.2)",
backgroundColor: "transparent",
},
statusIndicator: {
position: "absolute",
bottom: 2,
right: 2,
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: "#10b981",
borderWidth: 3,
borderColor: "white",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
memberCountBadge: {
position: "absolute",
top: -4,
right: -4,
backgroundColor: "#3b82f6",
borderRadius: 10,
minWidth: 20,
height: 20,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "white",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
memberCountText: {
color: "white",
fontSize: 11,
fontWeight: "bold",
},
});

File diff suppressed because it is too large Load diff

View file

@ -1,156 +0,0 @@
import { View, StyleSheet } from "react-native";
import { useAuth } from "@/stores/auth";
import { Avatar, Button, Input } from "@rn-vui/themed";
import { Card, ListItem } from "@rn-vui/themed";
import { useState } from "react";
import { useUser } from "@/providers/UserProvider";
export default function ProfileScreen() {
const signOut = useAuth((state) => state.signOut);
const user = useUser();
const [displayName, setDisplayName] = useState(user?.full_name || "");
const [isEditing, setIsEditing] = useState(false);
return (
<View style={styles.container}>
<Card containerStyle={styles.card}>
<Avatar
size="xlarge"
rounded
icon={{ name: "user", type: "font-awesome" }}
containerStyle={styles.avatar}
/>
<Card.Title style={styles.cardTitle}>{user?.full_name}</Card.Title>
<Card.Divider />
<ListItem key="email" bottomDivider containerStyle={styles.listItem}>
<ListItem.Content>
<ListItem.Title style={styles.listItemTitle}>Email</ListItem.Title>
<ListItem.Subtitle style={styles.listItemSubtitle}>
{user?.email}
</ListItem.Subtitle>
</ListItem.Content>
</ListItem>
<ListItem
key="display-name"
bottomDivider
containerStyle={styles.listItem}
>
<ListItem.Content>
<ListItem.Title style={styles.listItemTitle}>
Display Name
</ListItem.Title>
{isEditing ? (
<Input
placeholder="Enter display name"
value={displayName}
onChangeText={setDisplayName}
containerStyle={styles.inputContainer}
inputStyle={styles.inputText}
autoFocus
/>
) : (
<ListItem.Subtitle style={styles.listItemSubtitle}>
{user?.full_name || "Not Set"}
</ListItem.Subtitle>
)}
</ListItem.Content>
<Button
icon={{
name: isEditing ? "check" : "edit",
type: "font-awesome",
size: 18,
}}
type="clear"
onPress={() => {
if (isEditing) {
// handleSaveDisplayName();
} else {
setDisplayName(user?.full_name || "");
setIsEditing(true);
}
}}
/>
</ListItem>
<Button
title="Sign Out"
onPress={signOut}
buttonStyle={styles.signOutButton}
titleStyle={styles.signOutText}
icon={{
name: "sign-out",
type: "font-awesome",
color: "white",
size: 18,
}}
iconRight
/>
</Card>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 15,
backgroundColor: "#f8f9fa",
},
card: {
borderRadius: 10,
padding: 20,
margin: 0,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
avatar: {
marginBottom: 20,
backgroundColor: "#e0e0e0",
},
cardTitle: {
fontSize: 20,
fontWeight: "bold",
marginBottom: 15,
},
listItem: {
paddingVertical: 15,
width: "100%",
},
listItemTitle: {
fontWeight: "600",
marginBottom: 5,
color: "#555",
},
listItemSubtitle: {
color: "#333",
fontSize: 16,
},
inputContainer: {
paddingHorizontal: 0,
height: 40,
},
inputText: {
fontSize: 16,
},
signOutButton: {
marginTop: 30,
backgroundColor: "#dc3545",
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
width: "80%",
},
signOutText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
marginLeft: 10,
},
});

File diff suppressed because it is too large Load diff

View file

@ -1,38 +1,208 @@
import { useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, SafeAreaView } from "react-native";
import { Channel as ChannelType } from "stream-chat";
import { Stack, useLocalSearchParams } from "expo-router";
import {
ActivityIndicator,
SafeAreaView,
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from "react-native";
import {
Channel,
MessageInput,
MessageList,
useChatContext,
} from "stream-chat-expo";
import { MessageCircle, Users, Smile } from "lucide-react-native";
import { useEffect, useState } from "react";
import { useHeaderHeight } from "@react-navigation/elements";
export default function ChannelScreen() {
const [channel, setChannel] = useState<ChannelType | null>(null);
const { cid } = useLocalSearchParams<{ cid: string }>();
const [type, id] = cid.split(":");
const { client } = useChatContext();
const channel = client.channel(type, id);
const [hasMessages, setHasMessages] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const headerHeight = useHeaderHeight();
useEffect(() => {
const fetchChannel = async () => {
const channels = await client.queryChannels({ cid });
setChannel(channels[0]);
};
fetchChannel();
}, [cid]);
if (channel) {
const checkMessages = async () => {
try {
// Get channel state to check for messages
await channel.watch();
const messages = channel.state.messages || [];
setHasMessages(messages.length > 0);
} catch (error) {
} finally {
setIsLoading(false);
}
};
checkMessages();
// Listen for new messages
const handleNewMessage = () => {
setHasMessages(true);
};
channel.on("message.new", handleNewMessage);
return () => {
channel.off("message.new", handleNewMessage);
};
}
}, [channel]);
if (!channel) {
return <ActivityIndicator />;
}
const EmptyState = () => (
<View style={styles.emptyContainer}>
<View style={styles.emptyIconContainer}>
<MessageCircle size={64} color="#d1d5db" strokeWidth={1.5} />
<View style={styles.decorativeElements}>
<View style={styles.floatingIcon1}>
<Users size={20} color="#e5e7eb" />
</View>
<View style={styles.floatingIcon2}>
<Smile size={18} color="#e5e7eb" />
</View>
</View>
</View>
<Text style={styles.emptyTitle}>Commencez la conversation</Text>
<Text style={styles.emptyMessage}>
Soyez le premier à envoyer un message dans ce canal !
</Text>
</View>
);
return (
<Channel channel={channel}>
<MessageList />
<SafeAreaView>
<MessageInput />
</SafeAreaView>
</Channel>
<SafeAreaView style={{ flex: 1, backgroundColor: "#f8fafc" }}>
<Channel channel={channel} keyboardVerticalOffset={headerHeight}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3b82f6" />
<Text style={styles.loadingText}>Chargement des messages...</Text>
</View>
) : hasMessages ? (
<MessageList />
) : (
<EmptyState />
)}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
style={styles.keyboardContainer}
>
<MessageInput />
</KeyboardAvoidingView>
</Channel>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#f8fafc",
gap: 16,
},
loadingText: {
fontSize: 16,
color: "#6b7280",
fontWeight: "500",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 40,
backgroundColor: "#f8fafc",
},
emptyIconContainer: {
position: "relative",
marginBottom: 32,
width: 120,
height: 120,
justifyContent: "center",
alignItems: "center",
},
decorativeElements: {
position: "absolute",
width: "100%",
height: "100%",
},
floatingIcon1: {
position: "absolute",
top: 10,
right: 5,
backgroundColor: "white",
borderRadius: 15,
padding: 6,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
floatingIcon2: {
position: "absolute",
bottom: 15,
left: 8,
backgroundColor: "white",
borderRadius: 12,
padding: 5,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
emptyTitle: {
fontSize: 24,
fontWeight: "bold",
color: "#1f2937",
marginBottom: 12,
textAlign: "center",
},
emptyMessage: {
fontSize: 16,
color: "#6b7280",
textAlign: "center",
lineHeight: 24,
marginBottom: 32,
},
emptyHint: {
backgroundColor: "white",
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 20,
borderWidth: 1,
borderColor: "#e5e7eb",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 1,
},
emptyHintText: {
fontSize: 14,
color: "#9ca3af",
fontWeight: "500",
textAlign: "center",
},
keyboardContainer: {
backgroundColor: "#f8fafc",
},
messageInputContainer: {
backgroundColor: "#f8fafc",
},
});

View file

@ -0,0 +1,361 @@
import {
View,
StyleSheet,
ScrollView,
Text,
TouchableOpacity,
} from "react-native";
import { useAuth } from "@/stores/auth";
import { Avatar, Input } from "@rn-vui/themed";
import { Card } from "@rn-vui/themed";
import { useState } from "react";
import { useUser } from "@/providers/UserProvider";
import { LinearGradient } from "expo-linear-gradient";
import {
User,
Mail,
Edit3,
Check,
LogOut,
Settings,
Shield,
} from "lucide-react-native";
export default function ProfileScreen() {
const signOut = useAuth((state) => state.signOut);
const user = useUser();
const [displayName, setDisplayName] = useState(user.name || "");
const [isEditing, setIsEditing] = useState(false);
const handleSaveDisplayName = () => {
// TODO: Implémenter la fonctionnalité de sauvegarde
setIsEditing(false);
};
const menuItems = [
{
icon: Settings,
title: "Paramètres du compte",
subtitle: "Gérez vos préférences de compte",
onPress: () => console.log("Paramètres"),
},
{
icon: Shield,
title: "Confidentialité et sécurité",
subtitle: "Contrôlez vos paramètres de confidentialité",
onPress: () => console.log("Confidentialité"),
},
];
return (
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
<LinearGradient
colors={["#1e3a8a", "#3b82f6", "#60a5fa"]}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.headerContent}>
<Avatar
size={120}
rounded
source={user.avatar_url ? { uri: user.avatar_url } : undefined}
icon={
!user.avatar_url
? { name: "user", type: "font-awesome" }
: undefined
}
containerStyle={styles.avatar}
/>
<Text style={styles.userName}>{user.name || "Utilisateur"}</Text>
<Text style={styles.userEmail}>{user.email}</Text>
</View>
</LinearGradient>
{/* Contenu principal */}
<View style={styles.content}>
{/* Carte d'informations personnelles */}
<Card containerStyle={styles.mainCard}>
<View style={styles.cardHeader}>
<User size={20} color="#3b82f6" />
<Text style={styles.cardHeaderTitle}>
Informations personnelles
</Text>
</View>
<View style={styles.infoItem}>
<View style={styles.infoIconContainer}>
<Mail size={18} color="#6b7280" />
</View>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Adresse e-mail</Text>
<Text style={styles.infoValue}>{user.email}</Text>
</View>
</View>
<View style={styles.divider} />
<View style={styles.infoItem}>
<View style={styles.infoIconContainer}>
<User size={18} color="#6b7280" />
</View>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Nom d'affichage</Text>
{isEditing ? (
<Input
placeholder="Entrez le nom d'affichage"
value={displayName}
onChangeText={setDisplayName}
containerStyle={styles.inputContainer}
inputStyle={styles.inputText}
autoFocus
/>
) : (
<Text style={styles.infoValue}>
{user.name || "Non défini"}
</Text>
)}
</View>
<TouchableOpacity
style={styles.editButton}
onPress={() => {
if (isEditing) {
handleSaveDisplayName();
} else {
setDisplayName(user.name ?? "");
setIsEditing(true);
}
}}
>
{isEditing ? (
<Check size={18} color="#10b981" />
) : (
<Edit3 size={18} color="#6b7280" />
)}
</TouchableOpacity>
</View>
</Card>
{/* Éléments de menu */}
<Card containerStyle={styles.menuCard}>
<View style={styles.cardHeader}>
<Settings size={20} color="#3b82f6" />
<Text style={styles.cardHeaderTitle}>Préférences</Text>
</View>
{menuItems.map((item, index) => (
<View key={index}>
<TouchableOpacity style={styles.menuItem} onPress={item.onPress}>
<View style={styles.menuIconContainer}>
<item.icon size={20} color="#6b7280" />
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>{item.title}</Text>
<Text style={styles.menuSubtitle}>{item.subtitle}</Text>
</View>
<Text style={styles.menuArrow}></Text>
</TouchableOpacity>
{index < menuItems.length - 1 && <View style={styles.divider} />}
</View>
))}
</Card>
{/* Bouton de déconnexion */}
<TouchableOpacity style={styles.signOutContainer} onPress={signOut}>
<LinearGradient
colors={["#ef4444", "#dc2626"]}
style={styles.signOutGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<LogOut size={20} color="white" />
<Text style={styles.signOutText}>Se déconnecter</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f8fafc",
},
headerGradient: {
paddingTop: 60,
paddingBottom: 40,
paddingHorizontal: 20,
},
headerContent: {
alignItems: "center",
},
avatar: {
marginBottom: 16,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
borderWidth: 4,
borderColor: "white",
},
userName: {
fontSize: 24,
fontWeight: "bold",
color: "white",
marginBottom: 4,
textAlign: "center",
},
userEmail: {
fontSize: 16,
color: "rgba(255, 255, 255, 0.9)",
textAlign: "center",
},
content: {
padding: 20,
marginTop: -20,
},
mainCard: {
borderRadius: 16,
padding: 0,
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
borderWidth: 0,
},
menuCard: {
borderRadius: 16,
padding: 0,
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
borderWidth: 0,
},
cardHeader: {
flexDirection: "row",
alignItems: "center",
padding: 20,
paddingBottom: 16,
},
cardHeaderTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1f2937",
marginLeft: 12,
},
infoItem: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
},
infoIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#f3f4f6",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
infoContent: {
flex: 1,
},
infoLabel: {
fontSize: 14,
color: "#6b7280",
marginBottom: 4,
fontWeight: "500",
},
infoValue: {
fontSize: 16,
color: "#1f2937",
fontWeight: "500",
},
editButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "#f3f4f6",
justifyContent: "center",
alignItems: "center",
},
inputContainer: {
paddingHorizontal: 0,
height: 40,
marginTop: 4,
},
inputText: {
fontSize: 16,
color: "#1f2937",
},
divider: {
height: 1,
backgroundColor: "#e5e7eb",
marginLeft: 72,
},
menuItem: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
},
menuIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#f3f4f6",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
menuContent: {
flex: 1,
},
menuTitle: {
fontSize: 16,
fontWeight: "500",
color: "#1f2937",
marginBottom: 2,
},
menuSubtitle: {
fontSize: 14,
color: "#6b7280",
},
menuArrow: {
fontSize: 20,
color: "#9ca3af",
fontWeight: "300",
},
signOutContainer: {
marginTop: 10,
marginBottom: 40,
},
signOutGradient: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: 12,
shadowColor: "#ef4444",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
signOutText: {
color: "white",
fontSize: 16,
fontWeight: "600",
marginLeft: 8,
},
});

View file

@ -12,12 +12,25 @@ import "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useColorScheme } from "@/hooks/useColorScheme";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cloneDeep } from "lodash";
import { ActivityIndicator } from "react-native";
const queryClient = new QueryClient();
window.structuredClone = cloneDeep;
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 3 total attempts (1 initial + 2 retries)
retry: 2,
// 0s -> 1s, 1s → 5s. Little resiliency 😁
retryDelay: (attemptIndex) => Math.min(1000 * 5 ** attemptIndex, 10000),
},
},
});
export default function RootLayout() {
const colorScheme = useColorScheme();
const [loaded] = useFonts({
@ -31,7 +44,7 @@ export default function RootLayout() {
}, [loaded]);
if (!loaded) {
return null;
return <ActivityIndicator />;
}
return (

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View file

@ -0,0 +1,58 @@
import React from "react";
import { StyleSheet, View, Text, TouchableOpacity } from "react-native";
import { useAuth } from "@/stores/auth";
import { Svg, Path } from "react-native-svg";
const AppleIcon = ({ color = "#fff", size = 20 }) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.653-.026 2.681-1.507 3.694-2.961 1.169-1.69 1.648-3.327 1.679-3.418-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.646 1.09z"
fill={color}
/>
<Path
d="M15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701z"
fill={color}
/>
</Svg>
);
export const AppleLoginButton = ({ onPress }: { onPress: () => void }) => {
const authLoading = useAuth((state) => state.loading);
return (
<TouchableOpacity
style={styles.button}
onPress={onPress}
disabled={authLoading}
>
<View style={styles.contentWrapper}>
<AppleIcon color="#fff" size={20} />
<Text style={styles.text}>Continuer avec Apple</Text>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "#000000",
borderRadius: 8,
height: 48,
paddingHorizontal: 12,
alignItems: "center",
justifyContent: "center",
alignSelf: "stretch",
},
contentWrapper: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
text: {
color: "#ffffff",
fontSize: 14,
fontWeight: "600",
textAlign: "center",
marginLeft: 12,
},
});

View file

@ -0,0 +1,53 @@
import React from "react";
import { StyleSheet, View, Text, TouchableOpacity, Image } from "react-native";
import { useAuth } from "@/stores/auth";
export const GoogleLoginButton = ({ onPress }: { onPress: () => void }) => {
const authLoading = useAuth((state) => state.loading);
return (
<TouchableOpacity
style={styles.button}
onPress={onPress}
disabled={authLoading}
>
<View style={styles.contentWrapper}>
<Image
source={require("@/assets/images/google.png")}
style={styles.icon}
/>
<Text style={styles.text}>Continuer avec Google</Text>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "#ffffff",
borderColor: "#747775",
borderWidth: 1,
borderRadius: 8,
height: 48,
paddingHorizontal: 12,
alignItems: "center",
justifyContent: "center",
alignSelf: "stretch",
},
contentWrapper: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
icon: {
height: 20,
width: 20,
marginRight: 12,
},
text: {
color: "#1f1f1f",
fontSize: 14,
fontWeight: "500",
textAlign: "center",
},
});

View file

@ -1,6 +1,70 @@
// This is a shim for web and Android where the tab bar is generally opaque.
export default undefined;
import React from "react";
import { StyleSheet, View } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { useColorScheme } from "@/hooks/useColorScheme";
export default function TabBarBackground() {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
return (
<View style={StyleSheet.absoluteFill}>
<LinearGradient
colors={
isDark
? ["#1F2937", "#111827", "#0F172A"]
: ["#FFFFFF", "#F8FAFC", "#F1F5F9"]
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
{/* Subtle top border gradient */}
<LinearGradient
colors={
isDark
? ["rgba(59, 130, 246, 0.3)", "transparent"]
: ["rgba(59, 130, 246, 0.1)", "transparent"]
}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.topBorder}
/>
{/* Optional: Add some decorative elements */}
<View
style={[
styles.decorativeCircle,
{
backgroundColor: isDark
? "rgba(59, 130, 246, 0.05)"
: "rgba(59, 130, 246, 0.03)",
},
]}
/>
</View>
);
}
export function useBottomTabOverflow() {
return 0;
return 24; // Account for the rounded corners
}
const styles = StyleSheet.create({
topBorder: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 2,
},
decorativeCircle: {
position: "absolute",
top: -20,
right: 20,
width: 80,
height: 80,
borderRadius: 40,
},
});

View file

@ -3,24 +3,50 @@
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
const tintColorLight = "#0a7ea4";
const tintColorDark = "#fff";
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
text: "#11181C",
background: "#fff",
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
icon: "#687076",
tabIconDefault: "#687076",
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
text: "#ECEDEE",
background: "#151718",
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
icon: "#9BA1A6",
tabIconDefault: "#9BA1A6",
tabIconSelected: tintColorDark,
},
};
export const AVAILABLE_COLORS = [
"bg-blue-500",
"bg-green-500",
"bg-purple-500",
"bg-red-500",
"bg-yellow-500",
"bg-indigo-500",
"bg-pink-500",
"bg-teal-500",
"bg-orange-500",
"bg-cyan-500",
];
export const ColorMap: Record<(typeof AVAILABLE_COLORS)[number], string> = {
"bg-blue-500": "#3b82f6",
"bg-green-500": "#10b981",
"bg-red-500": "#ef4444",
"bg-yellow-500": "#f59e0b",
"bg-purple-500": "#8b5cf6",
"bg-pink-500": "#ec4899",
"bg-orange-500": "#f97316",
"bg-teal-500": "#0d9488",
"bg-indigo-500": "#6366f1",
"bg-cyan-500": "#06b6d4",
};

View file

@ -3,24 +3,50 @@
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
const tintColorLight = "#0a7ea4";
const tintColorDark = "#fff";
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
text: "#11181C",
background: "#fff",
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
icon: "#687076",
tabIconDefault: "#687076",
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
text: "#ECEDEE",
background: "#151718",
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
icon: "#9BA1A6",
tabIconDefault: "#9BA1A6",
tabIconSelected: tintColorDark,
},
};
export const AVAILABLE_COLORS = [
"bg-blue-500",
"bg-green-500",
"bg-purple-500",
"bg-red-500",
"bg-yellow-500",
"bg-indigo-500",
"bg-pink-500",
"bg-teal-500",
"bg-orange-500",
"bg-cyan-500",
];
export const ColorMap: Record<(typeof AVAILABLE_COLORS)[number], string> = {
"bg-blue-500": "#3b82f6",
"bg-green-500": "#10b981",
"bg-red-500": "#ef4444",
"bg-yellow-500": "#f59e0b",
"bg-purple-500": "#8b5cf6",
"bg-pink-500": "#ec4899",
"bg-orange-500": "#f97316",
"bg-teal-500": "#0d9488",
"bg-indigo-500": "#6366f1",
"bg-cyan-500": "#06b6d4",
};

21
xtablo-expo/eas.json Normal file
View file

@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 16.1.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

View file

@ -0,0 +1,59 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/lib/supabase";
import { EventAndTablo, EventInsert } from "@/types/events.types";
import { useUser } from "@/providers/UserProvider";
export const useEventsByTablo = (tabloId: string | null) => {
return useQuery({
queryKey: ["events", tabloId],
queryFn: async () => {
if (!tabloId) {
const { data, error } = await supabase
.from("events_and_tablos")
.select("*")
.order("start_date", { ascending: true })
.order("start_time", { ascending: true });
if (error) throw error;
return data as EventAndTablo[];
}
const { data, error } = await supabase
.from("events_and_tablos")
.select("*")
.eq("tablo_id", tabloId)
.order("start_date", { ascending: true })
.order("start_time", { ascending: true });
if (error) throw error;
return data as EventAndTablo[];
},
});
};
export const useCreateEvent = () => {
const user = useUser();
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: async (event: Omit<EventInsert, "created_by">) => {
const { data, error } = await supabase
.from("events")
.insert({
...event,
created_by: user.id,
})
.select()
.single();
if (error) throw error;
return data as Event;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["events"] });
},
onError: (err) => {
console.error(err);
},
});
return mutate;
};

View file

@ -0,0 +1,52 @@
import { supabase } from "@/lib/supabase";
import { useUser } from "@/providers/UserProvider";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { TabloInsert, UserTablo } from "@/types/tablos.types";
import { api } from "@/lib/api";
import { useAuth } from "@/stores/auth";
import { Alert } from "react-native";
// type TabloInsert = Tablo["Insert"];
// type TabloUpdate = Tablo["Update"];
// Fetch all tablos
export const useTablosList = () => {
const user = useUser();
return useQuery({
queryKey: ["tablos"],
queryFn: async () => {
const { data, error } = await supabase
.from("user_tablos")
.select("*")
.eq("user_id", user.id);
if (error) throw error;
const tablos = data as UserTablo[];
return tablos;
},
refetchInterval: 1000 * 60 * 5, // 5 minutes
});
};
export const useCreateTablo = () => {
const session = useAuth((state) => state.session);
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
tablo: Pick<TabloInsert, "name" | "color" | "image" | "status">
) => {
const { data } = await api.post("/api/v1/tablos/create", tablo, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tablos"] });
},
onError: (error) => {
Alert.alert("Erreur", "Impossible de créer le tablo.");
},
});
};

View file

@ -3,14 +3,14 @@
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Colors } from "@/constants/colors";
import { useColorScheme } from "@/hooks/useColorScheme";
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const theme = useColorScheme() ?? "light";
const colorFromProps = props[theme];
if (colorFromProps) {

View file

@ -1,17 +1,29 @@
import { Tables } from "@/lib/database.types";
import { supabase } from "@/lib/supabase";
import { api } from "@/lib/api";
import { Tables } from "@/types/database.types";
import { useAuth } from "@/stores/auth";
import { useQuery } from "@tanstack/react-query";
type User = Tables<"profiles">;
type User = Tables<"profiles"> & {
streamToken: string | null;
};
export const useGetUser = (): { user: User | null; isPending: boolean } => {
const { data, isPending } = useQuery<User[] | null>({
export const useGetUser = (): { user: User | null; isLoading: boolean } => {
const session = useAuth((state) => state.session);
const { data, isLoading } = useQuery<User | null>({
queryKey: ["user"],
queryFn: async () => {
const { data } = await supabase.from("profiles").select("*");
return data;
try {
const { data: user } = await api.get<User>("/api/v1/users/me", {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
return user;
} catch (error) {
return null;
}
},
});
return { user: data?.[0] ?? null, isPending };
return { user: data ?? null, isLoading };
};

10
xtablo-expo/lib/api.ts Normal file
View file

@ -0,0 +1,10 @@
import axios from "axios";
// Create axios instance with default config
export const api = axios.create({
baseURL: process.env.EXPO_PUBLIC_API_URL,
headers: {
"Content-Type": "application/json",
},
timeout: 2000,
});

View file

@ -1,237 +0,0 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[];
export type Database = {
public: {
Tables: {
devis: {
Row: {
client_email: string;
created_at: string;
date: string;
due_date: string;
id: string;
items: Json;
notes: string | null;
number: string;
status: Database["public"]["Enums"]["devis_status"];
subtotal: number;
tax: number;
terms: string | null;
total: number;
updated_at: string;
user_id: string;
};
Insert: {
client_email: string;
created_at?: string;
date: string;
due_date: string;
id?: string;
items?: Json;
notes?: string | null;
number: string;
status?: Database["public"]["Enums"]["devis_status"];
subtotal: number;
tax: number;
terms?: string | null;
total: number;
updated_at?: string;
user_id: string;
};
Update: {
client_email?: string;
created_at?: string;
date?: string;
due_date?: string;
id?: string;
items?: Json;
notes?: string | null;
number?: string;
status?: Database["public"]["Enums"]["devis_status"];
subtotal?: number;
tax?: number;
terms?: string | null;
total?: number;
updated_at?: string;
user_id?: string;
};
Relationships: [];
};
profiles: {
Row: {
avatar_url: string | null;
email: string | null;
full_name: string | null;
id: string;
updated_at: string | null;
website: string | null;
};
Insert: {
avatar_url?: string | null;
email?: string | null;
full_name?: string | null;
id: string;
updated_at?: string | null;
website?: string | null;
};
Update: {
avatar_url?: string | null;
email?: string | null;
full_name?: string | null;
id?: string;
updated_at?: string | null;
website?: string | null;
};
Relationships: [];
};
};
Views: {
user_tablos: {
Row: {
id: string;
owner_id: string;
name: string;
image: string | null;
color: string | null;
status: string;
position: number;
created_at: string | null;
deleted_at: string | null;
access_level: "owner" | "admin" | "member";
is_admin: boolean;
granted_by: string | null;
access_granted_at: string | null;
};
Relationships: [];
};
};
Functions: {
[_ in never]: never;
};
Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired";
};
CompositeTypes: {
[_ in never]: never;
};
};
};
type DefaultSchema = Database[Extract<keyof Database, "public">];
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database;
}
? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R;
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R;
}
? R
: never
: never;
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database;
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I;
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I;
}
? I
: never
: never;
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database;
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U;
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U;
}
? U
: never
: never;
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof Database },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof Database;
}
? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never;
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof Database },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database;
}
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never;
export const Constants = {
public: {
Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
},
},
} as const;

View file

@ -2,6 +2,9 @@ import "react-native-url-polyfill/auto";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createClient } from "@supabase/supabase-js";
import { Database } from "@/lib/database.types";
import * as SecureStore from "expo-secure-store";
import * as aesjs from "aes-js";
import * as Crypto from "expo-crypto";
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL as string;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY as string;
@ -10,9 +13,66 @@ if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing Supabase environment variables");
}
// As Expo's SecureStore does not support values larger than 2048
// bytes, an AES-256 key is generated and stored in SecureStore, while
// it is used to encrypt/decrypt values stored in AsyncStorage.
class LargeSecureStore {
private async _encrypt(key: string, value: string) {
const encryptionKey = Crypto.getRandomValues(new Uint8Array(256 / 8));
const cipher = new aesjs.ModeOfOperation.ctr(
encryptionKey,
new aesjs.Counter(1)
);
const encryptedBytes = cipher.encrypt(aesjs.utils.utf8.toBytes(value));
await SecureStore.setItemAsync(
key,
aesjs.utils.hex.fromBytes(encryptionKey)
);
return aesjs.utils.hex.fromBytes(encryptedBytes);
}
private async _decrypt(key: string, value: string) {
const encryptionKeyHex = await SecureStore.getItemAsync(key);
if (!encryptionKeyHex) {
return encryptionKeyHex;
}
const cipher = new aesjs.ModeOfOperation.ctr(
aesjs.utils.hex.toBytes(encryptionKeyHex),
new aesjs.Counter(1)
);
const decryptedBytes = cipher.decrypt(aesjs.utils.hex.toBytes(value));
return aesjs.utils.utf8.fromBytes(decryptedBytes);
}
async getItem(key: string) {
const encrypted = await AsyncStorage.getItem(key);
if (!encrypted) {
return encrypted;
}
return await this._decrypt(key, encrypted);
}
async removeItem(key: string) {
await AsyncStorage.removeItem(key);
await SecureStore.deleteItemAsync(key);
}
async setItem(key: string, value: string) {
const encrypted = await this._encrypt(key, value);
await AsyncStorage.setItem(key, encrypted);
}
}
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
storage: new LargeSecureStore(),
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,8 @@
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"test": "jest --watchAll",
"lint": "expo lint"
@ -21,43 +21,54 @@
"@react-navigation/bottom-tabs": "^7.2.0",
"@rn-vui/base": "^5.1.3",
"@rn-vui/themed": "^5.1.3",
"@supabase/supabase-js": "^2.49.4",
"@supabase/supabase-js": "^2.51.0",
"@tanstack/react-query": "^5.75.2",
"expo": "^53.0.0",
"expo-av": "~15.1.4",
"expo-blur": "~14.1.4",
"aes-js": "^3.1.2",
"expo": "^53.0.19",
"expo-av": "~15.1.7",
"expo-blur": "~14.1.5",
"expo-constants": "~17.1.5",
"expo-font": "~13.3.1",
"expo-font": "~13.3.2",
"expo-haptics": "~14.1.4",
"expo-image-manipulator": "~13.1.5",
"expo-image-manipulator": "~13.1.7",
"expo-image-picker": "~16.1.4",
"expo-linking": "~7.1.4",
"expo-router": "~5.0.5",
"expo-router": "~5.1.3",
"expo-secure-store": "~14.2.3",
"expo-splash-screen": "~0.30.8",
"expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.4",
"expo-system-ui": "~5.0.7",
"expo-web-browser": "~14.1.6",
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.10",
"expo-web-browser": "~14.2.0",
"lodash": "^4.17.21",
"lucide-react-native": "^0.525.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-native": "0.79.2",
"react-native": "0.79.5",
"react-native-gesture-handler": "~2.24.0",
"react-native-get-random-values": "~1.11.0",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.10.0",
"react-native-screens": "~4.11.1",
"react-native-svg": "15.11.2",
"react-native-url-polyfill": "^2.0.0",
"react-native-web": "^0.20.0",
"react-native-webview": "13.13.5",
"stream-chat-expo": "^6.7.3",
"zustand": "^5.0.4"
"zustand": "^5.0.4",
"expo-crypto": "~14.1.5",
"expo-auth-session": "~6.2.1",
"expo-linear-gradient": "~14.1.5"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/aes-js": "^3.1.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.13",
"@types/react": "~19.0.10",
"@types/react-test-renderer": "^19.0.0",
"jest": "^29.2.1",
"jest-expo": "~53.0.4",
"jest-expo": "~53.0.9",
"react-test-renderer": "18.3.1",
"typescript": "^5.3.3"
},

View file

@ -1,40 +1,34 @@
import { useEffect, useState } from "react";
import { ActivityIndicator } from "react-native";
import { StreamChat } from "stream-chat";
import { Chat, OverlayProvider } from "stream-chat-expo";
import { ActivityIndicator, View } from "react-native";
import { Chat, OverlayProvider, useCreateChatClient } from "stream-chat-expo";
import { useUser } from "@/providers/UserProvider";
import { Text } from "react-native";
export default function ChatProvider({
children,
}: {
children: React.ReactNode;
}) {
const [isReady, setIsReady] = useState(false);
const user = useUser();
const client = StreamChat.getInstance(
process.env.EXPO_PUBLIC_STREAM_CHAT_API_KEY as string
);
const client = useCreateChatClient({
apiKey: process.env.EXPO_PUBLIC_STREAM_CHAT_API_KEY as string,
options: { timeout: 5000 },
tokenOrProvider: user.streamToken,
userData: {
id: user.id,
name: user.name ?? "",
},
});
useEffect(() => {
const connect = async () => {
await client.connectUser(
{
id: "artslidd",
name: "Arthur",
},
client.devToken("artslidd")
);
setIsReady(true);
const channel = client.channel("messaging", "hello");
await channel.watch();
};
connect();
return () => {
client.disconnectUser();
setIsReady(false);
};
}, []);
if (!user.streamToken) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>Chat Indisponible</Text>
</View>
);
}
if (!isReady) {
if (!client) {
return <ActivityIndicator />;
}

View file

@ -1,12 +1,13 @@
import { createStore, StoreApi, useStore } from "zustand";
import React from "react";
import { Tables } from "@/lib/database.types";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/lib/supabase";
import { Tables } from "@/types/database.types";
import { ActivityIndicator } from "react-native";
import { Redirect } from "expo-router";
import { useGetUser } from "@/hooks/user";
type User = Tables<"profiles">;
type User = Tables<"profiles"> & {
streamToken: string | null;
};
const UserStoreContext = React.createContext<StoreApi<User> | null>(null);
@ -15,30 +16,18 @@ export const UserStoreProvider = ({
}: {
children: React.ReactNode;
}) => {
const { data, isPending } = useQuery<User | null>({
queryKey: ["user"],
queryFn: async () => {
const { data, error } = await supabase.from("profiles").select("*");
if (error) throw error;
return data[0];
},
});
const { user, isLoading } = useGetUser();
const [store] = React.useState(() => {
if (!data) {
return null;
}
return createStore<User>()(() => data);
});
if (isPending) {
if (isLoading) {
return <ActivityIndicator />;
}
if (!store) {
if (!user) {
return <Redirect href="/(auth)/login" />;
}
const store = createStore<User>()(() => user);
return (
<UserStoreContext.Provider value={store as StoreApi<User>}>
{children}

View file

@ -1,45 +1,137 @@
import { create } from "zustand";
import { Session } from "@supabase/supabase-js";
import { Provider, Session } from "@supabase/supabase-js";
import { supabase } from "@/lib/supabase";
import * as WebBrowser from "expo-web-browser";
import { makeRedirectUri } from "expo-auth-session";
import * as QueryParams from "expo-auth-session/build/QueryParams";
import { Linking } from "react-native";
import { QueryClient } from "@tanstack/react-query";
interface AuthState {
session: Session | null;
loading: boolean;
initialize: () => Promise<void>;
initialize: (queryClient: QueryClient) => Promise<void>;
setSession: (session: Session | null) => void;
login: (email: string, password: string) => Promise<void>;
signUp: (email: string, password: string) => Promise<void>;
signUp: (
email: string,
password: string,
firstName: string,
lastName: string,
companyName: string
) => Promise<void>;
performOAuth: (provider: Provider) => Promise<void>;
signOut: () => Promise<void>;
createSessionFromUrl: (url: string) => Promise<void>;
}
export const useAuth = create<AuthState>((set) => ({
session: null,
WebBrowser.maybeCompleteAuthSession();
const redirectTo = makeRedirectUri({ path: "/(home)/(tabs)" });
export const useAuth = create<AuthState>((set, get) => ({
loading: true,
initialize: async () => {
const {
data: { session },
} = await supabase.auth.getSession();
set({ session, loading: false });
session: null,
setSession: (session: Session | null) => set({ session }),
initialize: async (queryClient: QueryClient) => {
try {
const {
data: { session },
} = await supabase.auth.getSession();
set({
session,
});
supabase.auth.onAuthStateChange(async (event, session) => {
queryClient.invalidateQueries({ queryKey: ["user"] });
set({
session,
});
});
const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
await get().createSessionFromUrl(initialUrl);
}
Linking.addEventListener("url", ({ url }) => {
get().createSessionFromUrl(url);
});
} catch (error) {
console.error("Auth initialization error:", error);
} finally {
set({ loading: false });
}
},
setSession: (session) => set({ session }),
login: async (email: string, password: string) => {
await supabase.auth.signInWithPassword({ email, password });
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
set({ loading: false });
},
signUp: async (email: string, password: string) => {
await supabase.auth.signUp({ email, password });
signUp: async (
email: string,
password: string,
firstName: string,
lastName: string,
companyName: string
) => {
await supabase.auth.signUp({
email,
password,
options: {
data: {
firstName,
lastName,
companyName,
},
},
});
set({ loading: false });
},
performOAuth: async (provider: Provider) => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo,
skipBrowserRedirect: true,
queryParams: {
access_type: "offline",
prompt: "consent",
},
},
});
if (error) throw error;
const res = await WebBrowser.openAuthSessionAsync(
data?.url ?? "",
redirectTo
);
if (res.type === "success") {
const { url } = res;
await get().createSessionFromUrl(url);
}
},
signOut: async () => {
await supabase.auth.signOut();
set({ loading: false });
},
createSessionFromUrl: async (url: string) => {
const { params, errorCode } = QueryParams.getQueryParams(url);
if (errorCode) throw new Error(errorCode);
const { access_token, refresh_token } = params;
if (!access_token) return;
const { data, error } = await supabase.auth.setSession({
access_token,
refresh_token,
});
if (error) throw error;
set({ session: data.session });
},
}));
// Set up auth state listeners
supabase.auth.onAuthStateChange((_event, session) => {
useAuth.getState().setSession(session);
});
// Initialize auth state
useAuth.getState().initialize();

View file

@ -0,0 +1,498 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instanciate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "12.2.3 (519615d)"
}
public: {
Tables: {
devis: {
Row: {
client_email: string
created_at: string
date: string
due_date: string
id: string
items: Json
notes: string | null
number: string
status: Database["public"]["Enums"]["devis_status"]
subtotal: number
tax: number
terms: string | null
total: number
updated_at: string
user_id: string
}
Insert: {
client_email: string
created_at?: string
date: string
due_date: string
id?: string
items?: Json
notes?: string | null
number: string
status?: Database["public"]["Enums"]["devis_status"]
subtotal: number
tax: number
terms?: string | null
total: number
updated_at?: string
user_id: string
}
Update: {
client_email?: string
created_at?: string
date?: string
due_date?: string
id?: string
items?: Json
notes?: string | null
number?: string
status?: Database["public"]["Enums"]["devis_status"]
subtotal?: number
tax?: number
terms?: string | null
total?: number
updated_at?: string
user_id?: string
}
Relationships: []
}
events: {
Row: {
created_at: string | null
created_by: string
deleted_at: string | null
description: string | null
end_time: string | null
id: string
start_date: string
start_time: string
tablo_id: string
title: string
}
Insert: {
created_at?: string | null
created_by: string
deleted_at?: string | null
description?: string | null
end_time?: string | null
id?: string
start_date: string
start_time: string
tablo_id: string
title: string
}
Update: {
created_at?: string | null
created_by?: string
deleted_at?: string | null
description?: string | null
end_time?: string | null
id?: string
start_date?: string
start_time?: string
tablo_id?: string
title?: string
}
Relationships: [
{
foreignKeyName: "fk_events_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "fk_events_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "fk_events_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
feedbacks: {
Row: {
created_at: string | null
fd_type: string
id: number
message: string
user_id: string
}
Insert: {
created_at?: string | null
fd_type: string
id?: number
message: string
user_id: string
}
Update: {
created_at?: string | null
fd_type?: string
id?: number
message?: string
user_id?: string
}
Relationships: []
}
profiles: {
Row: {
avatar_url: string | null
email: string | null
id: string
name: string | null
}
Insert: {
avatar_url?: string | null
email?: string | null
id: string
name?: string | null
}
Update: {
avatar_url?: string | null
email?: string | null
id?: string
name?: string | null
}
Relationships: []
}
tablo_access: {
Row: {
created_at: string | null
granted_by: string
id: number
is_active: boolean | null
is_admin: boolean | null
tablo_id: string
user_id: string
}
Insert: {
created_at?: string | null
granted_by: string
id?: number
is_active?: boolean | null
is_admin?: boolean | null
tablo_id: string
user_id: string
}
Update: {
created_at?: string | null
granted_by?: string
id?: number
is_active?: boolean | null
is_admin?: boolean | null
tablo_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "fk_tablo_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "fk_tablo_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "fk_tablo_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "fk_tablo_access_user_id_from_profiles"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
tablo_invites: {
Row: {
id: number
invite_token: string
invited_by: string
invited_email: string
tablo_id: string
}
Insert: {
id?: number
invite_token: string
invited_by: string
invited_email: string
tablo_id: string
}
Update: {
id?: number
invite_token?: string
invited_by?: string
invited_email?: string
tablo_id?: string
}
Relationships: [
{
foreignKeyName: "fk_tablo_invitations_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "fk_tablo_invitations_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "fk_tablo_invitations_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
tablos: {
Row: {
color: string | null
created_at: string | null
deleted_at: string | null
id: string
image: string | null
name: string
owner_id: string
position: number
status: string
}
Insert: {
color?: string | null
created_at?: string | null
deleted_at?: string | null
id?: string
image?: string | null
name: string
owner_id: string
position?: number
status?: string
}
Update: {
color?: string | null
created_at?: string | null
deleted_at?: string | null
id?: string
image?: string | null
name?: string
owner_id?: string
position?: number
status?: string
}
Relationships: []
}
}
Views: {
events_and_tablos: {
Row: {
description: string | null
end_time: string | null
event_id: string | null
start_date: string | null
start_time: string | null
tablo_color: string | null
tablo_id: string | null
tablo_name: string | null
tablo_status: string | null
title: string | null
}
Relationships: []
}
user_tablos: {
Row: {
access_level: string | null
color: string | null
created_at: string | null
deleted_at: string | null
id: string | null
image: string | null
is_admin: boolean | null
name: string | null
position: number | null
status: string | null
user_id: string | null
}
Relationships: [
{
foreignKeyName: "fk_tablo_access_user_id_from_profiles"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
}
Functions: {
generate_random_string: {
Args: { length?: number }
Returns: string
}
}
Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
},
},
} as const

View file

@ -0,0 +1,22 @@
import { Tables, TablesInsert, TablesUpdate } from "@/types/database.types";
import { RemoveNullFromObject } from "@/types/removeNull";
export type Event = RemoveNullFromObject<
Tables<"events">,
"created_at" | "end_time"
>;
export type EventInsert = TablesInsert<"events">;
export type EventUpdate = TablesUpdate<"events">;
export type EventAndTablo = RemoveNullFromObject<
Tables<"events_and_tablos">,
| "event_id"
| "tablo_id"
| "tablo_name"
| "tablo_color"
| "tablo_status"
| "start_time"
| "end_time"
| "title"
| "start_date"
>;

View file

@ -0,0 +1,11 @@
/**
* Utility type to remove null from a type
*/
export type RemoveNull<T> = T extends null ? never : T;
/**
* Utility type to remove null from all properties of an object type
*/
export type RemoveNullFromObject<T, K extends keyof T = keyof T> = {
[L in keyof T]: L extends K ? RemoveNull<T[L]> : T[L];
};

View file

@ -0,0 +1,20 @@
import { Database } from "@/types/database.types";
import { RemoveNullFromObject } from "@/types/removeNull";
export type UserTablo = RemoveNullFromObject<
Database["public"]["Views"]["user_tablos"]["Row"],
| "id"
| "access_level"
| "is_admin"
| "created_at"
| "deleted_at"
| "position"
| "user_id"
| "name"
| "status"
>;
export type Tablo = Database["public"]["Tables"]["tablos"];
export type TabloInsert = Tablo["Insert"];
export type TabloUpdate = Tablo["Update"];