fix: conflicts resolved

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2024-12-30 22:39:18 +05:30
23 changed files with 570 additions and 32 deletions
+9 -3
View File
@@ -14,6 +14,7 @@
"url": "https://varunraj.in"
},
"dependencies": {
"@google/generative-ai": "^0.21.0",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.2",
@@ -36,6 +37,7 @@
"@types/cookie": "^0.6.0",
"@types/qs": "^6.9.15",
"@types/react-calendar-heatmap": "^1.6.7",
"@vis.gl/react-google-maps": "^1.4.2",
"axios": "^1.7.4",
"chrono-node": "^1.4.9",
"class-variance-authority": "^0.7.0",
@@ -46,25 +48,28 @@
"date-fns": "^3.6.0",
"drizzle-orm": "^0.33.0",
"leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility":"0.1.2",
"leaflet-defaulticon-compatibility": "0.1.2",
"lucide-react": "^0.428.0",
"next": "14.2.5",
"next-themes": "^0.3.0",
"pg": "^8.12.0",
"qs": "^6.13.0",
"rc-mentions": "^2.18.0",
"react": "^18",
"react-calendar-heatmap": "^1.9.0",
"react-day-picker": "9.0.8",
"react-dom": "^18",
"react-grid-gallery": "^1.0.1",
"react-leaflet": "^4.2.1",
"react-mentions": "^4.4.10",
"react-query": "^3.39.3",
"recharts": "^2.12.7",
"remotion": "4.0.232",
"sass": "^1.77.8",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"yet-another-react-lightbox": "^3.21.4"
"yet-another-react-lightbox": "^3.21.4",
"zod": "^3.24.1"
},
"devDependencies": {
"@semantic-release/commit-analyzer": "^13.0.0",
@@ -73,11 +78,12 @@
"@semantic-release/npm": "^12.0.1",
"@semantic-release/release-notes-generator": "^14.0.1",
"@types/jsonwebtoken": "^9.0.6",
"@types/leaflet":"^1.9.15",
"@types/leaflet": "^1.9.15",
"@types/node": "^20",
"@types/pg": "^8.11.6",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-mentions": "^4.4.1",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.24.0",
"eslint": "^8",
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -2,6 +2,7 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+52
View File
@@ -0,0 +1,52 @@
import React, { useState } from 'react'
import inputStyle from './findInputStyle'
import { MentionsInput, Mention } from 'react-mentions'
import { searchPeople } from '@/handlers/api/people.handler';
import mentionStyle from './findMentionStyle';
import classNames from './FindInput.module.css';
interface FindInputProps {
onSearch: (query: string) => void;
}
export default function FindInput({ onSearch }: FindInputProps) {
const [query, setQuery] = useState("");
const handleSearchPeople = async (e: any, callback: any) => {
if (!e.length) return [];
return searchPeople(e).then((people) => people.map((person: any) => ({
id: person.id,
display: person.name,
}))).then((people) => callback(people));
}
return (
<MentionsInput
value={query}
singleLine={true}
placeholder='Search for photos, use @ to search for people'
style={inputStyle}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onSearch(query);
}
}}
onChange={(e) => setQuery(e.target.value)}>
<Mention
trigger="@"
style={mentionStyle}
data={handleSearchPeople}
renderSuggestion={(
highlightedDisplay,
focused
) => (
<div className={`user ${focused ? 'focused' : ''} `}>
{highlightedDisplay.display}
</div>
)}
/>
</MentionsInput>
)
}
+45
View File
@@ -0,0 +1,45 @@
const findInputStyle = {
control: {
backgroundColor: 'hsl(var(--input))',
fontSize: 20,
fontWeight: 'normal',
outline: 'none',
borderRadius: '15px',
},
'&singleLine': {
// display: 'inline-block',
width: '100%',
highlighter: {
padding: '10px 15px',
border: 'none',
outline: 'none',
},
input: {
padding: '10px 15px',
border: 'none',
outline: 'none',
},
},
suggestions: {
list: {
backgroundColor: 'hsl(var(--background))',
fontSize: 14,
border: '1px solid hsl(var(--border))',
overflow: 'hidden',
},
item: {
padding: '5px 15px',
border: 'none',
'&focused': {
backgroundColor: 'hsl(var(--chart-2))',
},
},
},
}
export default findInputStyle;
+4
View File
@@ -0,0 +1,4 @@
export default {
backgroundColor: 'hsla(var(--chart-2) / 0.3)',
position: 'relative',
}
+2 -2
View File
@@ -23,11 +23,11 @@ export default function PageLayout({ children, className, title }: IProps) {
: children;
return (
<div>
<div className="flex flex-col w-full h-full">
{header}
<main
className={cn(
"flex flex-1 max-h-[calc(100vh-60px)] flex-col gap-4 overflow-y-auto mx-auto",
"flex flex-1 max-h-[calc(100vh-60px)] flex-col gap-4 overflow-y-auto mx-auto w-full",
{
"mb-14": !!header,
},
+4 -1
View File
@@ -23,6 +23,7 @@ import Head from "next/head";
import { sidebarNavs } from "@/config/constants/sidebarNavs";
import { cn } from "@/lib/utils";
import { useRouter } from "next/router";
import clsx from "clsx";
interface IProps {
leftComponent?: React.ReactNode | string;
@@ -62,7 +63,9 @@ export default function Header({ leftComponent, rightComponent, title }: IProps)
<title>{pageTitle} - Immich Power Tools</title>
</Head>
)}
<header key="header" className="sticky z-10 top-0 w-full flex h-14 items-center gap-4 border-b bg-white dark:bg-black px-4 lg:h-[60px] lg:px-6">
<header key="header" className={
clsx("sticky z-10 top-0 w-full flex h-14 items-center gap-4 border-b bg-white dark:bg-black px-4 lg:h-[60px] lg:px-6")
}>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0 md:hidden">
-1
View File
@@ -5,7 +5,6 @@ import { cn } from "@/lib/utils";
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
import Image from "next/image";
import ProfileInfo from "./ProfileInfo";
const ThemeSwitcher = dynamic(() => import("@/components/shared/ThemeSwitcher"), {
+18 -6
View File
@@ -1,6 +1,16 @@
import { Album, GalleryHorizontal, GalleryVerticalEnd, Home, Image, LocateOff, MapPinX, Rewind, User } from "lucide-react";
import { GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User } from "lucide-react";
export const sidebarNavs = [
{
title: "Rewind 2024",
link: "/rewind",
icon: <Rewind className="h-4 w-4" />,
},
{
title: "Find",
link: "/find",
icon: <Search className="h-4 w-4" />,
},
{
title: "Manage People",
link: "/",
@@ -9,7 +19,7 @@ export const sidebarNavs = [
{
title: "Analytics",
link: "/analytics/exif",
icon: <Image className="h-4 w-4" />,
icon: <ImageIcon className="h-4 w-4" />,
},
{
title: "Potential Albums",
@@ -26,9 +36,11 @@ export const sidebarNavs = [
link: "/albums",
icon: <GalleryHorizontal className="h-4 w-4" />,
},
{
title: "Rewind 2024",
link: "/rewind",
icon: <Rewind className="h-4 w-4" />,
},
title: "Geo Heatmap",
link: "/assets/geo-heatmap",
icon: <MapPin className="h-4 w-4" />,
}
];
+3 -1
View File
@@ -5,7 +5,9 @@ export const ENV = {
DATABASE_URL: (process.env.DATABASE_URL || `postgresql://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${(process.env.DB_HOST || 'immich_postgres')}:${(process.env.DB_PORT || '5432')}/${process.env.DB_DATABASE_NAME}`),
JWT_SECRET: process.env.JWT_SECRET as string,
SECURE_COOKIE: process.env.SECURE_COOKIE === 'true',
VERSION: process.env.VERSION
VERSION: process.env.VERSION,
GEMINI_API_KEY: process.env.GEMINI_API_KEY as string,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY as string,
};
+4 -1
View File
@@ -34,6 +34,7 @@ export const UPDATE_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/assets";
export const ASSET_THUMBNAIL_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id;
export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id + "?size=preview";
export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id;
export const ASSET_GEO_HEATMAP_PATH = BASE_API_ENDPOINT + "/assets/geo-heatmap";
// Location
@@ -51,4 +52,6 @@ export const GET_FILTERS = BASE_API_ENDPOINT + "/filters/asset-filters";
export const GET_PERSON_INFO = (personId: string) => BASE_API_ENDPOINT + "/people/" + personId + "/info";
// Rewind
export const REWIND_STATS = BASE_API_ENDPOINT + "/rewind/stats";
export const REWIND_STATS = BASE_API_ENDPOINT + "/rewind/stats";
// Find
export const FIND_ASSETS = BASE_API_ENDPOINT + "/find/search";
+4 -1
View File
@@ -1,15 +1,18 @@
import { createContext, useContext } from "react";
interface ConfigContextType {
export interface ConfigContextType {
immichURL: string;
exImmichUrl: string;
version?: string;
geminiEnabled: boolean;
googleMapsApiKey: string;
}
const ConfigContext = createContext<ConfigContextType>({
immichURL: "",
exImmichUrl: "",
version: "",
geminiEnabled: false,
});
export default ConfigContext;
+11 -1
View File
@@ -1,5 +1,7 @@
import {
ADD_ASSETS_ALBUMS_PATH,
ASSET_GEO_HEATMAP_PATH,
FIND_ASSETS,
LIST_ALBUMS_PATH,
LIST_MISSING_LOCATION_ASSETS_PATH,
LIST_MISSING_LOCATION_DATES_PATH,
@@ -45,4 +47,12 @@ export interface IUpdateAssetsParams {
export const updateAssets = async (params: IUpdateAssetsParams) => {
return API.put(UPDATE_ASSETS_PATH, params);
}
export const findAssets = async (query: string) => {
return API.post(FIND_ASSETS, { query });
}
export const getAssetGeoHeatmap = async () => {
return API.get(ASSET_GEO_HEATMAP_PATH);
}
+1 -1
View File
@@ -11,4 +11,4 @@ export const cleanUpAsset = (asset: IAsset): IAsset => {
previewUrl: ASSET_PREVIEW_PATH(asset.id),
videoURL: ASSET_VIDEO_PATH(asset.id),
}
}
}
+14
View File
@@ -3,4 +3,18 @@ export const stringToBoolean = (value: string | boolean): boolean => {
return value;
}
return value === 'true';
}
export const removeNullOrUndefinedProperties = (obj: any) => {
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => {
if (Array.isArray(v)) {
return v.length > 0;
}
if (typeof v === 'number') {
return v !== null && v !== undefined && v !== 0;
}
return v !== null && v !== undefined && v !== '' && v !== 'null' && v !== 'undefined' && v !== 'null' && v !== 'undefined'
}));
}
+87
View File
@@ -0,0 +1,87 @@
import { ENV } from "@/config/environment";
import { GoogleGenerativeAI, SchemaType } from "@google/generative-ai";
import { z } from "zod";
import { removeNullOrUndefinedProperties } from "./data.helper";
const gemini = new GoogleGenerativeAI(ENV.GEMINI_API_KEY);
const model = gemini.getGenerativeModel({ model: "gemini-1.5-flash" });
interface FindQuery {
query: string;
personIds?: string[];
city?: string;
size?: number;
model?: string;
createdAfter?: string;
state?: string;
}
const responseSchema = {
type: SchemaType.OBJECT,
properties: {
query: { type: SchemaType.STRING, description: "Gist of the query. Remove the details of tags" },
personIds: { type: SchemaType.ARRAY,
description: "List of person ids that match the query which starts with @",
items: { type: SchemaType.STRING }
},
city: { type: SchemaType.STRING,
description: "City of the query"
},
size: {
type: SchemaType.INTEGER,
description: "Size of the file in bytes"
},
country: {
type: SchemaType.STRING,
description: "Any reference to a country (United States, Canada, etc) in the query, return full country name"
},
state: {
type: SchemaType.STRING,
description: "Any reference to a state (California, New York, etc) in the query"
},
takenAfter: {
type: SchemaType.STRING,
description: "Extract the date from the query and return it in the format YYYY-MM-DD but only if it is a valid date"
},
takenBefore: {
type: SchemaType.STRING,
description: "Extract the date from the query and return it in the format YYYY-MM-DD but only if it is a valid date"
},
model: {
type: SchemaType.STRING,
description: "Name of the device that the query is about"
},
},
required: ["query"],
};
export const parseFindQuery = async (query: string): Promise<FindQuery> => {
const prompt = `
Parse the following query and return the query and tags: ${query}
Additional Information For Parsing:
today's date is ${new Date().toISOString().split('T')[0]}
`;
const { response } = await model.generateContent({
contents: [
{
role: "user",
parts: [
{
text: prompt,
},
],
},
],
generationConfig: {
responseMimeType: "application/json",
responseSchema,
}
});
const parsedResponse = JSON.parse(response.text()) as FindQuery;
const cleanedResponse = {
...parsedResponse
};
return removeNullOrUndefinedProperties(cleanedResponse) as any as FindQuery;
}
+5 -7
View File
@@ -3,14 +3,10 @@ import "@/styles/globals.scss";
import type { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";
import { ENV } from "@/config/environment";
import ConfigContext from "@/contexts/ConfigContext";
import ConfigContext, { ConfigContextType } from "@/contexts/ConfigContext";
import { useRef } from "react";
interface AppPropsWithProps extends AppProps {
props: {
immichURL: string;
exImmichUrl: string;
version?: string;
};
props: ConfigContextType;
}
const App = ({ Component, pageProps, ...props }: AppPropsWithProps) => {
const intialData = useRef(props.props);
@@ -32,7 +28,9 @@ App.getInitialProps = async () => {
props: {
exImmichUrl: ENV.EXTERNAL_IMMICH_URL,
immichURL: ENV.IMMICH_URL,
version: ENV.VERSION
version: ENV.VERSION,
geminiEnabled: !!ENV.GEMINI_API_KEY?.length,
googleMapsApiKey: ENV.GOOGLE_MAPS_API_KEY,
},
};
};
+33
View File
@@ -0,0 +1,33 @@
import { NextApiResponse } from "next";
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiRequest } from "next";
import { assets, exif } from "@/schema";
import { and, eq, isNotNull } from "drizzle-orm";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const currentUser = await getCurrentUser(req);
if (!currentUser) {
return res.status(401).json({ message: 'Unauthorized' });
}
const dbAssets = await db.select({
assetId: assets.id,
latitude: exif.latitude,
longitude: exif.longitude,
}).from(assets)
.innerJoin(exif, eq(assets.id, exif.assetId))
.where(
and(
eq(assets.ownerId, currentUser.id),
isNotNull(exif.latitude),
isNotNull(exif.longitude)
)
);
const heatmapData = dbAssets.map((asset) => [
asset.longitude,
asset.latitude,
]);
res.status(200).json(heatmapData);
}
+42
View File
@@ -0,0 +1,42 @@
import { db } from '@/config/db';
import { ENV } from '@/config/environment';
import { getCurrentUser } from '@/handlers/serverUtils/user.utils';
import { parseFindQuery } from '@/helpers/gemini.helper';
import { assetFaces, assets, person } from '@/schema';
import { and, eq, inArray } from 'drizzle-orm';
import { NextApiRequest, NextApiResponse } from 'next';
export default async function search(req: NextApiRequest, res: NextApiResponse) {
const { query } = req.body;
const currentUser = await getCurrentUser(req);
const parsedQuery = await parseFindQuery(query as string);
const url = ENV.IMMICH_URL + "/api/search/smart";
return fetch(url, {
method: 'POST',
body: JSON.stringify(parsedQuery),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentUser?.accessToken}`
}
}).then(response => {
if (response.status !== 200) {
return res.json({
assets: [],
filters: parsedQuery,
error: "Failed to fetch assets",
errorData: response.json()
});
}
return response.json();
})
.then(data => {
res.status(200).json({
assets: data.assets.items,
filters: parsedQuery
});
}).catch(err => {
res.status(500).json({ error: err.message });
});
}
+96
View File
@@ -0,0 +1,96 @@
import Header from '@/components/shared/Header'
import { useConfig } from '@/contexts/ConfigContext';
import React, { useEffect, useMemo, useState } from 'react'
import { useMap, useMapsLibrary } from '@vis.gl/react-google-maps';
import { APIProvider, Map } from '@vis.gl/react-google-maps';
import PageLayout from '@/components/layouts/PageLayout';
import { getAssetGeoHeatmap } from '@/handlers/api/asset.handler';
import { useTheme } from 'next-themes';
import { MapPinX } from 'lucide-react';
const HeatMap = () => {
const map = useMap();
const visualization = useMapsLibrary('visualization');
const heatmap = useMemo(() => {
if (!visualization) return null;
return new google.maps.visualization.HeatmapLayer({
radius: 20,
opacity: 0.5
});
}, [visualization]);
useEffect(() => {
if (!heatmap) return;
getAssetGeoHeatmap().then((data) => {
console.log(data);
heatmap.setData(data.map(([lng, lat]: [number, number]) => ({
location: new google.maps.LatLng(lat, lng),
weight: 10
})));
});
}, [heatmap]);
useEffect(() => {
if (!heatmap) return;
heatmap.setMap(map);
return () => {
heatmap.setMap(null);
};
}, [heatmap, map]);
return null;
}
export default function GeoHeatmap() {
const { googleMapsApiKey } = useConfig();
const { theme } = useTheme();
const [mapId, setMapId] = useState('7a9e2ebecd32a903');
useEffect(() => {
if (theme === 'light') {
setMapId("");
} else {
setMapId('7a9e2ebecd32a903');
}
}, [theme]);
return (
<PageLayout className="!p-0 !mb-0">
<Header leftComponent="Geo Heatmap" />
<div className='h-full w-full'>
{googleMapsApiKey ? (
<APIProvider apiKey={googleMapsApiKey}>
<Map
defaultCenter={{ lat: 0, lng: 0 }}
mapId={mapId}
defaultZoom={2}
gestureHandling={'greedy'}
disableDefaultUI={true}
className='h-full w-full'
/>
<HeatMap />
</APIProvider>
) : (
<div className='h-full w-full flex items-center justify-center flex-col gap-2'>
<MapPinX className='w-10 h-10 text-muted-foreground' />
<p className='text-muted-foreground'>No Google Maps API key found</p>
<p className='text-muted-foreground'>
Please create and add your Google Maps API key in the env file under <kbd className='bg-zinc-200 text-black dark:text-white px-1 py-0.5 rounded-md dark:bg-zinc-500'>GOOGLE_MAPS_API_KEY</kbd>.
</p>
</div>
)}
</div>
</PageLayout>
)
}
+134
View File
@@ -0,0 +1,134 @@
import "yet-another-react-lightbox/styles.css";
import FindInput from '@/components/find/FindInput';
import PageLayout from '@/components/layouts/PageLayout';
import Header from '@/components/shared/Header';
import Loader from '@/components/ui/loader';
import { ASSET_PREVIEW_PATH, ASSET_THUMBNAIL_PATH } from '@/config/routes';
import { useConfig } from '@/contexts/ConfigContext';
import { findAssets } from '@/handlers/api/asset.handler';
import { IAsset } from '@/types/asset';
import { Captions, Megaphone, Search, Speaker, TriangleAlert, WandSparkles } from 'lucide-react';
import Image from 'next/image';
import React, { useMemo, useState } from 'react'
import Lightbox from 'yet-another-react-lightbox';
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function FindPage() {
const [index, setIndex] = useState(-1);
const { geminiEnabled, exImmichUrl } = useConfig();
const [query, setQuery] = useState('');
const [assets, setAssets] = useState<IAsset[]>([]);
const [loading, setLoading] = useState(false);
const slides = useMemo(
() =>
assets.map((asset) => ({
src: ASSET_PREVIEW_PATH(asset.id),
width: 1000,
height: 1000,
})),
[assets]
);
const handleSearch = (query: string) => {
setQuery(query);
setLoading(true);
findAssets(query)
.then(({ assets }) => {
setAssets(assets);
})
.finally(() => {
setLoading(false);
});
}
const renderContent = () => {
if (loading) {
return <div className="flex justify-center items-center h-full">
<Loader />
</div>
}
else if (query.length === 0) {
return <div className="flex justify-center flex-col gap-2 items-center h-full">
<Search className='w-10 h-10 text-muted-foreground' />
<p className='text-lg font-bold'>Search for photos in natural language</p>
<p className='text-sm text-muted-foreground'>
Example: Sunset photos from last week. Use <kbd className='bg-zinc-200 text-black dark:text-white px-1 py-0.5 rounded-md dark:bg-zinc-500'>@</kbd> to search for photos of a specific person.
</p>
<p className='text-xs text-muted-foreground text-center flex gap-1 items-center'>
<span>Power tools uses Google Gemini only for parsing your query. None of your data is sent to Gemini.</span>
</p>
</div>
}
else if (assets.length === 0) {
return <div className="flex justify-center items-center h-full">
No results found
</div>
}
return (
<>
<Lightbox
slides={slides}
open={index >= 0}
index={index}
close={() => setIndex(-1)}
/>
<div className="grid grid-cols-5 gap-4 p-2">
{assets.map((asset, idx) => (
<div key={asset.id} className='w-full h-[200px] rounded-md overflow-hidden relative'>
<Link href={`${exImmichUrl}/photos/${asset.id}`}
target="_blank"
className='absolute top-2 right-2 text-xs'>
Open in Immich
</Link>
<Image
src={ASSET_THUMBNAIL_PATH(asset.id)}
alt={asset.id}
style={{
objectFit: 'cover',
width: '100%',
height: '100%',
}}
width={200}
height={200}
onClick={() => setIndex(idx)}
/>
</div>
))}
</div>
</>
)
}
return (
<PageLayout>
<Header leftComponent="Find" />
{geminiEnabled ? (
<>
<div className="flex flex-col gap-4 p-2">
<FindInput onSearch={handleSearch} />
</div>
{renderContent()}
</>
) : (
<div className="flex justify-center py-10 items-center h-full flex-col gap-2">
<WandSparkles className='w-10 h-10 text-muted-foreground' />
<p className='text-lg font-semibold'>Google Gemini is not enabled</p>
<p className='text-sm text-muted-foreground max-w-md text-center'>
Currently, the Power Tools Find is relying on Google Gemini for parsing the query.
Please enable Gemini by adding <kbd className='bg-zinc-200 text-black dark:text-white px-1 py-0.5 rounded-md dark:bg-zinc-500'>GEMINI_API_KEY</kbd> in the <kbd className='bg-zinc-200 text-black dark:text-white px-1 py-0.5 rounded-md dark:bg-zinc-500'>.env</kbd> file.
</p>
<div className="border border-l-4 border-zinc-200 dark:border-zinc-500 rounded-md p-2">
<p className='text-xs text-muted-foreground text-center flex gap-1 items-center'>
<span>Power tools uses Google Gemini only for parsing your query. None of your data is sent to Gemini.</span>
</p>
</div>
</div>
)}
</PageLayout>
)
}
+1 -1
View File
@@ -31,7 +31,6 @@
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
@@ -88,3 +87,4 @@
--chart-5: 340 75% 55%;
}
}