feat: default locale Russian, geo determines language for other countries
- localization-svc: defaultLocale ru, resolveLocale only by geo - web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru - countryToLocale: default ru when no country or unknown country Co-authored-by: Cursor <cursoragent@cursor.com>
3
services/web-svc/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
26
services/web-svc/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# web-svc — Next.js UI, standalone output
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY services ./services
|
||||
RUN npm ci
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# K8s: rewrites запекаются при сборке, нужен URL api-gateway
|
||||
ARG API_GATEWAY_URL=http://api-gateway.gooseek:3015
|
||||
ENV API_GATEWAY_URL=${API_GATEWAY_URL}
|
||||
RUN npm run build -w web-svc
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder /app/services/web-svc/public ./services/web-svc/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/services/web-svc/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/services/web-svc/.next/static ./services/web-svc/.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "services/web-svc/server.js"]
|
||||
2
services/web-svc/data/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
6
services/web-svc/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
65
services/web-svc/next.config.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
// Загружаем .env из корня монорепо (apps/frontend -> корень)
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, '..', '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
require('dotenv').config({ path: path.join(rootDir, '.env') });
|
||||
|
||||
import pkg from './package.json' with { type: 'json' };
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ hostname: 's2.googleusercontent.com' },
|
||||
{ hostname: 'images.unsplash.com' },
|
||||
{ hostname: 'placehold.co' },
|
||||
],
|
||||
},
|
||||
serverExternalPackages: ['pdf-parse'],
|
||||
outputFileTracingIncludes: {
|
||||
'/api/**': [
|
||||
'./node_modules/@napi-rs/canvas/**',
|
||||
'./node_modules/@napi-rs/canvas-linux-x64-gnu/**',
|
||||
'./node_modules/@napi-rs/canvas-linux-x64-musl/**',
|
||||
],
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_VERSION: pkg.version,
|
||||
},
|
||||
async rewrites() {
|
||||
const gateway = process.env.API_GATEWAY_URL ?? 'http://localhost:3015';
|
||||
return [
|
||||
{ source: '/api/chat', destination: `${gateway}/api/chat` },
|
||||
{ source: '/api/v1/:path*', destination: `${gateway}/api/v1/:path*` },
|
||||
{ source: '/api/config', destination: `${gateway}/api/v1/config` },
|
||||
{ source: '/api/config/:path*', destination: `${gateway}/api/v1/config/:path*` },
|
||||
{ source: '/api/providers', destination: `${gateway}/api/v1/providers` },
|
||||
{ source: '/api/providers/:path*', destination: `${gateway}/api/v1/providers/:path*` },
|
||||
{ source: '/api/weather', destination: `${gateway}/api/v1/weather` },
|
||||
{ source: '/api/auth/:path*', destination: `${gateway}/api/auth/:path*` },
|
||||
{ source: '/api/locale', destination: `${gateway}/api/locale` },
|
||||
{ source: '/api/translations/:path*', destination: `${gateway}/api/translations/:path*` },
|
||||
{ source: '/api/geo-context', destination: `${gateway}/api/geo-context` },
|
||||
{ source: '/api/images', destination: `${gateway}/api/images` },
|
||||
{ source: '/api/videos', destination: `${gateway}/api/videos` },
|
||||
{ source: '/api/suggestions', destination: `${gateway}/api/suggestions` },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const withPWA = require('@ducanh2912/next-pwa').default({
|
||||
dest: 'public',
|
||||
disable: process.env.NODE_ENV === 'development',
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
fallbacks: {
|
||||
document: '/offline',
|
||||
},
|
||||
});
|
||||
|
||||
export default withPWA(nextConfig);
|
||||
75
services/web-svc/package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "web-svc",
|
||||
"version": "1.12.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-auth": "^1.4.0",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@google/genai": "^1.34.0",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@headlessui/tailwindcss": "^0.2.2",
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@icons-pack/react-simple-icons": "^12.3.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/typography": "^0.5.12",
|
||||
"@toolsycc/json-repair": "^0.1.22",
|
||||
"axios": "^1.8.3",
|
||||
"clsx": "^2.1.0",
|
||||
"js-tiktoken": "^1.0.21",
|
||||
"jspdf": "^3.0.4",
|
||||
"lightweight-charts": "^5.0.9",
|
||||
"lucide-react": "^0.556.0",
|
||||
"mammoth": "^1.9.1",
|
||||
"markdown-to-jsx": "^7.7.2",
|
||||
"mathjs": "^15.1.0",
|
||||
"motion": "^12.23.26",
|
||||
"next": "^16.0.7",
|
||||
"next-themes": "^0.3.0",
|
||||
"officeparser": "^5.2.2",
|
||||
"ollama": "^0.6.3",
|
||||
"openai": "^6.9.0",
|
||||
"partial-json": "^0.1.7",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-text-to-speech": "^0.14.5",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"rfc6902": "^5.1.2",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"turndown": "^7.2.2",
|
||||
"yahoo-finance2": "^3.10.2",
|
||||
"yet-another-react-lightbox": "^3.17.2",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jspdf": "^2.0.0",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/trusted-types": "^2.0.7",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/unist": "^3.0.3",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.1.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"webpack": "^5.105.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.87"
|
||||
}
|
||||
}
|
||||
6
services/web-svc/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
services/web-svc/public/fonts/pp-ed-ul.otf
Normal file
BIN
services/web-svc/public/icon-100.png
Normal file
|
After Width: | Height: | Size: 916 B |
BIN
services/web-svc/public/icon-50.png
Normal file
|
After Width: | Height: | Size: 515 B |
BIN
services/web-svc/public/icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
11
services/web-svc/public/logo.svg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
1
services/web-svc/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
services/web-svc/public/screenshots/p1.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
services/web-svc/public/screenshots/p1_small.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
services/web-svc/public/screenshots/p2.png
Normal file
|
After Width: | Height: | Size: 627 KiB |
BIN
services/web-svc/public/screenshots/p2_small.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
1
services/web-svc/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
131
services/web-svc/public/weather-ico/clear-day.svg
Normal file
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.34167" y="-.34167" width="1.6833" height="1.85">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** SUN
|
||||
*/
|
||||
@keyframes am-weather-sun {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun {
|
||||
-webkit-animation-name: am-weather-sun;
|
||||
-moz-animation-name: am-weather-sun;
|
||||
-ms-animation-name: am-weather-sun;
|
||||
animation-name: am-weather-sun;
|
||||
-webkit-animation-duration: 9s;
|
||||
-moz-animation-duration: 9s;
|
||||
-ms-animation-duration: 9s;
|
||||
animation-duration: 9s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-sun-shiny {
|
||||
0% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
|
||||
50% {
|
||||
stroke-dasharray: 0.1px 10px;
|
||||
stroke-dashoffset: -1px;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun-shiny line {
|
||||
-webkit-animation-name: am-weather-sun-shiny;
|
||||
-moz-animation-name: am-weather-sun-shiny;
|
||||
-ms-animation-name: am-weather-sun-shiny;
|
||||
animation-name: am-weather-sun-shiny;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="translate(0,16)">
|
||||
<g class="am-weather-sun"
|
||||
style="-moz-animation-duration:9s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-sun;-moz-animation-timing-function:linear;-ms-animation-duration:9s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-sun;-ms-animation-timing-function:linear;-webkit-animation-duration:9s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-sun;-webkit-animation-timing-function:linear">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round" stroke-width="2" />
|
||||
<g transform="rotate(45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(135)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="scale(-1)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(225)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<circle r="5" fill="#ffa500" stroke="#ffa500" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
159
services/web-svc/public/weather-ico/clear-night.svg
Normal file
@@ -0,0 +1,159 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.3038" y="-.3318" width="1.6076" height="1.894">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** MOON
|
||||
*/
|
||||
@keyframes am-weather-moon {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
-moz-transform: rotate(15deg);
|
||||
-ms-transform: rotate(15deg);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon {
|
||||
-webkit-animation-name: am-weather-moon;
|
||||
-moz-animation-name: am-weather-moon;
|
||||
-ms-animation-name: am-weather-moon;
|
||||
animation-name: am-weather-moon;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
-webkit-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-moz-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-ms-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-1 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-1 {
|
||||
-webkit-animation-name: am-weather-moon-star-1;
|
||||
-moz-animation-name: am-weather-moon-star-1;
|
||||
-ms-animation-name: am-weather-moon-star-1;
|
||||
animation-name: am-weather-moon-star-1;
|
||||
-webkit-animation-delay: 3s;
|
||||
-moz-animation-delay: 3s;
|
||||
-ms-animation-delay: 3s;
|
||||
animation-delay: 3s;
|
||||
-webkit-animation-duration: 5s;
|
||||
-moz-animation-duration: 5s;
|
||||
-ms-animation-duration: 5s;
|
||||
animation-duration: 5s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-2 {
|
||||
-webkit-animation-name: am-weather-moon-star-2;
|
||||
-moz-animation-name: am-weather-moon-star-2;
|
||||
-ms-animation-name: am-weather-moon-star-2;
|
||||
animation-name: am-weather-moon-star-2;
|
||||
-webkit-animation-delay: 5s;
|
||||
-moz-animation-delay: 5s;
|
||||
-ms-animation-delay: 5s;
|
||||
animation-delay: 5s;
|
||||
-webkit-animation-duration: 4s;
|
||||
-moz-animation-duration: 4s;
|
||||
-ms-animation-duration: 4s;
|
||||
animation-duration: 4s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="night" transform="translate(-4,-18)" filter="url(#blur)">
|
||||
<g transform="matrix(.8 0 0 .78534 36 20.022)" stroke-width="1.2616">
|
||||
<g class="am-weather-moon-star-1"
|
||||
style="-moz-animation-delay:3s;-moz-animation-duration:5s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-1;-moz-animation-timing-function:linear;-ms-animation-delay:3s;-ms-animation-duration:5s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-1;-ms-animation-timing-function:linear;-webkit-animation-delay:3s;-webkit-animation-duration:5s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-1;-webkit-animation-timing-function:linear">
|
||||
<polygon points="4 2.7 5.2 3.3 4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5" fill="#ffa500" stroke-miterlimit="10"
|
||||
stroke-width="1.4105" />
|
||||
</g>
|
||||
<g class="am-weather-moon-star-2"
|
||||
style="-moz-animation-delay:5s;-moz-animation-duration:4s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-2;-moz-animation-timing-function:linear;-ms-animation-delay:5s;-ms-animation-duration:4s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-2;-ms-animation-timing-function:linear;-webkit-animation-delay:5s;-webkit-animation-duration:4s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-2;-webkit-animation-timing-function:linear">
|
||||
<polygon transform="translate(20,10)" points="4 2.7 5.2 3.3 4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5"
|
||||
fill="#ffa500" stroke-miterlimit="10" stroke-width="1.4105" />
|
||||
</g>
|
||||
<g class="am-weather-moon"
|
||||
style="-moz-animation-duration:6s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-moon;-moz-animation-timing-function:linear;-moz-transform-origin:12.5px 15.15px 0;-ms-animation-duration:6s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-moon;-ms-animation-timing-function:linear;-ms-transform-origin:12.5px 15.15px 0;-webkit-animation-duration:6s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-moon;-webkit-animation-timing-function:linear;-webkit-transform-origin:12.5px 15.15px 0">
|
||||
<path
|
||||
d="m14.5 13.2c0-3.7 2-6.9 5-8.7-1.5-0.9-3.2-1.3-5-1.3-5.5 0-10 4.5-10 10s4.5 10 10 10c1.8 0 3.5-0.5 5-1.3-3-1.7-5-5-5-8.7z"
|
||||
fill="#ffa500" stroke="#ffa500" stroke-linejoin="round" stroke-width="2.5232" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
178
services/web-svc/public/weather-ico/cloudy-1-day.svg
Normal file
@@ -0,0 +1,178 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.28472" width="1.403" height="1.6944">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** SUN
|
||||
*/
|
||||
@keyframes am-weather-sun {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun {
|
||||
-webkit-animation-name: am-weather-sun;
|
||||
-moz-animation-name: am-weather-sun;
|
||||
-ms-animation-name: am-weather-sun;
|
||||
animation-name: am-weather-sun;
|
||||
-webkit-animation-duration: 9s;
|
||||
-moz-animation-duration: 9s;
|
||||
-ms-animation-duration: 9s;
|
||||
animation-duration: 9s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-sun-shiny {
|
||||
0% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
|
||||
50% {
|
||||
stroke-dasharray: 0.1px 10px;
|
||||
stroke-dashoffset: -1px;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun-shiny line {
|
||||
-webkit-animation-name: am-weather-sun-shiny;
|
||||
-moz-animation-name: am-weather-sun-shiny;
|
||||
-ms-animation-name: am-weather-sun-shiny;
|
||||
animation-name: am-weather-sun-shiny;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="translate(0,16)">
|
||||
<g class="am-weather-sun"
|
||||
style="-moz-animation-duration:9s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-sun;-moz-animation-timing-function:linear;-ms-animation-duration:9s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-sun;-ms-animation-timing-function:linear;-webkit-animation-duration:9s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-sun;-webkit-animation-timing-function:linear">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round" stroke-width="2" />
|
||||
<g transform="rotate(45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(135)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="scale(-1)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(225)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<circle r="5" fill="#ffa500" stroke="#ffa500" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-2"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#c6deff" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
206
services/web-svc/public/weather-ico/cloudy-1-night.svg
Normal file
@@ -0,0 +1,206 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.19471" y="-.26087" width="1.3744" height="1.6884">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** MOON
|
||||
*/
|
||||
@keyframes am-weather-moon {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
-moz-transform: rotate(15deg);
|
||||
-ms-transform: rotate(15deg);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon {
|
||||
-webkit-animation-name: am-weather-moon;
|
||||
-moz-animation-name: am-weather-moon;
|
||||
-ms-animation-name: am-weather-moon;
|
||||
animation-name: am-weather-moon;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
-webkit-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-moz-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-ms-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-1 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-1 {
|
||||
-webkit-animation-name: am-weather-moon-star-1;
|
||||
-moz-animation-name: am-weather-moon-star-1;
|
||||
-ms-animation-name: am-weather-moon-star-1;
|
||||
animation-name: am-weather-moon-star-1;
|
||||
-webkit-animation-delay: 3s;
|
||||
-moz-animation-delay: 3s;
|
||||
-ms-animation-delay: 3s;
|
||||
animation-delay: 3s;
|
||||
-webkit-animation-duration: 5s;
|
||||
-moz-animation-duration: 5s;
|
||||
-ms-animation-duration: 5s;
|
||||
animation-duration: 5s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-2 {
|
||||
-webkit-animation-name: am-weather-moon-star-2;
|
||||
-moz-animation-name: am-weather-moon-star-2;
|
||||
-ms-animation-name: am-weather-moon-star-2;
|
||||
animation-name: am-weather-moon-star-2;
|
||||
-webkit-animation-delay: 5s;
|
||||
-moz-animation-delay: 5s;
|
||||
-ms-animation-delay: 5s;
|
||||
animation-delay: 5s;
|
||||
-webkit-animation-duration: 4s;
|
||||
-moz-animation-duration: 4s;
|
||||
-ms-animation-duration: 4s;
|
||||
animation-duration: 4s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="matrix(.8 0 0 .8 16 4)">
|
||||
<g class="am-weather-moon-star-1"
|
||||
style="-moz-animation-delay:3s;-moz-animation-duration:5s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-1;-moz-animation-timing-function:linear;-ms-animation-delay:3s;-ms-animation-duration:5s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-1;-ms-animation-timing-function:linear;-webkit-animation-delay:3s;-webkit-animation-duration:5s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-1;-webkit-animation-timing-function:linear">
|
||||
<polygon points="1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3 4 4 3.3 5.2 2.7 4" fill="#ffa500"
|
||||
stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon-star-2"
|
||||
style="-moz-animation-delay:5s;-moz-animation-duration:4s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-2;-moz-animation-timing-function:linear;-ms-animation-delay:5s;-ms-animation-duration:4s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-2;-ms-animation-timing-function:linear;-webkit-animation-delay:5s;-webkit-animation-duration:4s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-2;-webkit-animation-timing-function:linear">
|
||||
<polygon transform="translate(20,10)" points="1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3 4 4 3.3 5.2 2.7 4"
|
||||
fill="#ffa500" stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon"
|
||||
style="-moz-animation-duration:6s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-moon;-moz-animation-timing-function:linear;-moz-transform-origin:12.5px 15.15px 0;-ms-animation-duration:6s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-moon;-ms-animation-timing-function:linear;-ms-transform-origin:12.5px 15.15px 0;-webkit-animation-duration:6s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-moon;-webkit-animation-timing-function:linear;-webkit-transform-origin:12.5px 15.15px 0">
|
||||
<path
|
||||
d="m14.5 13.2c0-3.7 2-6.9 5-8.7-1.5-0.9-3.2-1.3-5-1.3-5.5 0-10 4.5-10 10s4.5 10 10 10c1.8 0 3.5-0.5 5-1.3-3-1.7-5-5-5-8.7z"
|
||||
fill="#ffa500" stroke="#ffa500" stroke-linejoin="round" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-2"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#c6deff" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.6 KiB |
244
services/web-svc/public/weather-ico/fog-day.svg
Normal file
@@ -0,0 +1,244 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.21122" width="1.403" height="1.4997">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** SUN
|
||||
*/
|
||||
@keyframes am-weather-sun {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun {
|
||||
-webkit-animation-name: am-weather-sun;
|
||||
-moz-animation-name: am-weather-sun;
|
||||
-ms-animation-name: am-weather-sun;
|
||||
animation-name: am-weather-sun;
|
||||
-webkit-animation-duration: 9s;
|
||||
-moz-animation-duration: 9s;
|
||||
-ms-animation-duration: 9s;
|
||||
animation-duration: 9s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** FOG
|
||||
*/
|
||||
@keyframes am-weather-fog-1 {
|
||||
0% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(7px, 0px)
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-fog-1 {
|
||||
-webkit-animation-name: am-weather-fog-1;
|
||||
-moz-animation-name: am-weather-fog-1;
|
||||
-ms-animation-name: am-weather-fog-1;
|
||||
animation-name: am-weather-fog-1;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-fog-2 {
|
||||
0% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
|
||||
21.05% {
|
||||
transform: translate(-6px, 0px)
|
||||
}
|
||||
|
||||
78.95% {
|
||||
transform: translate(9px, 0px)
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-fog-2 {
|
||||
-webkit-animation-name: am-weather-fog-2;
|
||||
-moz-animation-name: am-weather-fog-2;
|
||||
-ms-animation-name: am-weather-fog-2;
|
||||
animation-name: am-weather-fog-2;
|
||||
-webkit-animation-duration: 20s;
|
||||
-moz-animation-duration: 20s;
|
||||
-ms-animation-duration: 20s;
|
||||
animation-duration: 20s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-fog-3 {
|
||||
0% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translate(4px, 0px)
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translate(-4px, 0px)
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-fog-3 {
|
||||
-webkit-animation-name: am-weather-fog-3;
|
||||
-moz-animation-name: am-weather-fog-3;
|
||||
-ms-animation-name: am-weather-fog-3;
|
||||
animation-name: am-weather-fog-3;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-fog-4 {
|
||||
0% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-4px, 0px)
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-fog-4 {
|
||||
-webkit-animation-name: am-weather-fog-4;
|
||||
-moz-animation-name: am-weather-fog-4;
|
||||
-ms-animation-name: am-weather-fog-4;
|
||||
animation-name: am-weather-fog-4;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="translate(0,16)">
|
||||
<g class="am-weather-sun" transform="translate(0,16)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round" stroke-width="2" />
|
||||
<g transform="rotate(45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(135)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="scale(-1)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />F
|
||||
</g>
|
||||
<g transform="rotate(225)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<circle r="5" fill="#ffc04a" stroke="#ffc04a" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-fog" transform="translate(-10,20)" fill="none" stroke="#c6deff" stroke-linecap="round"
|
||||
stroke-width="2">
|
||||
<line class="am-weather-fog-1" y1="0" y2="0" x1="1" x2="37" stroke-dasharray="3, 5, 17, 5, 7" />
|
||||
<line class="am-weather-fog-2" y1="5" y2="5" x1="9" x2="33" stroke-dasharray="11, 7, 15" />
|
||||
<line class="am-weather-fog-3" y1="10" y2="10" x1="5" x2="40" stroke-dasharray="11, 7, 3, 5, 9" />
|
||||
<line class="am-weather-fog-4" y1="15" y2="15" x1="7" x2="42" stroke-dasharray="13, 5, 9, 5, 3" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
309
services/web-svc/public/weather-ico/fog-night.svg
Normal file
@@ -0,0 +1,309 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.21122" width="1.403" height="1.4997">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** MOON
|
||||
*/
|
||||
@keyframes am-weather-moon {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
-moz-transform: rotate(15deg);
|
||||
-ms-transform: rotate(15deg);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon {
|
||||
-webkit-animation-name: am-weather-moon;
|
||||
-moz-animation-name: am-weather-moon;
|
||||
-ms-animation-name: am-weather-moon;
|
||||
animation-name: am-weather-moon;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
-webkit-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-moz-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-ms-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-1 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-1 {
|
||||
-webkit-animation-name: am-weather-moon-star-1;
|
||||
-moz-animation-name: am-weather-moon-star-1;
|
||||
-ms-animation-name: am-weather-moon-star-1;
|
||||
animation-name: am-weather-moon-star-1;
|
||||
-webkit-animation-delay: 3s;
|
||||
-moz-animation-delay: 3s;
|
||||
-ms-animation-delay: 3s;
|
||||
animation-delay: 3s;
|
||||
-webkit-animation-duration: 5s;
|
||||
-moz-animation-duration: 5s;
|
||||
-ms-animation-duration: 5s;
|
||||
animation-duration: 5s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-2 {
|
||||
-webkit-animation-name: am-weather-moon-star-2;
|
||||
-moz-animation-name: am-weather-moon-star-2;
|
||||
-ms-animation-name: am-weather-moon-star-2;
|
||||
animation-name: am-weather-moon-star-2;
|
||||
-webkit-animation-delay: 5s;
|
||||
-moz-animation-delay: 5s;
|
||||
-ms-animation-delay: 5s;
|
||||
animation-delay: 5s;
|
||||
-webkit-animation-duration: 4s;
|
||||
-moz-animation-duration: 4s;
|
||||
-ms-animation-duration: 4s;
|
||||
animation-duration: 4s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
** FOG
|
||||
*/
|
||||
@keyframes am-weather-fog-1 {
|
||||
0% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(7px, 0px)
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-fog-1 {
|
||||
-webkit-animation-name: am-weather-fog-1;
|
||||
-moz-animation-name: am-weather-fog-1;
|
||||
-ms-animation-name: am-weather-fog-1;
|
||||
animation-name: am-weather-fog-1;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-fog-2 {
|
||||
0% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
|
||||
21.05% {
|
||||
transform: translate(-6px, 0px)
|
||||
}
|
||||
|
||||
78.95% {
|
||||
transform: translate(9px, 0px)
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-fog-2 {
|
||||
-webkit-animation-name: am-weather-fog-2;
|
||||
-moz-animation-name: am-weather-fog-2;
|
||||
-ms-animation-name: am-weather-fog-2;
|
||||
animation-name: am-weather-fog-2;
|
||||
-webkit-animation-duration: 20s;
|
||||
-moz-animation-duration: 20s;
|
||||
-ms-animation-duration: 20s;
|
||||
animation-duration: 20s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-fog-3 {
|
||||
0% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translate(4px, 0px)
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translate(-4px, 0px)
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-fog-3 {
|
||||
-webkit-animation-name: am-weather-fog-3;
|
||||
-moz-animation-name: am-weather-fog-3;
|
||||
-ms-animation-name: am-weather-fog-3;
|
||||
animation-name: am-weather-fog-3;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-fog-4 {
|
||||
0% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-4px, 0px)
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0px, 0px)
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-fog-4 {
|
||||
-webkit-animation-name: am-weather-fog-4;
|
||||
-moz-animation-name: am-weather-fog-4;
|
||||
-ms-animation-name: am-weather-fog-4;
|
||||
animation-name: am-weather-fog-4;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="matrix(.8 0 0 .8 16 4)">
|
||||
<g class="am-weather-moon-star-1"
|
||||
style="-moz-animation-delay:3s;-moz-animation-duration:5s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-1;-moz-animation-timing-function:linear;-ms-animation-delay:3s;-ms-animation-duration:5s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-1;-ms-animation-timing-function:linear;-webkit-animation-delay:3s;-webkit-animation-duration:5s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-1;-webkit-animation-timing-function:linear">
|
||||
<polygon points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3" fill="#ffc04a"
|
||||
stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon-star-2"
|
||||
style="-moz-animation-delay:5s;-moz-animation-duration:4s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-2;-moz-animation-timing-function:linear;-ms-animation-delay:5s;-ms-animation-duration:4s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-2;-ms-animation-timing-function:linear;-webkit-animation-delay:5s;-webkit-animation-duration:4s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-2;-webkit-animation-timing-function:linear">
|
||||
<polygon transform="translate(20,10)" points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3"
|
||||
fill="#ffc04a" stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon"
|
||||
style="-moz-animation-duration:6s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-moon;-moz-animation-timing-function:linear;-moz-transform-origin:12.5px 15.15px 0;-ms-animation-duration:6s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-moon;-ms-animation-timing-function:linear;-ms-transform-origin:12.5px 15.15px 0;-webkit-animation-duration:6s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-moon;-webkit-animation-timing-function:linear;-webkit-transform-origin:12.5px 15.15px 0">
|
||||
<path
|
||||
d="m14.5 13.2c0-3.7 2-6.9 5-8.7-1.5-0.9-3.2-1.3-5-1.3-5.5 0-10 4.5-10 10s4.5 10 10 10c1.8 0 3.5-0.5 5-1.3-3-1.7-5-5-5-8.7z"
|
||||
fill="#ffc04a" stroke="#ffc04a" stroke-linejoin="round" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-fog" transform="translate(-10,20)" fill="none" stroke="#c6deff" stroke-linecap="round"
|
||||
stroke-width="2">
|
||||
<line class="am-weather-fog-1" y1="0" y2="0" x1="1" x2="37" stroke-dasharray="3, 5, 17, 5, 7" />
|
||||
<line class="am-weather-fog-2" y1="5" y2="5" x1="9" x2="33" stroke-dasharray="11, 7, 15" />
|
||||
<line class="am-weather-fog-3" y1="10" y2="10" x1="5" x2="40" stroke-dasharray="11, 7, 3, 5, 9" />
|
||||
<line class="am-weather-fog-4" y1="15" y2="15" x1="7" x2="42" stroke-dasharray="13, 5, 9, 5, 3" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
204
services/web-svc/public/weather-ico/frost-day.svg
Normal file
@@ -0,0 +1,204 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.21122" width="1.403" height="1.4997">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** SUN
|
||||
*/
|
||||
@keyframes am-weather-sun {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun {
|
||||
-webkit-animation-name: am-weather-sun;
|
||||
-moz-animation-name: am-weather-sun;
|
||||
-ms-animation-name: am-weather-sun;
|
||||
animation-name: am-weather-sun;
|
||||
-webkit-animation-duration: 9s;
|
||||
-moz-animation-duration: 9s;
|
||||
-ms-animation-duration: 9s;
|
||||
animation-duration: 9s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** FROST
|
||||
*/
|
||||
@keyframes am-weather-frost {
|
||||
0% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
1% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
3% {
|
||||
-webkit-transform: translate(-0.3px, 0.0px);
|
||||
-moz-transform: translate(-0.3px, 0.0px);
|
||||
-ms-transform: translate(-0.3px, 0.0px);
|
||||
transform: translate(-0.3px, 0.0px);
|
||||
}
|
||||
|
||||
5% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
7% {
|
||||
-webkit-transform: translate(-0.3px, 0.0px);
|
||||
-moz-transform: translate(-0.3px, 0.0px);
|
||||
-ms-transform: translate(-0.3px, 0.0px);
|
||||
transform: translate(-0.3px, 0.0px);
|
||||
}
|
||||
|
||||
9% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
11% {
|
||||
-webkit-transform: translate(-0.3px, 0.0px);
|
||||
-moz-transform: translate(-0.3px, 0.0px);
|
||||
-ms-transform: translate(-0.3px, 0.0px);
|
||||
transform: translate(-0.3px, 0.0px);
|
||||
}
|
||||
|
||||
13% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
15% {
|
||||
-webkit-transform: translate(-0.3px, 0.0px);
|
||||
-moz-transform: translate(-0.3px, 0.0px);
|
||||
-ms-transform: translate(-0.3px, 0.0px);
|
||||
transform: translate(-0.3px, 0.0px);
|
||||
}
|
||||
|
||||
16% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-frost {
|
||||
-webkit-animation-name: am-weather-frost;
|
||||
-moz-animation-name: am-weather-frost;
|
||||
animation-name: am-weather-frost;
|
||||
-webkit-animation-duration: 1.11s;
|
||||
-moz-animation-duration: 1.11s;
|
||||
animation-duration: 1.11s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="translate(0,16)">
|
||||
<g class="am-weather-sun" transform="translate(0,16)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round" stroke-width="2" />
|
||||
<g transform="rotate(45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(135)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="scale(-1)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />F
|
||||
</g>
|
||||
<g transform="rotate(225)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffc04a" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<circle r="5" fill="#ffc04a" stroke="#ffc04a" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(-16,4)">
|
||||
<g class="am-weather-frost" stroke="#57a0ee" transform="translate(0,2)" fill="none" stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
style="-moz-animation-duration:1.11s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-frost;-moz-animation-timing-function:linear;-webkit-animation-duration:1.11s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-frost;-webkit-animation-timing-function:linear">
|
||||
<path d="M11,32H45" />
|
||||
<path d="M15.5,37H40.5" />
|
||||
<path d="M22.5,42H33.5" />
|
||||
</g>
|
||||
<g>
|
||||
<path stroke="#57a0ee" transform="translate(0,0)" fill="none" stroke-width="2" stroke-linecap="round"
|
||||
d="M28,31V9M28,22l11,-3.67M34,20l2,-4M34,20l4,2M28,22l-11,-3.67M22,20l-2,-4M22,20l-4,2M28,14.27l3.01,-3.02M28,14.27l-3.01,-3.02" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
269
services/web-svc/public/weather-ico/frost-night.svg
Normal file
@@ -0,0 +1,269 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.21122" width="1.403" height="1.4997">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** MOON
|
||||
*/
|
||||
@keyframes am-weather-moon {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
-moz-transform: rotate(15deg);
|
||||
-ms-transform: rotate(15deg);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon {
|
||||
-webkit-animation-name: am-weather-moon;
|
||||
-moz-animation-name: am-weather-moon;
|
||||
-ms-animation-name: am-weather-moon;
|
||||
animation-name: am-weather-moon;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
-webkit-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-moz-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-ms-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-1 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-1 {
|
||||
-webkit-animation-name: am-weather-moon-star-1;
|
||||
-moz-animation-name: am-weather-moon-star-1;
|
||||
-ms-animation-name: am-weather-moon-star-1;
|
||||
animation-name: am-weather-moon-star-1;
|
||||
-webkit-animation-delay: 3s;
|
||||
-moz-animation-delay: 3s;
|
||||
-ms-animation-delay: 3s;
|
||||
animation-delay: 3s;
|
||||
-webkit-animation-duration: 5s;
|
||||
-moz-animation-duration: 5s;
|
||||
-ms-animation-duration: 5s;
|
||||
animation-duration: 5s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-2 {
|
||||
-webkit-animation-name: am-weather-moon-star-2;
|
||||
-moz-animation-name: am-weather-moon-star-2;
|
||||
-ms-animation-name: am-weather-moon-star-2;
|
||||
animation-name: am-weather-moon-star-2;
|
||||
-webkit-animation-delay: 5s;
|
||||
-moz-animation-delay: 5s;
|
||||
-ms-animation-delay: 5s;
|
||||
animation-delay: 5s;
|
||||
-webkit-animation-duration: 4s;
|
||||
-moz-animation-duration: 4s;
|
||||
-ms-animation-duration: 4s;
|
||||
animation-duration: 4s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
** FROST
|
||||
*/
|
||||
@keyframes am-weather-frost {
|
||||
0% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
1% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
3% {
|
||||
-webkit-transform: translate(-0.3px, 0.0px);
|
||||
-moz-transform: translate(-0.3px, 0.0px);
|
||||
-ms-transform: translate(-0.3px, 0.0px);
|
||||
transform: translate(-0.3px, 0.0px);
|
||||
}
|
||||
|
||||
5% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
7% {
|
||||
-webkit-transform: translate(-0.3px, 0.0px);
|
||||
-moz-transform: translate(-0.3px, 0.0px);
|
||||
-ms-transform: translate(-0.3px, 0.0px);
|
||||
transform: translate(-0.3px, 0.0px);
|
||||
}
|
||||
|
||||
9% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
11% {
|
||||
-webkit-transform: translate(-0.3px, 0.0px);
|
||||
-moz-transform: translate(-0.3px, 0.0px);
|
||||
-ms-transform: translate(-0.3px, 0.0px);
|
||||
transform: translate(-0.3px, 0.0px);
|
||||
}
|
||||
|
||||
13% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
15% {
|
||||
-webkit-transform: translate(-0.3px, 0.0px);
|
||||
-moz-transform: translate(-0.3px, 0.0px);
|
||||
-ms-transform: translate(-0.3px, 0.0px);
|
||||
transform: translate(-0.3px, 0.0px);
|
||||
}
|
||||
|
||||
16% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-frost {
|
||||
-webkit-animation-name: am-weather-frost;
|
||||
-moz-animation-name: am-weather-frost;
|
||||
animation-name: am-weather-frost;
|
||||
-webkit-animation-duration: 1.11s;
|
||||
-moz-animation-duration: 1.11s;
|
||||
animation-duration: 1.11s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="matrix(.8 0 0 .8 16 4)">
|
||||
<g class="am-weather-moon-star-1"
|
||||
style="-moz-animation-delay:3s;-moz-animation-duration:5s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-1;-moz-animation-timing-function:linear;-ms-animation-delay:3s;-ms-animation-duration:5s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-1;-ms-animation-timing-function:linear;-webkit-animation-delay:3s;-webkit-animation-duration:5s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-1;-webkit-animation-timing-function:linear">
|
||||
<polygon points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3" fill="#ffc04a"
|
||||
stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon-star-2"
|
||||
style="-moz-animation-delay:5s;-moz-animation-duration:4s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-2;-moz-animation-timing-function:linear;-ms-animation-delay:5s;-ms-animation-duration:4s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-2;-ms-animation-timing-function:linear;-webkit-animation-delay:5s;-webkit-animation-duration:4s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-2;-webkit-animation-timing-function:linear">
|
||||
<polygon transform="translate(20,10)" points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3"
|
||||
fill="#ffc04a" stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon"
|
||||
style="-moz-animation-duration:6s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-moon;-moz-animation-timing-function:linear;-moz-transform-origin:12.5px 15.15px 0;-ms-animation-duration:6s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-moon;-ms-animation-timing-function:linear;-ms-transform-origin:12.5px 15.15px 0;-webkit-animation-duration:6s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-moon;-webkit-animation-timing-function:linear;-webkit-transform-origin:12.5px 15.15px 0">
|
||||
<path
|
||||
d="m14.5 13.2c0-3.7 2-6.9 5-8.7-1.5-0.9-3.2-1.3-5-1.3-5.5 0-10 4.5-10 10s4.5 10 10 10c1.8 0 3.5-0.5 5-1.3-3-1.7-5-5-5-8.7z"
|
||||
fill="#ffc04a" stroke="#ffc04a" stroke-linejoin="round" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(-16,4)">
|
||||
<g class="am-weather-frost" stroke="#57a0ee" transform="translate(0,2)" fill="none" stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
style="-moz-animation-duration:1.11s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-frost;-moz-animation-timing-function:linear;-webkit-animation-duration:1.11s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-frost;-webkit-animation-timing-function:linear">
|
||||
<path d="M11,32H45" />
|
||||
<path d="M15.5,37H40.5" />
|
||||
<path d="M22.5,42H33.5" />
|
||||
</g>
|
||||
<g>
|
||||
<path stroke="#57a0ee" transform="translate(0,0)" fill="none" stroke-width="2" stroke-linecap="round"
|
||||
d="M28,31V9M28,22l11,-3.67M34,20l2,-4M34,20l4,2M28,22l-11,-3.67M22,20l-2,-4M22,20l-4,2M28,14.27l3.01,-3.02M28,14.27l-3.01,-3.02" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
141
services/web-svc/public/weather-ico/rain-and-sleet-mix.svg
Normal file
@@ -0,0 +1,141 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<!-- Mix of Rain and Sleet | Contributed by hsoJ95 on GitHub: https://github.com/hsoj95 -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.24684" y="-.22776" width="1.4937" height="1.5756">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** RAIN
|
||||
*/
|
||||
@keyframes am-weather-rain {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: -100;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-rain-1 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.am-weather-rain-2 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-delay: 0.25s;
|
||||
-moz-animation-delay: 0.25s;
|
||||
-ms-animation-delay: 0.25s;
|
||||
animation-delay: 0.25s;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-3 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-3 {
|
||||
-webkit-animation-name: am-weather-cloud-3;
|
||||
-moz-animation-name: am-weather-cloud-3;
|
||||
animation-name: am-weather-cloud-3;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g class="am-weather-cloud-3"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-3;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-3;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-sleet-2" transform="translate(-20,-10) rotate(10,-247.39,200.17)" fill="none" stroke="#91c0f8"
|
||||
stroke-linecap="round">
|
||||
<line class="am-weather-rain-1" transform="translate(-5,1)" y2="8" stroke-dasharray="0.1, 7" stroke-width="2"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
<line class="am-weather-rain-1" transform="translate(5)" y2="8" stroke-dasharray="0.1, 7" stroke-width="2"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
</g>
|
||||
<g class="am-weather-rain-3" transform="translate(-20,-10) rotate(10,-245.89,217.31)" fill="none" stroke="#91c0f8"
|
||||
stroke-dasharray="4, 7" stroke-linecap="round" stroke-width="2">
|
||||
<line class="am-weather-rain-1" transform="translate(-13,1)" y2="8"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
<line class="am-weather-rain-1" transform="translate(-3,2)" y2="8"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
<line class="am-weather-rain-2" transform="translate(7,-1)" y2="8"
|
||||
style="-moz-animation-delay:0.25s;-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-delay:0.25s;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-delay:0.25s;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
179
services/web-svc/public/weather-ico/rainy-1-day.svg
Normal file
@@ -0,0 +1,179 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.21122" width="1.403" height="1.4997">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** SUN
|
||||
*/
|
||||
@keyframes am-weather-sun {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun {
|
||||
-webkit-animation-name: am-weather-sun;
|
||||
-moz-animation-name: am-weather-sun;
|
||||
-ms-animation-name: am-weather-sun;
|
||||
animation-name: am-weather-sun;
|
||||
-webkit-animation-duration: 9s;
|
||||
-moz-animation-duration: 9s;
|
||||
-ms-animation-duration: 9s;
|
||||
animation-duration: 9s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** RAIN
|
||||
*/
|
||||
@keyframes am-weather-rain {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: -100;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-rain-1 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="translate(0,16)">
|
||||
<g class="am-weather-sun"
|
||||
style="-moz-animation-duration:9s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-sun;-moz-animation-timing-function:linear;-ms-animation-duration:9s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-sun;-ms-animation-timing-function:linear;-webkit-animation-duration:9s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-sun;-webkit-animation-timing-function:linear">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round" stroke-width="2" />
|
||||
<g transform="rotate(45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(135)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="scale(-1)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(225)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<circle r="5" fill="#ffa500" stroke="#ffa500" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-3"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-rain-1" transform="translate(-20,-10) rotate(10,-238.68,233.96)">
|
||||
<line class="am-weather-rain-1" transform="translate(-6,1)" y2="8" fill="none" stroke="#91c0f8"
|
||||
stroke-dasharray="4, 7" stroke-linecap="round" stroke-width="2"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
243
services/web-svc/public/weather-ico/rainy-1-night.svg
Normal file
@@ -0,0 +1,243 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.21122" width="1.403" height="1.4997">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** MOON
|
||||
*/
|
||||
@keyframes am-weather-moon {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
-moz-transform: rotate(15deg);
|
||||
-ms-transform: rotate(15deg);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon {
|
||||
-webkit-animation-name: am-weather-moon;
|
||||
-moz-animation-name: am-weather-moon;
|
||||
-ms-animation-name: am-weather-moon;
|
||||
animation-name: am-weather-moon;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
-webkit-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-moz-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-ms-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-1 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-1 {
|
||||
-webkit-animation-name: am-weather-moon-star-1;
|
||||
-moz-animation-name: am-weather-moon-star-1;
|
||||
-ms-animation-name: am-weather-moon-star-1;
|
||||
animation-name: am-weather-moon-star-1;
|
||||
-webkit-animation-delay: 3s;
|
||||
-moz-animation-delay: 3s;
|
||||
-ms-animation-delay: 3s;
|
||||
animation-delay: 3s;
|
||||
-webkit-animation-duration: 5s;
|
||||
-moz-animation-duration: 5s;
|
||||
-ms-animation-duration: 5s;
|
||||
animation-duration: 5s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-2 {
|
||||
-webkit-animation-name: am-weather-moon-star-2;
|
||||
-moz-animation-name: am-weather-moon-star-2;
|
||||
-ms-animation-name: am-weather-moon-star-2;
|
||||
animation-name: am-weather-moon-star-2;
|
||||
-webkit-animation-delay: 5s;
|
||||
-moz-animation-delay: 5s;
|
||||
-ms-animation-delay: 5s;
|
||||
animation-delay: 5s;
|
||||
-webkit-animation-duration: 4s;
|
||||
-moz-animation-duration: 4s;
|
||||
-ms-animation-duration: 4s;
|
||||
animation-duration: 4s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** RAIN
|
||||
*/
|
||||
@keyframes am-weather-rain {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: -100;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-rain-1 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="matrix(.8 0 0 .8 16 4)">
|
||||
<g class="am-weather-moon-star-1"
|
||||
style="-moz-animation-delay:3s;-moz-animation-duration:5s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-1;-moz-animation-timing-function:linear;-ms-animation-delay:3s;-ms-animation-duration:5s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-1;-ms-animation-timing-function:linear;-webkit-animation-delay:3s;-webkit-animation-duration:5s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-1;-webkit-animation-timing-function:linear">
|
||||
<polygon points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3" fill="#ffa500"
|
||||
stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon-star-2"
|
||||
style="-moz-animation-delay:5s;-moz-animation-duration:4s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-2;-moz-animation-timing-function:linear;-ms-animation-delay:5s;-ms-animation-duration:4s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-2;-ms-animation-timing-function:linear;-webkit-animation-delay:5s;-webkit-animation-duration:4s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-2;-webkit-animation-timing-function:linear">
|
||||
<polygon transform="translate(20,10)" points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3"
|
||||
fill="#ffa500" stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon"
|
||||
style="-moz-animation-duration:6s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-moon;-moz-animation-timing-function:linear;-moz-transform-origin:12.5px 15.15px 0;-ms-animation-duration:6s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-moon;-ms-animation-timing-function:linear;-ms-transform-origin:12.5px 15.15px 0;-webkit-animation-duration:6s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-moon;-webkit-animation-timing-function:linear;-webkit-transform-origin:12.5px 15.15px 0">
|
||||
<path
|
||||
d="m14.5 13.2c0-3.7 2-6.9 5-8.7-1.5-0.9-3.2-1.3-5-1.3-5.5 0-10 4.5-10 10s4.5 10 10 10c1.8 0 3.5-0.5 5-1.3-3-1.7-5-5-5-8.7z"
|
||||
fill="#ffa500" stroke="#ffa500" stroke-linejoin="round" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-3"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weaher-rain-1" transform="translate(-20,-10) rotate(10,-238.68,233.96)">
|
||||
<line class="am-weather-rain-1" transform="translate(-6,1)" y2="8" fill="none" stroke="#91c0f8"
|
||||
stroke-dasharray="4, 7" stroke-linecap="round" stroke-width="2"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
204
services/web-svc/public/weather-ico/rainy-2-day.svg
Normal file
@@ -0,0 +1,204 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.20592" width="1.403" height="1.4872">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** SUN
|
||||
*/
|
||||
@keyframes am-weather-sun {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun {
|
||||
-webkit-animation-name: am-weather-sun;
|
||||
-moz-animation-name: am-weather-sun;
|
||||
-ms-animation-name: am-weather-sun;
|
||||
animation-name: am-weather-sun;
|
||||
-webkit-animation-duration: 9s;
|
||||
-moz-animation-duration: 9s;
|
||||
-ms-animation-duration: 9s;
|
||||
animation-duration: 9s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** RAIN
|
||||
*/
|
||||
@keyframes am-weather-rain {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: -100;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-rain-1 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.am-weather-rain-2 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-delay: 0.25s;
|
||||
-moz-animation-delay: 0.25s;
|
||||
-ms-animation-delay: 0.25s;
|
||||
animation-delay: 0.25s;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="translate(0,16)">
|
||||
<g class="am-weather-sun"
|
||||
style="-moz-animation-duration:9s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-sun;-moz-animation-timing-function:linear;-ms-animation-duration:9s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-sun;-ms-animation-timing-function:linear;-webkit-animation-duration:9s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-sun;-webkit-animation-timing-function:linear">
|
||||
<line transform="translate(0,9)" y2="3" stroke="#ffa500" stroke-linecap="round" stroke-width="2" fifll="none" />
|
||||
<g transform="rotate(45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(135)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="scale(-1)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(225)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<circle r="5" fill="#ffa500" stroke="#ffa500" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-3"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g transform="translate(-20,-10) rotate(10,-245.89,217.31)" fill="none" stroke="#91c0f8" stroke-dasharray="4, 7" stroke-linecap="round"
|
||||
stroke-width="2">
|
||||
<line class="am-weather-rain-1" transform="translate(-6,1)" y2="8"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
<line class="am-weather-rain-2" transform="translate(0,-1)" y2="8"
|
||||
style="-moz-animation-delay:0.25s;-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-delay:0.25s;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-delay:0.25s;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
256
services/web-svc/public/weather-ico/rainy-2-night.svg
Normal file
@@ -0,0 +1,256 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** MOON
|
||||
*/
|
||||
@keyframes am-weather-moon {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
-moz-transform: rotate(15deg);
|
||||
-ms-transform: rotate(15deg);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon {
|
||||
-webkit-animation-name: am-weather-moon;
|
||||
-moz-animation-name: am-weather-moon;
|
||||
-ms-animation-name: am-weather-moon;
|
||||
animation-name: am-weather-moon;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
-webkit-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-moz-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-ms-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-1 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-1 {
|
||||
-webkit-animation-name: am-weather-moon-star-1;
|
||||
-moz-animation-name: am-weather-moon-star-1;
|
||||
-ms-animation-name: am-weather-moon-star-1;
|
||||
animation-name: am-weather-moon-star-1;
|
||||
-webkit-animation-delay: 3s;
|
||||
-moz-animation-delay: 3s;
|
||||
-ms-animation-delay: 3s;
|
||||
animation-delay: 3s;
|
||||
-webkit-animation-duration: 5s;
|
||||
-moz-animation-duration: 5s;
|
||||
-ms-animation-duration: 5s;
|
||||
animation-duration: 5s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-2 {
|
||||
-webkit-animation-name: am-weather-moon-star-2;
|
||||
-moz-animation-name: am-weather-moon-star-2;
|
||||
-ms-animation-name: am-weather-moon-star-2;
|
||||
animation-name: am-weather-moon-star-2;
|
||||
-webkit-animation-delay: 5s;
|
||||
-moz-animation-delay: 5s;
|
||||
-ms-animation-delay: 5s;
|
||||
animation-delay: 5s;
|
||||
-webkit-animation-duration: 4s;
|
||||
-moz-animation-duration: 4s;
|
||||
-ms-animation-duration: 4s;
|
||||
animation-duration: 4s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
** RAIN
|
||||
*/
|
||||
@keyframes am-weather-rain {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: -100;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-rain-1 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.am-weather-rain-2 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-delay: 0.25s;
|
||||
-moz-animation-delay: 0.25s;
|
||||
-ms-animation-delay: 0.25s;
|
||||
animation-delay: 0.25s;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g class="layer" transform="translate(16,-2)">
|
||||
<g transform="matrix(.8 0 0 .8 16 4)">
|
||||
<g class="am-weather-moon-star-1"
|
||||
style="-moz-animation-delay:3s;-moz-animation-duration:5s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-1;-moz-animation-timing-function:linear;-ms-animation-delay:3s;-ms-animation-duration:5s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-1;-ms-animation-timing-function:linear;-webkit-animation-delay:3s;-webkit-animation-duration:5s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-1;-webkit-animation-timing-function:linear">
|
||||
<polygon points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3" fill="#ffa500"
|
||||
stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon-star-2"
|
||||
style="-moz-animation-delay:5s;-moz-animation-duration:4s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-2;-moz-animation-timing-function:linear;-ms-animation-delay:5s;-ms-animation-duration:4s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-2;-ms-animation-timing-function:linear;-webkit-animation-delay:5s;-webkit-animation-duration:4s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-2;-webkit-animation-timing-function:linear">
|
||||
<polygon transform="translate(20,10)" points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3"
|
||||
fill="#ffa500" stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon"
|
||||
style="-moz-animation-duration:6s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-moon;-moz-animation-timing-function:linear;-moz-transform-origin:12.5px 15.15px 0;-ms-animation-duration:6s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-moon;-ms-animation-timing-function:linear;-ms-transform-origin:12.5px 15.15px 0;-webkit-animation-duration:6s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-moon;-webkit-animation-timing-function:linear;-webkit-transform-origin:12.5px 15.15px 0">
|
||||
<path
|
||||
d="m14.5 13.2c0-3.7 2-6.9 5-8.7-1.5-0.9-3.2-1.3-5-1.3-5.5 0-10 4.5-10 10s4.5 10 10 10c1.8 0 3.5-0.5 5-1.3-3-1.7-5-5-5-8.7z"
|
||||
fill="#ffa500" stroke="#ffa500" stroke-linejoin="round" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-3"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-rain-2" transform="translate(-20,-10) rotate(10,34,46)" fill="none" stroke="#91c0f8"
|
||||
stroke-dasharray="4, 7" stroke-linecap="round" stroke-width="2">
|
||||
<line class="am-weather-rain-1" transform="translate(-6,1)" x1="34" x2="34" y1="46" y2="54"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
<line class="am-weather-rain-2" transform="translate(0,-1)" x1="34" x2="34" y1="46" y2="54"
|
||||
style="-moz-animation-delay:0.25s;-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-delay:0.25s;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-delay:0.25s;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
206
services/web-svc/public/weather-ico/rainy-3-day.svg
Normal file
@@ -0,0 +1,206 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.24684" y="-.22892" width="1.4937" height="1.5576">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** SUN
|
||||
*/
|
||||
@keyframes am-weather-sun {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun {
|
||||
-webkit-animation-name: am-weather-sun;
|
||||
-moz-animation-name: am-weather-sun;
|
||||
-ms-animation-name: am-weather-sun;
|
||||
animation-name: am-weather-sun;
|
||||
-webkit-animation-duration: 9s;
|
||||
-moz-animation-duration: 9s;
|
||||
-ms-animation-duration: 9s;
|
||||
animation-duration: 9s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** RAIN
|
||||
*/
|
||||
@keyframes am-weather-rain {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: -100;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-rain-1 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.am-weather-rain-2 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-delay: 0.25s;
|
||||
-moz-animation-delay: 0.25s;
|
||||
-ms-animation-delay: 0.25s;
|
||||
animation-delay: 0.25s;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="translate(0,16)">
|
||||
<g class="am-weather-sun"
|
||||
style="-moz-animation-duration:9s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-sun;-moz-animation-timing-function:linear;-ms-animation-duration:9s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-sun;-ms-animation-timing-function:linear;-webkit-animation-duration:9s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-sun;-webkit-animation-timing-function:linear">
|
||||
<line transform="translate(0,9)" y2="3" stroke="#ffa500" stroke-linecap="round" stroke-width="2" fifll="none" />
|
||||
<g transform="rotate(45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(135)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="scale(-1)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(225)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<circle r="5" fill="#ffa500" stroke="#ffa500" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-3"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g transform="translate(-20,-10) rotate(10,-247.39,200.17)" fill="none" stroke="#91c0f8" stroke-dasharray="4, 4"
|
||||
stroke-linecap="round" stroke-width="2">
|
||||
<line class="am-weather-rain-1" transform="translate(-4,1)" y2="8"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
<line class="am-weather-rain-2" transform="translate(0,-1)" y2="8"
|
||||
style="-moz-animation-delay:0.25s;-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-delay:0.25s;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-delay:0.25s;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
<line class="am-weather-rain-1" transform="translate(4)" y2="8"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
270
services/web-svc/public/weather-ico/rainy-3-night.svg
Normal file
@@ -0,0 +1,270 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.24684" y="-.22892" width="1.4937" height="1.5576">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** RAIN
|
||||
*/
|
||||
@keyframes am-weather-rain {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: -100;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-rain-1 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.am-weather-rain-2 {
|
||||
-webkit-animation-name: am-weather-rain;
|
||||
-moz-animation-name: am-weather-rain;
|
||||
-ms-animation-name: am-weather-rain;
|
||||
animation-name: am-weather-rain;
|
||||
-webkit-animation-delay: 0.25s;
|
||||
-moz-animation-delay: 0.25s;
|
||||
-ms-animation-delay: 0.25s;
|
||||
animation-delay: 0.25s;
|
||||
-webkit-animation-duration: 8s;
|
||||
-moz-animation-duration: 8s;
|
||||
-ms-animation-duration: 8s;
|
||||
animation-duration: 8s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** MOON
|
||||
*/
|
||||
@keyframes am-weather-moon {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
-moz-transform: rotate(15deg);
|
||||
-ms-transform: rotate(15deg);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon {
|
||||
-webkit-animation-name: am-weather-moon;
|
||||
-moz-animation-name: am-weather-moon;
|
||||
-ms-animation-name: am-weather-moon;
|
||||
animation-name: am-weather-moon;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
-webkit-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-moz-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-ms-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-1 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-1 {
|
||||
-webkit-animation-name: am-weather-moon-star-1;
|
||||
-moz-animation-name: am-weather-moon-star-1;
|
||||
-ms-animation-name: am-weather-moon-star-1;
|
||||
animation-name: am-weather-moon-star-1;
|
||||
-webkit-animation-delay: 3s;
|
||||
-moz-animation-delay: 3s;
|
||||
-ms-animation-delay: 3s;
|
||||
animation-delay: 3s;
|
||||
-webkit-animation-duration: 5s;
|
||||
-moz-animation-duration: 5s;
|
||||
-ms-animation-duration: 5s;
|
||||
animation-duration: 5s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-2 {
|
||||
-webkit-animation-name: am-weather-moon-star-2;
|
||||
-moz-animation-name: am-weather-moon-star-2;
|
||||
-ms-animation-name: am-weather-moon-star-2;
|
||||
animation-name: am-weather-moon-star-2;
|
||||
-webkit-animation-delay: 5s;
|
||||
-moz-animation-delay: 5s;
|
||||
-ms-animation-delay: 5s;
|
||||
animation-delay: 5s;
|
||||
-webkit-animation-duration: 4s;
|
||||
-moz-animation-duration: 4s;
|
||||
-ms-animation-duration: 4s;
|
||||
animation-duration: 4s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="matrix(.8 0 0 .8 16 4)">
|
||||
<g class="am-weather-moon-star-1"
|
||||
style="-moz-animation-delay:3s;-moz-animation-duration:5s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-1;-moz-animation-timing-function:linear;-ms-animation-delay:3s;-ms-animation-duration:5s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-1;-ms-animation-timing-function:linear;-webkit-animation-delay:3s;-webkit-animation-duration:5s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-1;-webkit-animation-timing-function:linear">
|
||||
<polygon points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3" fill="#ffa500"
|
||||
stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon-star-2"
|
||||
style="-moz-animation-delay:5s;-moz-animation-duration:4s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-2;-moz-animation-timing-function:linear;-ms-animation-delay:5s;-ms-animation-duration:4s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-2;-ms-animation-timing-function:linear;-webkit-animation-delay:5s;-webkit-animation-duration:4s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-2;-webkit-animation-timing-function:linear">
|
||||
<polygon transform="translate(20,10)" points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3"
|
||||
fill="#ffa500" stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon"
|
||||
style="-moz-animation-duration:6s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-moon;-moz-animation-timing-function:linear;-moz-transform-origin:12.5px 15.15px 0;-ms-animation-duration:6s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-moon;-ms-animation-timing-function:linear;-ms-transform-origin:12.5px 15.15px 0;-webkit-animation-duration:6s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-moon;-webkit-animation-timing-function:linear;-webkit-transform-origin:12.5px 15.15px 0">
|
||||
<path
|
||||
d="m14.5 13.2c0-3.7 2-6.9 5-8.7-1.5-0.9-3.2-1.3-5-1.3-5.5 0-10 4.5-10 10s4.5 10 10 10c1.8 0 3.5-0.5 5-1.3-3-1.7-5-5-5-8.7z"
|
||||
fill="#ffa500" stroke="#ffa500" stroke-linejoin="round" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-3"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g transform="translate(-20,-10) rotate(10,-247.39,200.17)" fill="none" stroke="#91c0f8" stroke-dasharray="4, 4"
|
||||
stroke-linecap="round" stroke-width="2">
|
||||
<line class="am-weather-rain-1" transform="translate(-4,1)" y2="8"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
<line class="am-weather-rain-2" transform="translate(0,-1)" y2="8"
|
||||
style="-moz-animation-delay:0.25s;-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-delay:0.25s;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-delay:0.25s;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
<line class="am-weather-rain-1" transform="translate(4)" y2="8"
|
||||
style="-moz-animation-duration:8s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-rain;-moz-animation-timing-function:linear;-ms-animation-duration:8s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-rain;-ms-animation-timing-function:linear;-webkit-animation-duration:8s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-rain;-webkit-animation-timing-function:linear" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,374 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<!-- Scattered Thunderstorms | Contributed by hsoJ95 on GitHub: https://github.com/hsoj95 -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.1975" width="1.403" height="1.4766">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-3 {
|
||||
0% {
|
||||
-webkit-transform: translate(-5px, 0px);
|
||||
-moz-transform: translate(-5px, 0px);
|
||||
-ms-transform: translate(-5px, 0px);
|
||||
transform: translate(-5px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(10px, 0px);
|
||||
-moz-transform: translate(10px, 0px);
|
||||
-ms-transform: translate(10px, 0px);
|
||||
transform: translate(10px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(-5px, 0px);
|
||||
-moz-transform: translate(-5px, 0px);
|
||||
-ms-transform: translate(-5px, 0px);
|
||||
transform: translate(-5px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-3 {
|
||||
-webkit-animation-name: am-weather-cloud-3;
|
||||
-moz-animation-name: am-weather-cloud-3;
|
||||
animation-name: am-weather-cloud-3;
|
||||
-webkit-animation-duration: 7s;
|
||||
-moz-animation-duration: 7s;
|
||||
animation-duration: 7s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** SUN
|
||||
*/
|
||||
@keyframes am-weather-sun {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun {
|
||||
-webkit-animation-name: am-weather-sun;
|
||||
-moz-animation-name: am-weather-sun;
|
||||
-ms-animation-name: am-weather-sun;
|
||||
animation-name: am-weather-sun;
|
||||
-webkit-animation-duration: 9s;
|
||||
-moz-animation-duration: 9s;
|
||||
-ms-animation-duration: 9s;
|
||||
animation-duration: 9s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-sun-shiny {
|
||||
0% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
|
||||
50% {
|
||||
stroke-dasharray: 0.1px 10px;
|
||||
stroke-dashoffset: -1px;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun-shiny line {
|
||||
-webkit-animation-name: am-weather-sun-shiny;
|
||||
-moz-animation-name: am-weather-sun-shiny;
|
||||
-ms-animation-name: am-weather-sun-shiny;
|
||||
animation-name: am-weather-sun-shiny;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** STROKE
|
||||
*/
|
||||
@keyframes am-weather-stroke {
|
||||
0% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
2% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
4% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
6% {
|
||||
-webkit-transform: translate(0.5px, 0.4px);
|
||||
-moz-transform: translate(0.5px, 0.4px);
|
||||
-ms-transform: translate(0.5px, 0.4px);
|
||||
transform: translate(0.5px, 0.4px);
|
||||
}
|
||||
|
||||
8% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
10% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
12% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
14% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
16% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
18% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
20% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
22% {
|
||||
-webkit-transform: translate(1px, 0.0px);
|
||||
-moz-transform: translate(1px, 0.0px);
|
||||
-ms-transform: translate(1px, 0.0px);
|
||||
transform: translate(1px, 0.0px);
|
||||
}
|
||||
|
||||
24% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
26% {
|
||||
-webkit-transform: translate(-1px, 0.0px);
|
||||
-moz-transform: translate(-1px, 0.0px);
|
||||
-ms-transform: translate(-1px, 0.0px);
|
||||
transform: translate(-1px, 0.0px);
|
||||
|
||||
}
|
||||
|
||||
28% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
40% {
|
||||
fill: orange;
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
65% {
|
||||
fill: white;
|
||||
-webkit-transform: translate(-1px, 5.0px);
|
||||
-moz-transform: translate(-1px, 5.0px);
|
||||
-ms-transform: translate(-1px, 5.0px);
|
||||
transform: translate(-1px, 5.0px);
|
||||
}
|
||||
|
||||
61% {
|
||||
fill: orange;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-stroke {
|
||||
-webkit-animation-name: am-weather-stroke;
|
||||
-moz-animation-name: am-weather-stroke;
|
||||
animation-name: am-weather-stroke;
|
||||
-webkit-animation-duration: 1.11s;
|
||||
-moz-animation-duration: 1.11s;
|
||||
animation-duration: 1.11s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="thunder" transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="translate(0,16)">
|
||||
<g class="am-weather-sun"
|
||||
style="-moz-animation-duration:9s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-sun;-moz-animation-timing-function:linear;-ms-animation-duration:9s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-sun;-ms-animation-timing-function:linear;-webkit-animation-duration:9s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-sun;-webkit-animation-timing-function:linear">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round" stroke-width="2" />
|
||||
<g transform="rotate(45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(135)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="scale(-1)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(225)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<circle r="5" fill="#ffa500" stroke="#ffa500" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-3">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-lightning" transform="matrix(1.2,0,0,1.2,-4,28)">
|
||||
<polygon class="am-weather-stroke" points="11.1 6.9 14.3 -2.9 20.5 -2.9 16.4 4.3 20.3 4.3 11.5 14.6 14.9 6.9"
|
||||
fill="#ffa500" stroke="#fff"
|
||||
style="-moz-animation-duration:1.11s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-stroke;-moz-animation-timing-function:linear;-webkit-animation-duration:1.11s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-stroke;-webkit-animation-timing-function:linear" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,283 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<!-- Scattered Thunderstorms | Contributed by hsoJ95 on GitHub: https://github.com/hsoj95 -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.1975" width="1.403" height="1.4766">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-3 {
|
||||
0% {
|
||||
-webkit-transform: translate(-5px, 0px);
|
||||
-moz-transform: translate(-5px, 0px);
|
||||
-ms-transform: translate(-5px, 0px);
|
||||
transform: translate(-5px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(10px, 0px);
|
||||
-moz-transform: translate(10px, 0px);
|
||||
-ms-transform: translate(10px, 0px);
|
||||
transform: translate(10px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(-5px, 0px);
|
||||
-moz-transform: translate(-5px, 0px);
|
||||
-ms-transform: translate(-5px, 0px);
|
||||
transform: translate(-5px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-3 {
|
||||
-webkit-animation-name: am-weather-cloud-3;
|
||||
-moz-animation-name: am-weather-cloud-3;
|
||||
animation-name: am-weather-cloud-3;
|
||||
-webkit-animation-duration: 7s;
|
||||
-moz-animation-duration: 7s;
|
||||
animation-duration: 7s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** STROKE
|
||||
*/
|
||||
@keyframes am-weather-stroke {
|
||||
0% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
2% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
4% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
6% {
|
||||
-webkit-transform: translate(0.5px, 0.4px);
|
||||
-moz-transform: translate(0.5px, 0.4px);
|
||||
-ms-transform: translate(0.5px, 0.4px);
|
||||
transform: translate(0.5px, 0.4px);
|
||||
}
|
||||
|
||||
8% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
10% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
12% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
14% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
16% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
18% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
20% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
22% {
|
||||
-webkit-transform: translate(1px, 0.0px);
|
||||
-moz-transform: translate(1px, 0.0px);
|
||||
-ms-transform: translate(1px, 0.0px);
|
||||
transform: translate(1px, 0.0px);
|
||||
}
|
||||
|
||||
24% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
26% {
|
||||
-webkit-transform: translate(-1px, 0.0px);
|
||||
-moz-transform: translate(-1px, 0.0px);
|
||||
-ms-transform: translate(-1px, 0.0px);
|
||||
transform: translate(-1px, 0.0px);
|
||||
|
||||
}
|
||||
|
||||
28% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
40% {
|
||||
fill: orange;
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
65% {
|
||||
fill: white;
|
||||
-webkit-transform: translate(-1px, 5.0px);
|
||||
-moz-transform: translate(-1px, 5.0px);
|
||||
-ms-transform: translate(-1px, 5.0px);
|
||||
transform: translate(-1px, 5.0px);
|
||||
}
|
||||
|
||||
61% {
|
||||
fill: orange;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-stroke {
|
||||
-webkit-animation-name: am-weather-stroke;
|
||||
-moz-animation-name: am-weather-stroke;
|
||||
animation-name: am-weather-stroke;
|
||||
-webkit-animation-duration: 1.11s;
|
||||
-moz-animation-duration: 1.11s;
|
||||
animation-duration: 1.11s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="thunder" transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="matrix(.8 0 0 .8 16 4)">
|
||||
<g class="am-weather-moon-star-1"
|
||||
style="-moz-animation-delay:3s;-moz-animation-duration:5s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-1;-moz-animation-timing-function:linear;-ms-animation-delay:3s;-ms-animation-duration:5s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-1;-ms-animation-timing-function:linear;-webkit-animation-delay:3s;-webkit-animation-duration:5s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-1;-webkit-animation-timing-function:linear">
|
||||
<polygon points="3.3 1.5 4 2.7 5.2 3.3 4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7" fill="#ffa500"
|
||||
stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon-star-2"
|
||||
style="-moz-animation-delay:5s;-moz-animation-duration:4s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-2;-moz-animation-timing-function:linear;-ms-animation-delay:5s;-ms-animation-duration:4s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-2;-ms-animation-timing-function:linear;-webkit-animation-delay:5s;-webkit-animation-duration:4s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-2;-webkit-animation-timing-function:linear">
|
||||
<polygon transform="translate(20,10)" points="3.3 1.5 4 2.7 5.2 3.3 4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7"
|
||||
fill="#ffa500" stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon"
|
||||
style="-moz-animation-duration:6s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-moon;-moz-animation-timing-function:linear;-moz-transform-origin:12.5px 15.15px 0;-ms-animation-duration:6s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-moon;-ms-animation-timing-function:linear;-ms-transform-origin:12.5px 15.15px 0;-webkit-animation-duration:6s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-moon;-webkit-animation-timing-function:linear;-webkit-transform-origin:12.5px 15.15px 0">
|
||||
<path
|
||||
d="m14.5 13.2c0-3.7 2-6.9 5-8.7-1.5-0.9-3.2-1.3-5-1.3-5.5 0-10 4.5-10 10s4.5 10 10 10c1.8 0 3.5-0.5 5-1.3-3-1.7-5-5-5-8.7z"
|
||||
fill="#ffa500" stroke="#ffa500" stroke-linejoin="round" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-3">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-lightning" transform="matrix(1.2,0,0,1.2,-4,28)">
|
||||
<polygon class="am-weather-stroke" points="11.1 6.9 14.3 -2.9 20.5 -2.9 16.4 4.3 20.3 4.3 11.5 14.6 14.9 6.9"
|
||||
fill="#ffa500" stroke="#fff"
|
||||
style="-moz-animation-duration:1.11s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-stroke;-moz-animation-timing-function:linear;-webkit-animation-duration:1.11s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-stroke;-webkit-animation-timing-function:linear" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
307
services/web-svc/public/weather-ico/severe-thunderstorm.svg
Normal file
@@ -0,0 +1,307 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<!-- Severe Thunderstorm | Contributed by hsoJ95 on GitHub: https://github.com/hsoj95 -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.17571" y="-.19575" width="1.3379" height="1.4959">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-3 {
|
||||
0% {
|
||||
-webkit-transform: translate(-5px, 0px);
|
||||
-moz-transform: translate(-5px, 0px);
|
||||
-ms-transform: translate(-5px, 0px);
|
||||
transform: translate(-5px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(10px, 0px);
|
||||
-moz-transform: translate(10px, 0px);
|
||||
-ms-transform: translate(10px, 0px);
|
||||
transform: translate(10px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(-5px, 0px);
|
||||
-moz-transform: translate(-5px, 0px);
|
||||
-ms-transform: translate(-5px, 0px);
|
||||
transform: translate(-5px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-3 {
|
||||
-webkit-animation-name: am-weather-cloud-3;
|
||||
-moz-animation-name: am-weather-cloud-3;
|
||||
animation-name: am-weather-cloud-3;
|
||||
-webkit-animation-duration: 7s;
|
||||
-moz-animation-duration: 7s;
|
||||
animation-duration: 7s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-cloud-1 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-1 {
|
||||
-webkit-animation-name: am-weather-cloud-1;
|
||||
-moz-animation-name: am-weather-cloud-1;
|
||||
animation-name: am-weather-cloud-1;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** STROKE
|
||||
*/
|
||||
|
||||
@keyframes am-weather-stroke {
|
||||
0% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
2% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
4% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
6% {
|
||||
-webkit-transform: translate(0.5px, 0.4px);
|
||||
-moz-transform: translate(0.5px, 0.4px);
|
||||
-ms-transform: translate(0.5px, 0.4px);
|
||||
transform: translate(0.5px, 0.4px);
|
||||
}
|
||||
|
||||
8% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
10% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
12% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
14% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
16% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
18% {
|
||||
-webkit-transform: translate(0.3px, 0.0px);
|
||||
-moz-transform: translate(0.3px, 0.0px);
|
||||
-ms-transform: translate(0.3px, 0.0px);
|
||||
transform: translate(0.3px, 0.0px);
|
||||
}
|
||||
|
||||
20% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
22% {
|
||||
-webkit-transform: translate(1px, 0.0px);
|
||||
-moz-transform: translate(1px, 0.0px);
|
||||
-ms-transform: translate(1px, 0.0px);
|
||||
transform: translate(1px, 0.0px);
|
||||
}
|
||||
|
||||
24% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
26% {
|
||||
-webkit-transform: translate(-1px, 0.0px);
|
||||
-moz-transform: translate(-1px, 0.0px);
|
||||
-ms-transform: translate(-1px, 0.0px);
|
||||
transform: translate(-1px, 0.0px);
|
||||
}
|
||||
|
||||
28% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
40% {
|
||||
fill: orange;
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
|
||||
65% {
|
||||
fill: white;
|
||||
-webkit-transform: translate(-1px, 5.0px);
|
||||
-moz-transform: translate(-1px, 5.0px);
|
||||
-ms-transform: translate(-1px, 5.0px);
|
||||
transform: translate(-1px, 5.0px);
|
||||
}
|
||||
|
||||
61% {
|
||||
fill: orange;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0.0px, 0.0px);
|
||||
-moz-transform: translate(0.0px, 0.0px);
|
||||
-ms-transform: translate(0.0px, 0.0px);
|
||||
transform: translate(0.0px, 0.0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-stroke {
|
||||
-webkit-animation-name: am-weather-stroke;
|
||||
-moz-animation-name: am-weather-stroke;
|
||||
animation-name: am-weather-stroke;
|
||||
-webkit-animation-duration: 1.11s;
|
||||
-moz-animation-duration: 1.11s;
|
||||
animation-duration: 1.11s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes error {
|
||||
0% {
|
||||
fill: #cc0000;
|
||||
}
|
||||
|
||||
50% {
|
||||
fill: #ff0000;
|
||||
}
|
||||
|
||||
100% {
|
||||
fill: #cc0000;
|
||||
}
|
||||
}
|
||||
|
||||
#Shape {
|
||||
-webkit-animation-name: error;
|
||||
-moz-animation-name: error;
|
||||
animation-name: error;
|
||||
-webkit-animation-duration: 1s;
|
||||
-moz-animation-duration: 1s;
|
||||
animation-duration: 1s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g class="am-weather-cloud-1"
|
||||
style="-moz-animation-duration:7s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-1;-moz-animation-timing-function:linear;-webkit-animation-duration:7s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-1;-webkit-animation-timing-function:linear">
|
||||
<path transform="matrix(.6 0 0 .6 -10 -6)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#666" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-cloud-3">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#333" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g transform="matrix(1.2,0,0,1.2,-4,28)">
|
||||
<polygon class="am-weather-stroke"
|
||||
points="11.1 6.9 14.3 -2.9 20.5 -2.9 16.4 4.3 20.3 4.3 11.5 14.6 14.9 6.9" fill="#ffa500" stroke="#fff"
|
||||
style="-moz-animation-duration:1.11s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-stroke;-moz-animation-timing-function:linear;-webkit-animation-duration:1.11s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-stroke;-webkit-animation-timing-function:linear" />
|
||||
</g>
|
||||
<g class="warning" transform="translate(20,30)">
|
||||
<path
|
||||
d="m7.7791 2.906-5.9912 10.117c-0.56283 0.95042-0.24862 2.1772 0.7018 2.74 0.30853 0.18271 0.66051 0.27911 1.0191 0.27911h11.982c1.1046 0 2-0.89543 2-2 0-0.35857-0.0964-0.71056-0.27911-1.0191l-5.9912-10.117c-0.56283-0.95042-1.7896-1.2646-2.74-0.7018-0.28918 0.17125-0.53055 0.41262-0.7018 0.7018z"
|
||||
fill="#c00" />
|
||||
<path d="m9.5 10.5v-5" stroke="#fff" stroke-linecap="round" stroke-width="1.5" />
|
||||
<circle cx="9.5" cy="13" r="1" fill="#fff" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
241
services/web-svc/public/weather-ico/snowy-1-day.svg
Normal file
@@ -0,0 +1,241 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.23099" width="1.403" height="1.5634">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** SUN
|
||||
*/
|
||||
@keyframes am-weather-sun {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun {
|
||||
-webkit-animation-name: am-weather-sun;
|
||||
-moz-animation-name: am-weather-sun;
|
||||
-ms-animation-name: am-weather-sun;
|
||||
animation-name: am-weather-sun;
|
||||
-webkit-animation-duration: 9s;
|
||||
-moz-animation-duration: 9s;
|
||||
-ms-animation-duration: 9s;
|
||||
animation-duration: 9s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-sun-shiny {
|
||||
0% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
|
||||
50% {
|
||||
stroke-dasharray: 0.1px 10px;
|
||||
stroke-dashoffset: -1px;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun-shiny line {
|
||||
-webkit-animation-name: am-weather-sun-shiny;
|
||||
-moz-animation-name: am-weather-sun-shiny;
|
||||
-ms-animation-name: am-weather-sun-shiny;
|
||||
animation-name: am-weather-sun-shiny;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** SNOW
|
||||
*/
|
||||
@keyframes am-weather-snow {
|
||||
0% {
|
||||
-webkit-transform: translateX(0) translateY(0);
|
||||
-moz-transform: translateX(0) translateY(0);
|
||||
-ms-transform: translateX(0) translateY(0);
|
||||
transform: translateX(0) translateY(0);
|
||||
}
|
||||
|
||||
33.33% {
|
||||
-webkit-transform: translateX(-1.2px) translateY(2px);
|
||||
-moz-transform: translateX(-1.2px) translateY(2px);
|
||||
-ms-transform: translateX(-1.2px) translateY(2px);
|
||||
transform: translateX(-1.2px) translateY(2px);
|
||||
}
|
||||
|
||||
66.66% {
|
||||
-webkit-transform: translateX(1.4px) translateY(4px);
|
||||
-moz-transform: translateX(1.4px) translateY(4px);
|
||||
-ms-transform: translateX(1.4px) translateY(4px);
|
||||
transform: translateX(1.4px) translateY(4px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translateX(-1.6px) translateY(6px);
|
||||
-moz-transform: translateX(-1.6px) translateY(6px);
|
||||
-ms-transform: translateX(-1.6px) translateY(6px);
|
||||
transform: translateX(-1.6px) translateY(6px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-snow-1 {
|
||||
-webkit-animation-name: am-weather-snow;
|
||||
-moz-animation-name: am-weather-snow;
|
||||
-ms-animation-name: am-weather-snow;
|
||||
animation-name: am-weather-snow;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="translate(0,16)">
|
||||
<g class="am-weather-sun" transform="translate(0,16)"
|
||||
style="-moz-animation-duration:9s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-sun;-moz-animation-timing-function:linear;-ms-animation-duration:9s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-sun;-ms-animation-timing-function:linear;-webkit-animation-duration:9s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-sun;-webkit-animation-timing-function:linear">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round" stroke-width="2" />
|
||||
<g transform="rotate(45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(135)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="scale(-1)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(225)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<circle r="5" fill="#ffa500" stroke="#ffa500" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-3"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-snow-1"
|
||||
style="-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow;-moz-animation-timing-function:linear;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow;-ms-animation-timing-function:linear;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(12,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
269
services/web-svc/public/weather-ico/snowy-1-night.svg
Normal file
@@ -0,0 +1,269 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.23099" width="1.403" height="1.5634">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** MOON
|
||||
*/
|
||||
@keyframes am-weather-moon {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
-moz-transform: rotate(15deg);
|
||||
-ms-transform: rotate(15deg);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon {
|
||||
-webkit-animation-name: am-weather-moon;
|
||||
-moz-animation-name: am-weather-moon;
|
||||
-ms-animation-name: am-weather-moon;
|
||||
animation-name: am-weather-moon;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
-webkit-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-moz-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-ms-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-1 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-1 {
|
||||
-webkit-animation-name: am-weather-moon-star-1;
|
||||
-moz-animation-name: am-weather-moon-star-1;
|
||||
-ms-animation-name: am-weather-moon-star-1;
|
||||
animation-name: am-weather-moon-star-1;
|
||||
-webkit-animation-delay: 3s;
|
||||
-moz-animation-delay: 3s;
|
||||
-ms-animation-delay: 3s;
|
||||
animation-delay: 3s;
|
||||
-webkit-animation-duration: 5s;
|
||||
-moz-animation-duration: 5s;
|
||||
-ms-animation-duration: 5s;
|
||||
animation-duration: 5s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-2 {
|
||||
-webkit-animation-name: am-weather-moon-star-2;
|
||||
-moz-animation-name: am-weather-moon-star-2;
|
||||
-ms-animation-name: am-weather-moon-star-2;
|
||||
animation-name: am-weather-moon-star-2;
|
||||
-webkit-animation-delay: 5s;
|
||||
-moz-animation-delay: 5s;
|
||||
-ms-animation-delay: 5s;
|
||||
animation-delay: 5s;
|
||||
-webkit-animation-duration: 4s;
|
||||
-moz-animation-duration: 4s;
|
||||
-ms-animation-duration: 4s;
|
||||
animation-duration: 4s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
** SNOW
|
||||
*/
|
||||
@keyframes am-weather-snow {
|
||||
0% {
|
||||
-webkit-transform: translateX(0) translateY(0);
|
||||
-moz-transform: translateX(0) translateY(0);
|
||||
-ms-transform: translateX(0) translateY(0);
|
||||
transform: translateX(0) translateY(0);
|
||||
}
|
||||
|
||||
33.33% {
|
||||
-webkit-transform: translateX(-1.2px) translateY(2px);
|
||||
-moz-transform: translateX(-1.2px) translateY(2px);
|
||||
-ms-transform: translateX(-1.2px) translateY(2px);
|
||||
transform: translateX(-1.2px) translateY(2px);
|
||||
}
|
||||
|
||||
66.66% {
|
||||
-webkit-transform: translateX(1.4px) translateY(4px);
|
||||
-moz-transform: translateX(1.4px) translateY(4px);
|
||||
-ms-transform: translateX(1.4px) translateY(4px);
|
||||
transform: translateX(1.4px) translateY(4px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translateX(-1.6px) translateY(6px);
|
||||
-moz-transform: translateX(-1.6px) translateY(6px);
|
||||
-ms-transform: translateX(-1.6px) translateY(6px);
|
||||
transform: translateX(-1.6px) translateY(6px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-snow-1 {
|
||||
-webkit-animation-name: am-weather-snow;
|
||||
-moz-animation-name: am-weather-snow;
|
||||
-ms-animation-name: am-weather-snow;
|
||||
animation-name: am-weather-snow;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="matrix(.8 0 0 .8 16 4)">
|
||||
<g class="am-weather-moon-star-1"
|
||||
style="-moz-animation-delay:3s;-moz-animation-duration:5s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-1;-moz-animation-timing-function:linear;-ms-animation-delay:3s;-ms-animation-duration:5s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-1;-ms-animation-timing-function:linear;-webkit-animation-delay:3s;-webkit-animation-duration:5s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-1;-webkit-animation-timing-function:linear">
|
||||
<polygon points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3" fill="#ffa500"
|
||||
stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon-star-2"
|
||||
style="-moz-animation-delay:5s;-moz-animation-duration:4s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-2;-moz-animation-timing-function:linear;-ms-animation-delay:5s;-ms-animation-duration:4s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-2;-ms-animation-timing-function:linear;-webkit-animation-delay:5s;-webkit-animation-duration:4s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-2;-webkit-animation-timing-function:linear">
|
||||
<polygon transform="translate(20,10)" points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3"
|
||||
fill="#ffa500" stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon"
|
||||
style="-moz-animation-duration:6s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-moon;-moz-animation-timing-function:linear;-moz-transform-origin:12.5px 15.15px 0;-ms-animation-duration:6s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-moon;-ms-animation-timing-function:linear;-ms-transform-origin:12.5px 15.15px 0;-webkit-animation-duration:6s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-moon;-webkit-animation-timing-function:linear;-webkit-transform-origin:12.5px 15.15px 0">
|
||||
<path
|
||||
d="m14.5 13.2c0-3.7 2-6.9 5-8.7-1.5-0.9-3.2-1.3-5-1.3-5.5 0-10 4.5-10 10s4.5 10 10 10c1.8 0 3.5-0.5 5-1.3-3-1.7-5-5-5-8.7z"
|
||||
fill="#ffa500" stroke="#ffa500" stroke-linejoin="round" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-3"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-snow-1"
|
||||
style="-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow;-moz-animation-timing-function:linear;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow;-ms-animation-timing-function:linear;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(12,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
273
services/web-svc/public/weather-ico/snowy-2-day.svg
Normal file
@@ -0,0 +1,273 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.23099" width="1.403" height="1.5634">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** SUN
|
||||
*/
|
||||
@keyframes am-weather-sun {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun {
|
||||
-webkit-animation-name: am-weather-sun;
|
||||
-moz-animation-name: am-weather-sun;
|
||||
-ms-animation-name: am-weather-sun;
|
||||
animation-name: am-weather-sun;
|
||||
-webkit-animation-duration: 9s;
|
||||
-moz-animation-duration: 9s;
|
||||
-ms-animation-duration: 9s;
|
||||
animation-duration: 9s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-sun-shiny {
|
||||
0% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
|
||||
50% {
|
||||
stroke-dasharray: 0.1px 10px;
|
||||
stroke-dashoffset: -1px;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun-shiny line {
|
||||
-webkit-animation-name: am-weather-sun-shiny;
|
||||
-moz-animation-name: am-weather-sun-shiny;
|
||||
-ms-animation-name: am-weather-sun-shiny;
|
||||
animation-name: am-weather-sun-shiny;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** SNOW
|
||||
*/
|
||||
@keyframes am-weather-snow {
|
||||
0% {
|
||||
-webkit-transform: translateX(0) translateY(0);
|
||||
-moz-transform: translateX(0) translateY(0);
|
||||
-ms-transform: translateX(0) translateY(0);
|
||||
transform: translateX(0) translateY(0);
|
||||
}
|
||||
|
||||
33.33% {
|
||||
-webkit-transform: translateX(-1.2px) translateY(2px);
|
||||
-moz-transform: translateX(-1.2px) translateY(2px);
|
||||
-ms-transform: translateX(-1.2px) translateY(2px);
|
||||
transform: translateX(-1.2px) translateY(2px);
|
||||
}
|
||||
|
||||
66.66% {
|
||||
-webkit-transform: translateX(1.4px) translateY(4px);
|
||||
-moz-transform: translateX(1.4px) translateY(4px);
|
||||
-ms-transform: translateX(1.4px) translateY(4px);
|
||||
transform: translateX(1.4px) translateY(4px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translateX(-1.6px) translateY(6px);
|
||||
-moz-transform: translateX(-1.6px) translateY(6px);
|
||||
-ms-transform: translateX(-1.6px) translateY(6px);
|
||||
transform: translateX(-1.6px) translateY(6px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-snow-1 {
|
||||
-webkit-animation-name: am-weather-snow;
|
||||
-moz-animation-name: am-weather-snow;
|
||||
-ms-animation-name: am-weather-snow;
|
||||
animation-name: am-weather-snow;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.am-weather-snow-2 {
|
||||
-webkit-animation-name: am-weather-snow;
|
||||
-moz-animation-name: am-weather-snow;
|
||||
-ms-animation-name: am-weather-snow;
|
||||
animation-name: am-weather-snow;
|
||||
-webkit-animation-delay: 1.2s;
|
||||
-moz-animation-delay: 1.2s;
|
||||
-ms-animation-delay: 1.2s;
|
||||
animation-delay: 1.2s;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="translate(0,16)">
|
||||
<g class="am-weather-sun"
|
||||
style="-moz-animation-duration:9s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-sun;-moz-animation-timing-function:linear;-ms-animation-duration:9s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-sun;-ms-animation-timing-function:linear;-webkit-animation-duration:9s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-sun;-webkit-animation-timing-function:linear">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round" stroke-width="2" />
|
||||
<g transform="rotate(45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(135)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="scale(-1)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(225)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<circle r="5" fill="#ffa500" stroke="#ffa500" stroke-width="2" />
|
||||
</g>
|
||||
<g class="am-weather-cloud-3"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-snow-1"
|
||||
style="-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow;-moz-animation-timing-function:linear;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow;-ms-animation-timing-function:linear;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(7,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-snow-2"
|
||||
style="-moz-animation-delay:1.2s;-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow;-moz-animation-timing-function:linear;-ms-animation-delay:1.2s;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow;-ms-animation-timing-function:linear;-webkit-animation-delay:1.2s;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(16,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
301
services/web-svc/public/weather-ico/snowy-2-night.svg
Normal file
@@ -0,0 +1,301 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.20655" y="-.23099" width="1.403" height="1.5634">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** MOON
|
||||
*/
|
||||
@keyframes am-weather-moon {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
-moz-transform: rotate(15deg);
|
||||
-ms-transform: rotate(15deg);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon {
|
||||
-webkit-animation-name: am-weather-moon;
|
||||
-moz-animation-name: am-weather-moon;
|
||||
-ms-animation-name: am-weather-moon;
|
||||
animation-name: am-weather-moon;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
-webkit-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-moz-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-ms-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-1 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-1 {
|
||||
-webkit-animation-name: am-weather-moon-star-1;
|
||||
-moz-animation-name: am-weather-moon-star-1;
|
||||
-ms-animation-name: am-weather-moon-star-1;
|
||||
animation-name: am-weather-moon-star-1;
|
||||
-webkit-animation-delay: 3s;
|
||||
-moz-animation-delay: 3s;
|
||||
-ms-animation-delay: 3s;
|
||||
animation-delay: 3s;
|
||||
-webkit-animation-duration: 5s;
|
||||
-moz-animation-duration: 5s;
|
||||
-ms-animation-duration: 5s;
|
||||
animation-duration: 5s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-2 {
|
||||
-webkit-animation-name: am-weather-moon-star-2;
|
||||
-moz-animation-name: am-weather-moon-star-2;
|
||||
-ms-animation-name: am-weather-moon-star-2;
|
||||
animation-name: am-weather-moon-star-2;
|
||||
-webkit-animation-delay: 5s;
|
||||
-moz-animation-delay: 5s;
|
||||
-ms-animation-delay: 5s;
|
||||
animation-delay: 5s;
|
||||
-webkit-animation-duration: 4s;
|
||||
-moz-animation-duration: 4s;
|
||||
-ms-animation-duration: 4s;
|
||||
animation-duration: 4s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
** SNOW
|
||||
*/
|
||||
@keyframes am-weather-snow {
|
||||
0% {
|
||||
-webkit-transform: translateX(0) translateY(0);
|
||||
-moz-transform: translateX(0) translateY(0);
|
||||
-ms-transform: translateX(0) translateY(0);
|
||||
transform: translateX(0) translateY(0);
|
||||
}
|
||||
|
||||
33.33% {
|
||||
-webkit-transform: translateX(-1.2px) translateY(2px);
|
||||
-moz-transform: translateX(-1.2px) translateY(2px);
|
||||
-ms-transform: translateX(-1.2px) translateY(2px);
|
||||
transform: translateX(-1.2px) translateY(2px);
|
||||
}
|
||||
|
||||
66.66% {
|
||||
-webkit-transform: translateX(1.4px) translateY(4px);
|
||||
-moz-transform: translateX(1.4px) translateY(4px);
|
||||
-ms-transform: translateX(1.4px) translateY(4px);
|
||||
transform: translateX(1.4px) translateY(4px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translateX(-1.6px) translateY(6px);
|
||||
-moz-transform: translateX(-1.6px) translateY(6px);
|
||||
-ms-transform: translateX(-1.6px) translateY(6px);
|
||||
transform: translateX(-1.6px) translateY(6px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-snow-1 {
|
||||
-webkit-animation-name: am-weather-snow;
|
||||
-moz-animation-name: am-weather-snow;
|
||||
-ms-animation-name: am-weather-snow;
|
||||
animation-name: am-weather-snow;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.am-weather-snow-2 {
|
||||
-webkit-animation-name: am-weather-snow;
|
||||
-moz-animation-name: am-weather-snow;
|
||||
-ms-animation-name: am-weather-snow;
|
||||
animation-name: am-weather-snow;
|
||||
-webkit-animation-delay: 1.2s;
|
||||
-moz-animation-delay: 1.2s;
|
||||
-ms-animation-delay: 1.2s;
|
||||
animation-delay: 1.2s;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="matrix(.8 0 0 .8 16 4)">
|
||||
<g class="am-weather-moon-star-1"
|
||||
style="-moz-animation-delay:3s;-moz-animation-duration:5s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-1;-moz-animation-timing-function:linear;-ms-animation-delay:3s;-ms-animation-duration:5s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-1;-ms-animation-timing-function:linear;-webkit-animation-delay:3s;-webkit-animation-duration:5s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-1;-webkit-animation-timing-function:linear">
|
||||
<polygon points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3" fill="#ffa500"
|
||||
stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon-star-2"
|
||||
style="-moz-animation-delay:5s;-moz-animation-duration:4s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-2;-moz-animation-timing-function:linear;-ms-animation-delay:5s;-ms-animation-duration:4s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-2;-ms-animation-timing-function:linear;-webkit-animation-delay:5s;-webkit-animation-duration:4s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-2;-webkit-animation-timing-function:linear">
|
||||
<polygon transform="translate(20,10)" points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3"
|
||||
fill="#ffa500" stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon"
|
||||
style="-moz-animation-duration:6s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-moon;-moz-animation-timing-function:linear;-moz-transform-origin:12.5px 15.15px 0;-ms-animation-duration:6s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-moon;-ms-animation-timing-function:linear;-ms-transform-origin:12.5px 15.15px 0;-webkit-animation-duration:6s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-moon;-webkit-animation-timing-function:linear;-webkit-transform-origin:12.5px 15.15px 0">
|
||||
<path
|
||||
d="m14.5 13.2c0-3.7 2-6.9 5-8.7-1.5-0.9-3.2-1.3-5-1.3-5.5 0-10 4.5-10 10s4.5 10 10 10c1.8 0 3.5-0.5 5-1.3-3-1.7-5-5-5-8.7z"
|
||||
fill="#ffa500" stroke="#ffa500" stroke-linejoin="round" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-3"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-snow-1"
|
||||
style="-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow;-moz-animation-timing-function:linear;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow;-ms-animation-timing-function:linear;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(7,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-snow-2"
|
||||
style="-moz-animation-delay:1.2s;-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow;-moz-animation-timing-function:linear;-ms-animation-delay:1.2s;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow;-ms-animation-timing-function:linear;-webkit-animation-delay:1.2s;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(16,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
334
services/web-svc/public/weather-ico/snowy-3-day.svg
Normal file
@@ -0,0 +1,334 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.24684" y="-.26897" width="1.4937" height="1.6759">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** SUN
|
||||
*/
|
||||
@keyframes am-weather-sun {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun {
|
||||
-webkit-animation-name: am-weather-sun;
|
||||
-moz-animation-name: am-weather-sun;
|
||||
-ms-animation-name: am-weather-sun;
|
||||
animation-name: am-weather-sun;
|
||||
-webkit-animation-duration: 9s;
|
||||
-moz-animation-duration: 9s;
|
||||
-ms-animation-duration: 9s;
|
||||
animation-duration: 9s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes am-weather-sun-shiny {
|
||||
0% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
|
||||
50% {
|
||||
stroke-dasharray: 0.1px 10px;
|
||||
stroke-dashoffset: -1px;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dasharray: 3px 10px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-sun-shiny line {
|
||||
-webkit-animation-name: am-weather-sun-shiny;
|
||||
-moz-animation-name: am-weather-sun-shiny;
|
||||
-ms-animation-name: am-weather-sun-shiny;
|
||||
animation-name: am-weather-sun-shiny;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** SNOW
|
||||
*/
|
||||
@keyframes am-weather-snow {
|
||||
0% {
|
||||
-webkit-transform: translateX(0) translateY(0);
|
||||
-moz-transform: translateX(0) translateY(0);
|
||||
-ms-transform: translateX(0) translateY(0);
|
||||
transform: translateX(0) translateY(0);
|
||||
}
|
||||
|
||||
33.33% {
|
||||
-webkit-transform: translateX(-1.2px) translateY(2px);
|
||||
-moz-transform: translateX(-1.2px) translateY(2px);
|
||||
-ms-transform: translateX(-1.2px) translateY(2px);
|
||||
transform: translateX(-1.2px) translateY(2px);
|
||||
}
|
||||
|
||||
66.66% {
|
||||
-webkit-transform: translateX(1.4px) translateY(4px);
|
||||
-moz-transform: translateX(1.4px) translateY(4px);
|
||||
-ms-transform: translateX(1.4px) translateY(4px);
|
||||
transform: translateX(1.4px) translateY(4px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translateX(-1.6px) translateY(6px);
|
||||
-moz-transform: translateX(-1.6px) translateY(6px);
|
||||
-ms-transform: translateX(-1.6px) translateY(6px);
|
||||
transform: translateX(-1.6px) translateY(6px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes am-weather-snow-reverse {
|
||||
0% {
|
||||
-webkit-transform: translateX(0) translateY(0);
|
||||
-moz-transform: translateX(0) translateY(0);
|
||||
-ms-transform: translateX(0) translateY(0);
|
||||
transform: translateX(0) translateY(0);
|
||||
}
|
||||
|
||||
33.33% {
|
||||
-webkit-transform: translateX(1.2px) translateY(2px);
|
||||
-moz-transform: translateX(1.2px) translateY(2px);
|
||||
-ms-transform: translateX(1.2px) translateY(2px);
|
||||
transform: translateX(1.2px) translateY(2px);
|
||||
}
|
||||
|
||||
66.66% {
|
||||
-webkit-transform: translateX(-1.4px) translateY(4px);
|
||||
-moz-transform: translateX(-1.4px) translateY(4px);
|
||||
-ms-transform: translateX(-1.4px) translateY(4px);
|
||||
transform: translateX(-1.4px) translateY(4px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translateX(1.6px) translateY(6px);
|
||||
-moz-transform: translateX(1.6px) translateY(6px);
|
||||
-ms-transform: translateX(1.6px) translateY(6px);
|
||||
transform: translateX(1.6px) translateY(6px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-snow-1 {
|
||||
-webkit-animation-name: am-weather-snow;
|
||||
-moz-animation-name: am-weather-snow;
|
||||
-ms-animation-name: am-weather-snow;
|
||||
animation-name: am-weather-snow;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.am-weather-snow-2 {
|
||||
-webkit-animation-name: am-weather-snow;
|
||||
-moz-animation-name: am-weather-snow;
|
||||
-ms-animation-name: am-weather-snow;
|
||||
animation-name: am-weather-snow;
|
||||
-webkit-animation-delay: 1.2s;
|
||||
-moz-animation-delay: 1.2s;
|
||||
-ms-animation-delay: 1.2s;
|
||||
animation-delay: 1.2s;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.am-weather-snow-3 {
|
||||
-webkit-animation-name: am-weather-snow-reverse;
|
||||
-moz-animation-name: am-weather-snow-reverse;
|
||||
-ms-animation-name: am-weather-snow-reverse;
|
||||
animation-name: am-weather-snow-reverse;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="translate(0,16)">
|
||||
<g class="am-weather-sun"
|
||||
style="-moz-animation-duration:9s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-sun;-moz-animation-timing-function:linear;-ms-animation-duration:9s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-sun;-ms-animation-timing-function:linear;-webkit-animation-duration:9s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-sun;-webkit-animation-timing-function:linear">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
<g transform="rotate(45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(135)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="scale(-1)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(225)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-90)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
<g transform="rotate(-45)">
|
||||
<line transform="translate(0,9)" y2="3" fill="none" stroke="#ffa500" stroke-linecap="round"
|
||||
stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<circle r="5" fill="#ffa500" stroke="#ffa500" stroke-width="2" />
|
||||
</g>
|
||||
<g class="am-weather-cloud-2"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-snow-1"
|
||||
style="-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow;-moz-animation-timing-function:linear;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow;-ms-animation-timing-function:linear;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(3,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-snow-2"
|
||||
style="-moz-animation-delay:1.2s;-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow;-moz-animation-timing-function:linear;-ms-animation-delay:1.2s;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow;-ms-animation-timing-function:linear;-webkit-animation-delay:1.2s;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(11,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-snow-3"
|
||||
style="-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow-reverse;-moz-animation-timing-function:linear;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow-reverse;-ms-animation-timing-function:linear;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow-reverse;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(20,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
361
services/web-svc/public/weather-ico/snowy-3-night.svg
Normal file
@@ -0,0 +1,361 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- (c) ammap.com | SVG weather icons -->
|
||||
<svg width="56" height="48" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="blur" x="-.24684" y="-.26897" width="1.4937" height="1.6759">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="0" dy="4" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA slope="0.05" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
/*
|
||||
** CLOUDS
|
||||
*/
|
||||
@keyframes am-weather-cloud-2 {
|
||||
0% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translate(2px, 0px);
|
||||
-moz-transform: translate(2px, 0px);
|
||||
-ms-transform: translate(2px, 0px);
|
||||
transform: translate(2px, 0px);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translate(0px, 0px);
|
||||
-moz-transform: translate(0px, 0px);
|
||||
-ms-transform: translate(0px, 0px);
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-cloud-2 {
|
||||
-webkit-animation-name: am-weather-cloud-2;
|
||||
-moz-animation-name: am-weather-cloud-2;
|
||||
animation-name: am-weather-cloud-2;
|
||||
-webkit-animation-duration: 3s;
|
||||
-moz-animation-duration: 3s;
|
||||
animation-duration: 3s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
** MOON
|
||||
*/
|
||||
@keyframes am-weather-moon {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
-moz-transform: rotate(15deg);
|
||||
-ms-transform: rotate(15deg);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon {
|
||||
-webkit-animation-name: am-weather-moon;
|
||||
-moz-animation-name: am-weather-moon;
|
||||
-ms-animation-name: am-weather-moon;
|
||||
animation-name: am-weather-moon;
|
||||
-webkit-animation-duration: 6s;
|
||||
-moz-animation-duration: 6s;
|
||||
-ms-animation-duration: 6s;
|
||||
animation-duration: 6s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
-webkit-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-moz-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
-ms-transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
transform-origin: 12.5px 15.15px 0;
|
||||
/* TODO FF CENTER ISSUE */
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-1 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-1 {
|
||||
-webkit-animation-name: am-weather-moon-star-1;
|
||||
-moz-animation-name: am-weather-moon-star-1;
|
||||
-ms-animation-name: am-weather-moon-star-1;
|
||||
animation-name: am-weather-moon-star-1;
|
||||
-webkit-animation-delay: 3s;
|
||||
-moz-animation-delay: 3s;
|
||||
-ms-animation-delay: 3s;
|
||||
animation-delay: 3s;
|
||||
-webkit-animation-duration: 5s;
|
||||
-moz-animation-duration: 5s;
|
||||
-ms-animation-duration: 5s;
|
||||
animation-duration: 5s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@keyframes am-weather-moon-star-2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-moon-star-2 {
|
||||
-webkit-animation-name: am-weather-moon-star-2;
|
||||
-moz-animation-name: am-weather-moon-star-2;
|
||||
-ms-animation-name: am-weather-moon-star-2;
|
||||
animation-name: am-weather-moon-star-2;
|
||||
-webkit-animation-delay: 5s;
|
||||
-moz-animation-delay: 5s;
|
||||
-ms-animation-delay: 5s;
|
||||
animation-delay: 5s;
|
||||
-webkit-animation-duration: 4s;
|
||||
-moz-animation-duration: 4s;
|
||||
-ms-animation-duration: 4s;
|
||||
animation-duration: 4s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
-moz-animation-iteration-count: 1;
|
||||
-ms-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
** SNOW
|
||||
*/
|
||||
@keyframes am-weather-snow {
|
||||
0% {
|
||||
-webkit-transform: translateX(0) translateY(0);
|
||||
-moz-transform: translateX(0) translateY(0);
|
||||
-ms-transform: translateX(0) translateY(0);
|
||||
transform: translateX(0) translateY(0);
|
||||
}
|
||||
|
||||
33.33% {
|
||||
-webkit-transform: translateX(-1.2px) translateY(2px);
|
||||
-moz-transform: translateX(-1.2px) translateY(2px);
|
||||
-ms-transform: translateX(-1.2px) translateY(2px);
|
||||
transform: translateX(-1.2px) translateY(2px);
|
||||
}
|
||||
|
||||
66.66% {
|
||||
-webkit-transform: translateX(1.4px) translateY(4px);
|
||||
-moz-transform: translateX(1.4px) translateY(4px);
|
||||
-ms-transform: translateX(1.4px) translateY(4px);
|
||||
transform: translateX(1.4px) translateY(4px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translateX(-1.6px) translateY(6px);
|
||||
-moz-transform: translateX(-1.6px) translateY(6px);
|
||||
-ms-transform: translateX(-1.6px) translateY(6px);
|
||||
transform: translateX(-1.6px) translateY(6px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes am-weather-snow-reverse {
|
||||
0% {
|
||||
-webkit-transform: translateX(0) translateY(0);
|
||||
-moz-transform: translateX(0) translateY(0);
|
||||
-ms-transform: translateX(0) translateY(0);
|
||||
transform: translateX(0) translateY(0);
|
||||
}
|
||||
|
||||
33.33% {
|
||||
-webkit-transform: translateX(1.2px) translateY(2px);
|
||||
-moz-transform: translateX(1.2px) translateY(2px);
|
||||
-ms-transform: translateX(1.2px) translateY(2px);
|
||||
transform: translateX(1.2px) translateY(2px);
|
||||
}
|
||||
|
||||
66.66% {
|
||||
-webkit-transform: translateX(-1.4px) translateY(4px);
|
||||
-moz-transform: translateX(-1.4px) translateY(4px);
|
||||
-ms-transform: translateX(-1.4px) translateY(4px);
|
||||
transform: translateX(-1.4px) translateY(4px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: translateX(1.6px) translateY(6px);
|
||||
-moz-transform: translateX(1.6px) translateY(6px);
|
||||
-ms-transform: translateX(1.6px) translateY(6px);
|
||||
transform: translateX(1.6px) translateY(6px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.am-weather-snow-1 {
|
||||
-webkit-animation-name: am-weather-snow;
|
||||
-moz-animation-name: am-weather-snow;
|
||||
-ms-animation-name: am-weather-snow;
|
||||
animation-name: am-weather-snow;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.am-weather-snow-2 {
|
||||
-webkit-animation-name: am-weather-snow;
|
||||
-moz-animation-name: am-weather-snow;
|
||||
-ms-animation-name: am-weather-snow;
|
||||
animation-name: am-weather-snow;
|
||||
-webkit-animation-delay: 1.2s;
|
||||
-moz-animation-delay: 1.2s;
|
||||
-ms-animation-delay: 1.2s;
|
||||
animation-delay: 1.2s;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.am-weather-snow-3 {
|
||||
-webkit-animation-name: am-weather-snow-reverse;
|
||||
-moz-animation-name: am-weather-snow-reverse;
|
||||
-ms-animation-name: am-weather-snow-reverse;
|
||||
animation-name: am-weather-snow-reverse;
|
||||
-webkit-animation-duration: 2s;
|
||||
-moz-animation-duration: 2s;
|
||||
-ms-animation-duration: 2s;
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-timing-function: linear;
|
||||
-moz-animation-timing-function: linear;
|
||||
-ms-animation-timing-function: linear;
|
||||
animation-timing-function: linear;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-ms-animation-iteration-count: infinite;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(16,-2)" filter="url(#blur)">
|
||||
<g transform="matrix(.8 0 0 .8 16 4)">
|
||||
<g class="am-weather-moon-star-1"
|
||||
style="-moz-animation-delay:3s;-moz-animation-duration:5s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-1;-moz-animation-timing-function:linear;-ms-animation-delay:3s;-ms-animation-duration:5s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-1;-ms-animation-timing-function:linear;-webkit-animation-delay:3s;-webkit-animation-duration:5s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-1;-webkit-animation-timing-function:linear">
|
||||
<polygon points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3" fill="#ffa500"
|
||||
stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon-star-2"
|
||||
style="-moz-animation-delay:5s;-moz-animation-duration:4s;-moz-animation-iteration-count:1;-moz-animation-name:am-weather-moon-star-2;-moz-animation-timing-function:linear;-ms-animation-delay:5s;-ms-animation-duration:4s;-ms-animation-iteration-count:1;-ms-animation-name:am-weather-moon-star-2;-ms-animation-timing-function:linear;-webkit-animation-delay:5s;-webkit-animation-duration:4s;-webkit-animation-iteration-count:1;-webkit-animation-name:am-weather-moon-star-2;-webkit-animation-timing-function:linear">
|
||||
<polygon transform="translate(20,10)" points="4 4 3.3 5.2 2.7 4 1.5 3.3 2.7 2.7 3.3 1.5 4 2.7 5.2 3.3"
|
||||
fill="#ffa500" stroke-miterlimit="10" />
|
||||
</g>
|
||||
<g class="am-weather-moon"
|
||||
style="-moz-animation-duration:6s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-moon;-moz-animation-timing-function:linear;-moz-transform-origin:12.5px 15.15px 0;-ms-animation-duration:6s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-moon;-ms-animation-timing-function:linear;-ms-transform-origin:12.5px 15.15px 0;-webkit-animation-duration:6s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-moon;-webkit-animation-timing-function:linear;-webkit-transform-origin:12.5px 15.15px 0">
|
||||
<path
|
||||
d="m14.5 13.2c0-3.7 2-6.9 5-8.7-1.5-0.9-3.2-1.3-5-1.3-5.5 0-10 4.5-10 10s4.5 10 10 10c1.8 0 3.5-0.5 5-1.3-3-1.7-5-5-5-8.7z"
|
||||
fill="#ffa500" stroke="#ffa500" stroke-linejoin="round" stroke-width="2" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-cloud-2"
|
||||
style="-moz-animation-duration:3s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-cloud-2;-moz-animation-timing-function:linear;-webkit-animation-duration:3s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-cloud-2;-webkit-animation-timing-function:linear">
|
||||
<path transform="translate(-20,-11)"
|
||||
d="m47.7 35.4c0-4.6-3.7-8.2-8.2-8.2-1 0-1.9 0.2-2.8 0.5-0.3-3.4-3.1-6.2-6.6-6.2-3.7 0-6.7 3-6.7 6.7 0 0.8 0.2 1.6 0.4 2.3-0.3-0.1-0.7-0.1-1-0.1-3.7 0-6.7 3-6.7 6.7 0 3.6 2.9 6.6 6.5 6.7h17.2c4.4-0.5 7.9-4 7.9-8.4z"
|
||||
fill="#57a0ee" stroke="#fff" stroke-linejoin="round" stroke-width="1.2" />
|
||||
</g>
|
||||
<g class="am-weather-snow-1"
|
||||
style="-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow;-moz-animation-timing-function:linear;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow;-ms-animation-timing-function:linear;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(3,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-snow-2"
|
||||
style="-moz-animation-delay:1.2s;-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow;-moz-animation-timing-function:linear;-ms-animation-delay:1.2s;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow;-ms-animation-timing-function:linear;-webkit-animation-delay:1.2s;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(11,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="am-weather-snow-3"
|
||||
style="-moz-animation-duration:2s;-moz-animation-iteration-count:infinite;-moz-animation-name:am-weather-snow-reverse;-moz-animation-timing-function:linear;-ms-animation-duration:2s;-ms-animation-iteration-count:infinite;-ms-animation-name:am-weather-snow-reverse;-ms-animation-timing-function:linear;-webkit-animation-duration:2s;-webkit-animation-iteration-count:infinite;-webkit-animation-name:am-weather-snow-reverse;-webkit-animation-timing-function:linear">
|
||||
<g transform="translate(20,28)" fill="none" stroke="#57a0ee" stroke-linecap="round">
|
||||
<line transform="translate(0,9)" y1="-2.5" y2="2.5" stroke-width="1.2" />
|
||||
<line transform="rotate(45,-10.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(90,-4.5,4.5)" y1="-2.5" y2="2.5" />
|
||||
<line transform="rotate(135,-1.864,4.5)" y1="-2.5" y2="2.5" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 17 KiB |
11
services/web-svc/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Health probe для K8s liveness
|
||||
* docs/architecture: 05-gaps-and-best-practices.md §9
|
||||
*/
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({ status: 'ok', service: 'web-svc' });
|
||||
}
|
||||
17
services/web-svc/src/app/api/metrics/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Prometheus /metrics — docs/architecture: 05-gaps-and-best-practices.md §5
|
||||
*/
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
const body =
|
||||
'# HELP gooseek_up Service is up (1) or down (0)\n' +
|
||||
'# TYPE gooseek_up gauge\n' +
|
||||
'gooseek_up 1\n';
|
||||
|
||||
return new Response(body, {
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
12
services/web-svc/src/app/api/ready/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Readiness probe для K8s
|
||||
* web-svc не имеет критичных внешних зависимостей при старте
|
||||
* docs/architecture: 05-gaps-and-best-practices.md §9
|
||||
*/
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({ status: 'ready' });
|
||||
}
|
||||
94
services/web-svc/src/app/api/reconnect/[id]/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import SessionManager from '@/lib/session';
|
||||
|
||||
export const POST = async (
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const session = SessionManager.getSession(id);
|
||||
|
||||
if (!session) {
|
||||
return Response.json({ message: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const responseStream = new TransformStream();
|
||||
const writer = responseStream.writable.getWriter();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
let unsub: () => void = () => {};
|
||||
unsub = session.subscribe((event, data) => {
|
||||
if (event === 'data') {
|
||||
if (data.type === 'block') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'block',
|
||||
block: data.block,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
} else if (data.type === 'updateBlock') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'updateBlock',
|
||||
blockId: data.blockId,
|
||||
patch: data.patch,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
} else if (data.type === 'researchComplete') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'researchComplete',
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (event === 'end') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'messageEnd',
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
writer.close();
|
||||
setImmediate(() => unsub());
|
||||
} else if (event === 'error') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
data: data.data,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
writer.close();
|
||||
setImmediate(() => unsub());
|
||||
}
|
||||
});
|
||||
|
||||
req.signal.addEventListener('abort', () => {
|
||||
unsub();
|
||||
writer.close();
|
||||
});
|
||||
|
||||
return new Response(responseStream.readable, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
Connection: 'keep-alive',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in reconnecting to session stream: ', err);
|
||||
return Response.json(
|
||||
{ message: 'An error has occurred.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
34
services/web-svc/src/app/api/uploads/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Тонкий прокси к chat-svc. Вся логика uploads на бекенде.
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const chatUrl = process.env.CHAT_SVC_URL ?? 'http://localhost:3005';
|
||||
|
||||
const newFormData = new FormData();
|
||||
const providerId = formData.get('embedding_model_provider_id');
|
||||
const modelKey = formData.get('embedding_model_key');
|
||||
if (providerId) newFormData.set('embedding_model_provider_id', String(providerId));
|
||||
if (modelKey) newFormData.set('embedding_model_key', String(modelKey));
|
||||
|
||||
const files = formData.getAll('files');
|
||||
for (const f of files) {
|
||||
if (f instanceof File) newFormData.append('files', f);
|
||||
}
|
||||
|
||||
const res = await fetch(`${chatUrl}/api/v1/uploads`, {
|
||||
method: 'POST',
|
||||
body: newFormData,
|
||||
signal: AbortSignal.timeout(300000),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (err) {
|
||||
console.error('Upload proxy error:', err);
|
||||
return NextResponse.json({ message: 'An error has occurred.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
5
services/web-svc/src/app/c/[chatId]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import ChatWindow from '@/components/ChatWindow';
|
||||
|
||||
export default ChatWindow;
|
||||
80
services/web-svc/src/app/collections/[id]/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, FolderOpen } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Collection {
|
||||
id: string;
|
||||
title: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const Page = () => {
|
||||
const params = useParams();
|
||||
const id = params?.id as string;
|
||||
const [collection, setCollection] = useState<Collection | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const fetchCollection = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/collections/${id}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) setCollection(null);
|
||||
else throw new Error('Failed to fetch');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setCollection(data);
|
||||
} catch {
|
||||
toast.error('Failed to load collection');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCollection();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-4xl mx-auto w-full">
|
||||
<Link
|
||||
href="/spaces"
|
||||
className="inline-flex items-center gap-1 text-sm text-black/60 dark:text-white/60 hover:text-black dark:hover:text-white mb-4"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Back to Spaces
|
||||
</Link>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-48 rounded-2xl bg-light-secondary dark:bg-dark-secondary animate-pulse" />
|
||||
) : collection ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
|
||||
<FolderOpen size={40} className="text-[#7C3AED]" />
|
||||
<h1 className="text-3xl font-normal">{collection.title}</h1>
|
||||
</div>
|
||||
{collection.category && (
|
||||
<span className="inline-block mt-4 text-sm px-2 py-1 rounded bg-light-200 dark:bg-dark-200">
|
||||
{collection.category}
|
||||
</span>
|
||||
)}
|
||||
{collection.description && (
|
||||
<p className="mt-4 text-black/70 dark:text-white/70">{collection.description}</p>
|
||||
)}
|
||||
<div className="mt-8 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary text-center text-black/60 dark:text-white/60">
|
||||
<p>Read-only collection. Full content integration coming soon.</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-6 text-black/60 dark:text-white/60">Collection not found.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
436
services/web-svc/src/app/discover/page.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
'use client';
|
||||
|
||||
import { Globe2Icon, Settings } from 'lucide-react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import SmallNewsCard from '@/components/Discover/SmallNewsCard';
|
||||
import MajorNewsCard from '@/components/Discover/MajorNewsCard';
|
||||
import DataFetchError from '@/components/DataFetchError';
|
||||
import { fetchContextWithClient } from '@/lib/geoDevice';
|
||||
|
||||
const COUNTRY_TO_REGION: Record<string, string> = {
|
||||
US: 'america', CA: 'america', MX: 'america',
|
||||
RU: 'russia', BY: 'russia', KZ: 'russia',
|
||||
CN: 'china', HK: 'china', TW: 'china',
|
||||
DE: 'eu', FR: 'eu', IT: 'eu', ES: 'eu', GB: 'eu', UK: 'eu',
|
||||
NL: 'eu', PL: 'eu', BE: 'eu', AT: 'eu', PT: 'eu', SE: 'eu',
|
||||
FI: 'eu', IE: 'eu', GR: 'eu', RO: 'eu', CZ: 'eu', HU: 'eu',
|
||||
BG: 'eu', HR: 'eu', SK: 'eu', SI: 'eu', LT: 'eu', LV: 'eu', EE: 'eu', DK: 'eu',
|
||||
};
|
||||
|
||||
const getRegionFromGeo = (countryCode: string | undefined): string | null => {
|
||||
if (!countryCode) return null;
|
||||
return COUNTRY_TO_REGION[countryCode] ?? null;
|
||||
};
|
||||
|
||||
const fetchRegion = async (): Promise<string | null> => {
|
||||
let region: string | null = null;
|
||||
try {
|
||||
const ctx = await fetchContextWithClient();
|
||||
region = getRegionFromGeo(ctx.geo?.countryCode);
|
||||
} catch {
|
||||
// geo-context недоступен
|
||||
}
|
||||
if (!region) {
|
||||
try {
|
||||
const res = await fetch('https://get.geojs.io/v1/ip/geo.json');
|
||||
const d = await res.json();
|
||||
region = getRegionFromGeo(d?.country_code);
|
||||
} catch {
|
||||
// GeoJS недоступен
|
||||
}
|
||||
}
|
||||
return region;
|
||||
};
|
||||
|
||||
export interface Discover {
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
const topics: { key: string; display: string }[] = [
|
||||
{
|
||||
display: 'GooSeek',
|
||||
key: 'gooseek',
|
||||
},
|
||||
{
|
||||
display: 'Tech & Science',
|
||||
key: 'tech',
|
||||
},
|
||||
{
|
||||
display: 'Finance',
|
||||
key: 'finance',
|
||||
},
|
||||
{
|
||||
display: 'Art & Culture',
|
||||
key: 'art',
|
||||
},
|
||||
{
|
||||
display: 'Sports',
|
||||
key: 'sports',
|
||||
},
|
||||
{
|
||||
display: 'Entertainment',
|
||||
key: 'entertainment',
|
||||
},
|
||||
];
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
const Page = () => {
|
||||
const [discover, setDiscover] = useState<Discover[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
|
||||
const [setupRequired, setSetupRequired] = useState(false);
|
||||
const regionPromiseRef = useRef<Promise<string | null> | null>(null);
|
||||
|
||||
const getRegionPromise = (): Promise<string | null> => {
|
||||
if (!regionPromiseRef.current) {
|
||||
regionPromiseRef.current = fetchRegion();
|
||||
}
|
||||
return regionPromiseRef.current;
|
||||
};
|
||||
|
||||
const fetchArticles = async (topic: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSetupRequired(false);
|
||||
try {
|
||||
const region = await getRegionPromise();
|
||||
const url = new URL('/api/v1/discover', window.location.origin);
|
||||
url.searchParams.set('topic', topic);
|
||||
if (region) url.searchParams.set('region', region);
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || 'Failed to load articles');
|
||||
}
|
||||
|
||||
if (topic !== 'gooseek') {
|
||||
data.blogs = data.blogs.filter((blog: Discover) => blog.thumbnail);
|
||||
}
|
||||
|
||||
setDiscover(data.blogs);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Error fetching data';
|
||||
if (message.includes('SearxNG is not configured')) {
|
||||
setSetupRequired(true);
|
||||
setDiscover(null);
|
||||
} else {
|
||||
setError(message.includes('timeout') || (err as Error)?.name === 'AbortError'
|
||||
? 'Request timed out. Try again.'
|
||||
: message);
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTopic === 'gooseek') {
|
||||
fetchArticles('gooseek');
|
||||
return;
|
||||
}
|
||||
fetchArticles(activeTopic);
|
||||
}, [activeTopic]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex flex-col pt-10 border-b border-light-200/20 dark:border-dark-200/20 pb-6 px-2">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<Globe2Icon size={45} className="mb-2.5" />
|
||||
<h1 className="text-5xl font-normal p-2">
|
||||
Discover
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-2 overflow-x-auto">
|
||||
{topics.map((t, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'border-[0.1px] rounded-full text-sm px-3 py-1 text-nowrap transition duration-200 cursor-pointer',
|
||||
activeTopic === t.key
|
||||
? 'text-[#EA580C] bg-[#EA580C]/20 border-[#EA580C]/60 dark:bg-[#EA580C]/30 dark:border-[#EA580C]/40'
|
||||
: 'border-black/30 dark:border-white/30 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:border-black/40 dark:hover:border-white/40 hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
onClick={() => setActiveTopic(t.key)}
|
||||
>
|
||||
<span>{t.display}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-4 pb-28 pt-5 lg:pb-8 w-full">
|
||||
{/* Mobile: SmallNewsCard — rounded-3xl, aspect-video, p-4, h3 mb-2 + p */}
|
||||
<div className="block lg:hidden">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-3xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-col"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="p-4">
|
||||
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
|
||||
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
|
||||
<div className="h-3 w-2/3 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="h-3 w-1/2 rounded bg-light-200 dark:bg-dark-200 animate-pulse mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Desktop: точно как реальный layout — 1st Major isLeft=false (текст слева, картинка справа) */}
|
||||
<div className="hidden lg:block space-y-0">
|
||||
{/* 1st MajorNewsCard isLeft=FALSE: content LEFT, image RIGHT */}
|
||||
<div className="w-full flex flex-row items-stretch gap-6 h-60 py-3">
|
||||
<div className="flex flex-col justify-center flex-1 py-4 space-y-3">
|
||||
<div className="h-8 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="h-4 w-1/2 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
</div>
|
||||
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0 bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
</div>
|
||||
<hr className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full" />
|
||||
{/* 3× SmallNewsCard */}
|
||||
<div className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-3xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-col"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="p-4">
|
||||
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
|
||||
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
|
||||
<div className="h-3 w-2/3 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="h-3 w-1/2 rounded bg-light-200 dark:bg-dark-200 animate-pulse mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<hr className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full" />
|
||||
{/* 2nd MajorNewsCard isLeft=TRUE: image LEFT, content RIGHT */}
|
||||
<div className="w-full flex flex-row items-stretch gap-6 h-60 py-3">
|
||||
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0 bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="flex flex-col justify-center flex-1 py-4 space-y-3">
|
||||
<div className="h-8 w-2/3 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full" />
|
||||
{/* 3rd MajorNewsCard isLeft=FALSE: content LEFT, image RIGHT */}
|
||||
<div className="w-full flex flex-row items-stretch gap-6 h-60 py-3">
|
||||
<div className="flex flex-col justify-center flex-1 py-4 space-y-3">
|
||||
<div className="h-8 w-2/3 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
</div>
|
||||
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0 bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
</div>
|
||||
<hr className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full" />
|
||||
{/* 3× SmallNewsCard */}
|
||||
<div className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-3xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-col"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="p-4">
|
||||
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
|
||||
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
|
||||
<div className="h-3 w-2/3 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="h-3 w-1/2 rounded bg-light-200 dark:bg-dark-200 animate-pulse mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="pb-28 pt-5 lg:pb-8 px-4">
|
||||
<DataFetchError message={error} onRetry={() => fetchArticles(activeTopic)} />
|
||||
</div>
|
||||
) : setupRequired ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 px-4 text-center">
|
||||
<div className="rounded-full p-4 bg-[#EA580C]/10 dark:bg-[#EA580C]/5">
|
||||
<Settings size={48} className="text-[#EA580C]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-medium text-black dark:text-white">
|
||||
Configure SearxNG to discover articles
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-black/60 dark:text-white/60 max-w-md">
|
||||
Open Settings (gear icon in the navbar), go to Search, and enter
|
||||
your SearxNG URL (e.g. http://localhost:8080)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : discover && discover.length > 0 ? (
|
||||
<div className="flex flex-col gap-4 pb-28 pt-5 lg:pb-8 w-full">
|
||||
<div className="block lg:hidden">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{discover?.map((item, i) => (
|
||||
<SmallNewsCard key={`mobile-${i}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
{discover &&
|
||||
discover.length > 0 &&
|
||||
(() => {
|
||||
const sections = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < discover.length) {
|
||||
if (sections.length > 0) {
|
||||
sections.push(
|
||||
<hr
|
||||
key={`sep-${index}`}
|
||||
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
sections.push(
|
||||
<MajorNewsCard
|
||||
key={`major-${index}`}
|
||||
item={discover[index]}
|
||||
isLeft={false}
|
||||
/>,
|
||||
);
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
sections.push(
|
||||
<hr
|
||||
key={`sep-${index}-after`}
|
||||
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
const smallCards = discover.slice(index, index + 3);
|
||||
sections.push(
|
||||
<div
|
||||
key={`small-group-${index}`}
|
||||
className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4"
|
||||
>
|
||||
{smallCards.map((item, i) => (
|
||||
<SmallNewsCard
|
||||
key={`small-${index + i}`}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
);
|
||||
index += 3;
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
sections.push(
|
||||
<hr
|
||||
key={`sep-${index}-after-small`}
|
||||
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (index < discover.length - 1) {
|
||||
const twoMajorCards = discover.slice(index, index + 2);
|
||||
twoMajorCards.forEach((item, i) => {
|
||||
sections.push(
|
||||
<MajorNewsCard
|
||||
key={`double-${index + i}`}
|
||||
item={item}
|
||||
isLeft={i === 0}
|
||||
/>,
|
||||
);
|
||||
if (i === 0) {
|
||||
sections.push(
|
||||
<hr
|
||||
key={`sep-double-${index + i}`}
|
||||
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
});
|
||||
index += 2;
|
||||
} else if (index < discover.length) {
|
||||
sections.push(
|
||||
<MajorNewsCard
|
||||
key={`final-major-${index}`}
|
||||
item={discover[index]}
|
||||
isLeft={true}
|
||||
/>,
|
||||
);
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
sections.push(
|
||||
<hr
|
||||
key={`sep-${index}-after-major`}
|
||||
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
const smallCards = discover.slice(index, index + 3);
|
||||
sections.push(
|
||||
<div
|
||||
key={`small-group-2-${index}`}
|
||||
className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4"
|
||||
>
|
||||
{smallCards.map((item, i) => (
|
||||
<SmallNewsCard
|
||||
key={`small-2-${index + i}`}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
);
|
||||
index += 3;
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
) : discover && discover.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[40vh] px-4 text-center">
|
||||
<p className="text-black/60 dark:text-white/60 text-sm">
|
||||
No articles found for this topic.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
243
services/web-svc/src/app/finance/[ticker]/page.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, TrendingUp, FileText, BarChart3, Sparkles, Star } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QuoteData {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
high?: number;
|
||||
low?: number;
|
||||
}
|
||||
|
||||
interface NewsItem {
|
||||
title?: string;
|
||||
url?: string;
|
||||
publishedDate?: string;
|
||||
}
|
||||
|
||||
interface SecFiling {
|
||||
type?: string;
|
||||
fillingDate?: string;
|
||||
finalLink?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const WATCHLIST_KEY = 'gooseek_finance_watchlist';
|
||||
|
||||
const Page = () => {
|
||||
const params = useParams();
|
||||
const ticker = (params?.ticker as string)?.toUpperCase() ?? '';
|
||||
const [quote, setQuote] = useState<QuoteData | null>(null);
|
||||
const [inWatchlist, setInWatchlist] = useState(false);
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [filings, setFilings] = useState<SecFiling[]>([]);
|
||||
const [priceContext, setPriceContext] = useState<{
|
||||
summary?: string | null;
|
||||
news?: string[];
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ticker) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(WATCHLIST_KEY);
|
||||
const list = raw ? JSON.parse(raw) : [];
|
||||
setInWatchlist(Array.isArray(list) && list.includes(ticker));
|
||||
} catch {
|
||||
setInWatchlist(false);
|
||||
}
|
||||
}, [ticker]);
|
||||
|
||||
const toggleWatchlist = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(WATCHLIST_KEY);
|
||||
const list: string[] = raw ? JSON.parse(raw) : [];
|
||||
const idx = list.indexOf(ticker);
|
||||
if (idx >= 0) {
|
||||
list.splice(idx, 1);
|
||||
setInWatchlist(false);
|
||||
} else {
|
||||
list.push(ticker);
|
||||
setInWatchlist(true);
|
||||
}
|
||||
localStorage.setItem(WATCHLIST_KEY, JSON.stringify(list));
|
||||
} catch {
|
||||
toast.error('Failed to update watchlist');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ticker) return;
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [quoteRes, newsRes, filingsRes, contextRes] = await Promise.all([
|
||||
fetch(`/api/v1/finance/quote/${ticker}`),
|
||||
fetch(`/api/v1/finance/news/${ticker}`),
|
||||
fetch(`/api/v1/finance/sec-filings/${ticker}`),
|
||||
fetch(`/api/v1/finance/price-context/${ticker}`),
|
||||
]);
|
||||
if (quoteRes.ok) setQuote(await quoteRes.json());
|
||||
if (newsRes.ok) {
|
||||
const d = await newsRes.json();
|
||||
setNews(Array.isArray(d?.items) ? d.items : []);
|
||||
}
|
||||
if (filingsRes.ok) {
|
||||
const d = await filingsRes.json();
|
||||
setFilings(Array.isArray(d?.filings) ? d.filings : []);
|
||||
}
|
||||
if (contextRes.ok) {
|
||||
const d = await contextRes.json();
|
||||
if (d.summary || (d.news && d.news.length > 0)) {
|
||||
setPriceContext({ summary: d.summary ?? null, news: d.news ?? [] });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load ticker data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [ticker]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-4xl mx-auto w-full">
|
||||
<Link
|
||||
href="/finance"
|
||||
className="inline-flex items-center gap-1 text-sm text-black/60 dark:text-white/60 hover:text-black dark:hover:text-white mb-4"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Back to Finance
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
|
||||
<TrendingUp size={40} className="text-[#EA580C]" />
|
||||
<h1 className="text-3xl font-normal">{ticker}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleWatchlist}
|
||||
className={cn(
|
||||
'p-2 rounded-lg transition-colors',
|
||||
inWatchlist ? 'text-yellow-500 hover:text-yellow-600' : 'text-black/40 dark:text-white/40 hover:text-[#EA580C]'
|
||||
)}
|
||||
title={inWatchlist ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||
>
|
||||
<Star size={24} fill={inWatchlist ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="h-24 rounded-2xl bg-light-secondary dark:bg-dark-secondary animate-pulse" />
|
||||
<div className="h-32 rounded-2xl bg-light-secondary dark:bg-dark-secondary animate-pulse" />
|
||||
</div>
|
||||
) : quote ? (
|
||||
<>
|
||||
<div className="mt-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary p-6 shadow-sm border border-light-200/20 dark:border-dark-200/20">
|
||||
<p className="text-sm text-black/60 dark:text-white/60">{quote.name || ticker}</p>
|
||||
<p className="text-3xl font-medium mt-1">
|
||||
{quote.price > 0 ? `$${quote.price.toLocaleString(undefined, { minimumFractionDigits: 2 })}` : '—'}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-lg mt-1',
|
||||
quote.change > 0 ? 'text-green-600' : quote.change < 0 ? 'text-red-600' : 'text-black/60 dark:text-white/60'
|
||||
)}
|
||||
>
|
||||
{quote.change !== 0 ? `${quote.change > 0 ? '+' : ''}${quote.change.toFixed(2)}%` : '—'}
|
||||
</p>
|
||||
{quote.high != null && quote.low != null && quote.high > 0 && quote.low > 0 && (
|
||||
<p className="text-sm text-black/60 dark:text-white/60 mt-2">
|
||||
Day range: ${quote.low.toFixed(2)} – ${quote.high.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{priceContext && (priceContext.summary || (priceContext.news && priceContext.news.length > 0)) && (
|
||||
<div className="mt-4 rounded-2xl bg-light-secondary/80 dark:bg-dark-secondary/80 p-4 border border-light-200/30 dark:border-dark-200/30">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2 mb-2">
|
||||
<Sparkles size={16} className="text-[#EA580C]" />
|
||||
Price movement context
|
||||
</h3>
|
||||
{priceContext.summary ? (
|
||||
<p className="text-sm text-black/80 dark:text-white/80">{priceContext.summary}</p>
|
||||
) : priceContext.news && priceContext.news.length > 0 ? (
|
||||
<ul className="text-sm text-black/70 dark:text-white/70 space-y-1">
|
||||
{priceContext.news.slice(0, 3).map((h, i) => (
|
||||
<li key={i}>• {h}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{news.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-lg font-medium mb-3 flex items-center gap-2">
|
||||
<BarChart3 size={20} /> News
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{news.slice(0, 5).map((item, i) => (
|
||||
<li key={i}>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline block truncate"
|
||||
>
|
||||
{item.title ?? 'News'}
|
||||
</a>
|
||||
{item.publishedDate && (
|
||||
<span className="text-xs text-black/50 dark:text-white/50">{item.publishedDate}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filings.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-lg font-medium mb-3 flex items-center gap-2">
|
||||
<FileText size={20} /> SEC Filings
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{filings.slice(0, 10).map((f, i) => (
|
||||
<li key={i} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm">{f.type ?? 'Filing'}</span>
|
||||
<a
|
||||
href={f.finalLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline text-sm truncate max-w-[200px]"
|
||||
>
|
||||
{f.fillingDate ?? 'View'}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{news.length === 0 && filings.length === 0 && quote.price === 0 && (
|
||||
<p className="mt-6 text-black/60 dark:text-white/60 text-sm">
|
||||
Set FMP_API_KEY in finance-svc for full data.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-6 text-black/60 dark:text-white/60">Ticker not found.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
386
services/web-svc/src/app/finance/page.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
'use client';
|
||||
|
||||
import { TrendingUp, Coins, BarChart3, Star } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import DataFetchError from '@/components/DataFetchError';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
interface IndexItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
}
|
||||
|
||||
interface SummaryData {
|
||||
indices?: IndexItem[];
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
id: string;
|
||||
title: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface HeatmapSector {
|
||||
name?: string;
|
||||
symbol?: string;
|
||||
change?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface HeatmapData {
|
||||
sectors?: HeatmapSector[];
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
type FinanceTab = 'overview' | 'crypto' | 'movers' | 'watchlist';
|
||||
|
||||
interface MoverItem {
|
||||
symbol?: string;
|
||||
name?: string;
|
||||
price?: number;
|
||||
changesPercentage?: number;
|
||||
change?: number;
|
||||
}
|
||||
|
||||
const WATCHLIST_KEY = 'gooseek_finance_watchlist';
|
||||
|
||||
const Page = () => {
|
||||
const [tab, setTab] = useState<FinanceTab>('overview');
|
||||
const [summary, setSummary] = useState<SummaryData | null>(null);
|
||||
const [heatmap, setHeatmap] = useState<HeatmapData | null>(null);
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [gainers, setGainers] = useState<MoverItem[]>([]);
|
||||
const [losers, setLosers] = useState<MoverItem[]>([]);
|
||||
const [crypto, setCrypto] = useState<MoverItem[]>([]);
|
||||
const [watchlist, setWatchlist] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchSummary = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/v1/finance/summary', {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch');
|
||||
const data = await res.json();
|
||||
setSummary(data);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Error';
|
||||
const isTimeout = msg.includes('timeout') || (err as Error)?.name === 'AbortError';
|
||||
setError(isTimeout ? 'Request timed out. Try again.' : msg);
|
||||
toast.error(msg);
|
||||
setSummary({ indices: [] });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/finance/heatmap', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
||||
.then((r) => r.json())
|
||||
.then((d: HeatmapData) => setHeatmap(d))
|
||||
.catch(() => setHeatmap({ sectors: [] }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/collections?category=finance')
|
||||
.then((r) => r.json())
|
||||
.then((d) => setCollections(d.items ?? []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/finance/gainers', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setGainers(d.items ?? []))
|
||||
.catch(() => setGainers([]));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/finance/losers', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setLosers(d.items ?? []))
|
||||
.catch(() => setLosers([]));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/finance/crypto', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setCrypto(d.items ?? []))
|
||||
.catch(() => setCrypto([]));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(WATCHLIST_KEY);
|
||||
setWatchlist(raw ? JSON.parse(raw) : []);
|
||||
} catch {
|
||||
setWatchlist([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-4xl mx-auto w-full">
|
||||
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
|
||||
<TrendingUp size={40} className="text-[#EA580C]" />
|
||||
<h1 className="text-4xl font-normal">Finance</h1>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{(['overview', 'crypto', 'movers', 'watchlist'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-medium transition-colors',
|
||||
tab === t
|
||||
? 'bg-[#EA580C] text-white'
|
||||
: 'bg-light-secondary dark:bg-dark-secondary text-black/70 dark:text-white/70 hover:bg-light-200 dark:hover:bg-dark-200'
|
||||
)}
|
||||
>
|
||||
{t === 'overview' && <TrendingUp size={16} />}
|
||||
{t === 'crypto' && <Coins size={16} />}
|
||||
{t === 'movers' && <BarChart3 size={16} />}
|
||||
{t === 'watchlist' && <Star size={16} />}
|
||||
{t === 'overview' && 'Overview'}
|
||||
{t === 'crypto' && 'Crypto'}
|
||||
{t === 'movers' && 'Gainers & Losers'}
|
||||
{t === 'watchlist' && `Watchlist (${watchlist.length})`}
|
||||
</button>
|
||||
))}
|
||||
<Link
|
||||
href="/finance/predictions/polymarket"
|
||||
className="text-sm text-[#EA580C] hover:underline self-center ml-2"
|
||||
>
|
||||
Predictions →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<DataFetchError message={error} onRetry={fetchSummary} />
|
||||
) : loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 animate-pulse"
|
||||
>
|
||||
<div className="h-4 w-20 bg-light-200 dark:bg-dark-200 rounded mb-3" />
|
||||
<div className="h-6 w-24 bg-light-200 dark:bg-dark-200 rounded mb-2" />
|
||||
<div className="h-4 w-16 bg-light-200 dark:bg-dark-200 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tab === 'overview' && summary?.indices && summary.indices.length > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
|
||||
{summary.indices.map((idx) => (
|
||||
<Link
|
||||
key={idx.symbol}
|
||||
href={`/finance/${encodeURIComponent(idx.symbol)}`}
|
||||
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 shadow-sm border border-light-200/20 dark:border-dark-200/20 block hover:border-[#EA580C]/30 transition-colors"
|
||||
>
|
||||
<p className="text-sm text-black/60 dark:text-white/60">{idx.name}</p>
|
||||
<p className="text-xl font-medium mt-1">
|
||||
{idx.price > 0 ? idx.price.toLocaleString() : '—'}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm mt-1',
|
||||
idx.change > 0 ? 'text-green-600' : idx.change < 0 ? 'text-red-600' : 'text-black/60 dark:text-white/60'
|
||||
)}
|
||||
>
|
||||
{idx.change !== 0 ? `${idx.change > 0 ? '+' : ''}${idx.change.toFixed(2)}%` : '—'}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xl font-medium mb-4">S&P 500 Sector Heatmap</h2>
|
||||
{heatmap?.sectors && heatmap.sectors.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{heatmap.sectors.map((s, i) => {
|
||||
const ch = typeof s.change === 'number' ? s.change : 0;
|
||||
const label = (s.name ?? s.symbol ?? `Sector ${i + 1}`).toString();
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-xl p-3 text-center text-sm font-medium',
|
||||
ch > 0 && 'bg-green-500/20 text-green-700 dark:text-green-400',
|
||||
ch < 0 && 'bg-red-500/20 text-red-700 dark:text-red-400',
|
||||
ch === 0 && 'bg-light-secondary dark:bg-dark-secondary text-black/70 dark:text-white/70'
|
||||
)}
|
||||
>
|
||||
<span className="truncate block">{label}</span>
|
||||
<span className="text-xs mt-0.5">
|
||||
{ch > 0 ? '+' : ''}{ch.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-6 text-black/60 dark:text-white/60 text-sm">
|
||||
<p>Sector heatmap will appear when finance-svc has market data (FMP_API_KEY + cache-worker).</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xl font-medium mb-4">Popular Spaces for Finance Research</h2>
|
||||
{collections.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{collections.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/collections/${c.id}`}
|
||||
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 border border-light-200/20 dark:border-dark-200/20 hover:border-[#EA580C]/30 transition-colors block"
|
||||
>
|
||||
<h3 className="font-medium">{c.title}</h3>
|
||||
{c.description && (
|
||||
<p className="text-sm text-black/60 dark:text-white/60 mt-1 line-clamp-2">{c.description}</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 text-black/60 dark:text-white/60 text-sm">
|
||||
<p>Collections will appear when projects-svc is running.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'crypto' && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-xl font-medium mb-4">Popular Cryptocurrencies</h2>
|
||||
{crypto.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{crypto.map((c, i) => (
|
||||
<Link
|
||||
key={c.symbol ?? i}
|
||||
href={`/finance/${encodeURIComponent(c.symbol ?? '')}`}
|
||||
className="rounded-xl p-3 bg-light-secondary dark:bg-dark-secondary border border-light-200/20 dark:border-dark-200/20 hover:border-[#EA580C]/30 transition-colors"
|
||||
>
|
||||
<p className="text-sm font-medium truncate">{c.symbol ?? '—'}</p>
|
||||
<p className="text-lg mt-0.5">
|
||||
{c.price != null ? `$${Number(c.price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })}` : '—'}
|
||||
</p>
|
||||
<p className={cn(
|
||||
'text-xs mt-0.5',
|
||||
(c.changesPercentage ?? c.change ?? 0) > 0 ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{(c.changesPercentage ?? c.change ?? 0) !== 0
|
||||
? `${(c.changesPercentage ?? c.change ?? 0) > 0 ? '+' : ''}${(c.changesPercentage ?? c.change ?? 0).toFixed(2)}%`
|
||||
: '—'}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-black/60 dark:text-white/60 text-sm">Set FMP_API_KEY for crypto data.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'movers' && (
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3 text-green-600">Top Gainers</h3>
|
||||
{gainers.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{gainers.slice(0, 8).map((g, i) => (
|
||||
<Link
|
||||
key={g.symbol ?? i}
|
||||
href={`/finance/${encodeURIComponent(g.symbol ?? '')}`}
|
||||
className="flex justify-between items-center rounded-lg p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary"
|
||||
>
|
||||
<span className="font-medium truncate max-w-[120px]">{g.symbol ?? '—'}</span>
|
||||
<span className="text-green-600 text-sm">
|
||||
+{((g.changesPercentage ?? g.change) ?? 0).toFixed(2)}%
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-black/60 dark:text-white/60 text-sm">FMP_API_KEY required.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3 text-red-600">Top Losers</h3>
|
||||
{losers.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{losers.slice(0, 8).map((l, i) => (
|
||||
<Link
|
||||
key={l.symbol ?? i}
|
||||
href={`/finance/${encodeURIComponent(l.symbol ?? '')}`}
|
||||
className="flex justify-between items-center rounded-lg p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary"
|
||||
>
|
||||
<span className="font-medium truncate max-w-[120px]">{l.symbol ?? '—'}</span>
|
||||
<span className="text-red-600 text-sm">
|
||||
{((l.changesPercentage ?? l.change) ?? 0).toFixed(2)}%
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-black/60 dark:text-white/60 text-sm">FMP_API_KEY required.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'watchlist' && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-xl font-medium mb-4">My Watchlist</h2>
|
||||
<p className="text-sm text-black/60 dark:text-white/60 mb-4">
|
||||
Add tickers from search or ticker pages. Stored locally.
|
||||
</p>
|
||||
{watchlist.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{watchlist.map((ticker) => (
|
||||
<Link
|
||||
key={ticker}
|
||||
href={`/finance/${ticker}`}
|
||||
className="px-4 py-2 rounded-xl bg-light-secondary dark:bg-dark-secondary border border-light-200/20 dark:border-dark-200/20 hover:border-[#EA580C]/30"
|
||||
>
|
||||
{ticker}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-black/60 dark:text-white/60">No tickers in watchlist yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!summary?.indices?.length && tab === 'overview' && (
|
||||
<div className="mt-6 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary text-center text-black/60 dark:text-white/60">
|
||||
<p>Market data will appear here when finance-svc is running with FMP_API_KEY.</p>
|
||||
<p className="text-sm mt-2">Enable finance-svc in deploy.config.yaml</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
103
services/web-svc/src/app/finance/predictions/[id]/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
|
||||
interface Prediction {
|
||||
id: string;
|
||||
question: string;
|
||||
outcomes: { name: string; probability?: number }[];
|
||||
volume?: number;
|
||||
endDate?: string | null;
|
||||
_stub?: boolean;
|
||||
}
|
||||
|
||||
export default function PredictionPage() {
|
||||
const params = useParams();
|
||||
const id = params?.id as string;
|
||||
const [data, setData] = useState<Prediction | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/finance/predictions/${id}`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
}
|
||||
} catch {
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="flex flex-col pt-10 pb-28 px-4 max-w-4xl mx-auto">
|
||||
<p className="text-black/60 dark:text-white/60">Invalid prediction ID</p>
|
||||
<Link href="/finance" className="mt-4 text-[#7C3AED] hover:underline">
|
||||
← Back to Finance
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="flex flex-col pt-10 pb-28 px-4 max-w-4xl mx-auto">
|
||||
<div className="animate-pulse h-8 bg-light-200 dark:bg-dark-200 rounded w-48 mb-4" />
|
||||
<div className="animate-pulse h-4 bg-light-200 dark:bg-dark-200 rounded w-full max-w-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col pt-10 pb-28 px-4 max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
|
||||
<TrendingUp size={32} className="text-[#7C3AED]" />
|
||||
<h1 className="text-3xl font-normal">Prediction Market</h1>
|
||||
</div>
|
||||
|
||||
{data._stub ? (
|
||||
<div className="mt-6 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary">
|
||||
<p className="text-black/60 dark:text-white/60">
|
||||
Polymarket prediction markets integration coming soon. Use the search to
|
||||
explore prediction markets.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-xl font-medium">{data.question}</h2>
|
||||
{data.outcomes?.length > 0 && (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{data.outcomes.map((o, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex justify-between py-2 px-4 rounded-lg bg-light-secondary dark:bg-dark-secondary"
|
||||
>
|
||||
<span>{o.name}</span>
|
||||
{o.probability != null && (
|
||||
<span>{(o.probability * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<Link href="/finance" className="text-sm text-[#7C3AED] hover:underline">
|
||||
← Back to Finance
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
services/web-svc/src/app/globals.css
Normal file
@@ -0,0 +1,98 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
.overflow-hidden-scrollable {
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.overflow-hidden-scrollable::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #e8edf1 transparent; /* light-200 */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #e8edf1; /* light-200 */
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #d0d7de; /* light-300 */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
* {
|
||||
scrollbar-color: #21262d transparent; /* dark-200 */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #21262d; /* dark-200 */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #30363d; /* dark-300 */
|
||||
}
|
||||
}
|
||||
|
||||
:root.dark *,
|
||||
html.dark *,
|
||||
body.dark * {
|
||||
scrollbar-color: #21262d transparent; /* dark-200 */
|
||||
}
|
||||
|
||||
:root.dark *::-webkit-scrollbar-thumb,
|
||||
html.dark *::-webkit-scrollbar-thumb,
|
||||
body.dark *::-webkit-scrollbar-thumb {
|
||||
background: #21262d; /* dark-200 */
|
||||
}
|
||||
|
||||
:root.dark *::-webkit-scrollbar-thumb:hover,
|
||||
html.dark *::-webkit-scrollbar-thumb:hover,
|
||||
body.dark *::-webkit-scrollbar-thumb:hover {
|
||||
background: #30363d; /* dark-300 */
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.text-fade {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
mask-image: linear-gradient(to right, black 75%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, black 75%, transparent 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
select,
|
||||
textarea,
|
||||
input {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
4
services/web-svc/src/app/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#EA580C"/>
|
||||
<text x="16" y="22" font-family="Arial" font-size="18" font-weight="bold" fill="white" text-anchor="middle">G</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 242 B |
96
services/web-svc/src/app/layout.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Roboto } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import { Toaster } from 'sonner';
|
||||
import ThemeProvider from '@/components/theme/Provider';
|
||||
import { LocalizationProvider } from '@/lib/localization/context';
|
||||
import { ChatProvider } from '@/lib/hooks/useChat';
|
||||
import { ClientOnly } from '@/components/ClientOnly';
|
||||
import GuestWarningBanner from '@/components/GuestWarningBanner';
|
||||
import GuestMigration from '@/components/GuestMigration';
|
||||
|
||||
const roboto = Roboto({
|
||||
weight: ['300', '400', '500', '700'],
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
fallback: ['Arial', 'sans-serif'],
|
||||
});
|
||||
|
||||
const APP_NAME = 'GooSeek';
|
||||
const APP_DEFAULT_TITLE = 'GooSeek - Chat with the internet';
|
||||
const APP_DESCRIPTION =
|
||||
'GooSeek is an AI powered chatbot that is connected to the internet.';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
applicationName: APP_NAME,
|
||||
title: {
|
||||
default: APP_DEFAULT_TITLE,
|
||||
template: `%s - ${APP_NAME}`,
|
||||
},
|
||||
description: APP_DESCRIPTION,
|
||||
manifest: '/manifest.webmanifest',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: APP_DEFAULT_TITLE,
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: APP_NAME,
|
||||
title: { default: APP_DEFAULT_TITLE, template: `%s - ${APP_NAME}` },
|
||||
description: APP_DESCRIPTION,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: { default: APP_DEFAULT_TITLE, template: `%s - ${APP_NAME}` },
|
||||
description: APP_DESCRIPTION,
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#0a0a0a',
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html className="h-full" lang="ru" suppressHydrationWarning>
|
||||
<body className={cn('h-full antialiased', roboto.className)}>
|
||||
<ThemeProvider>
|
||||
<LocalizationProvider>
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<div className="min-h-screen bg-light-primary dark:bg-dark-primary" />
|
||||
}
|
||||
>
|
||||
<ChatProvider>
|
||||
<Sidebar>{children}</Sidebar>
|
||||
<GuestWarningBanner />
|
||||
<GuestMigration />
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
unstyled: true,
|
||||
classNames: {
|
||||
toast:
|
||||
'bg-light-secondary dark:bg-dark-secondary dark:text-white/70 text-black/70 rounded-lg p-4 flex flex-row items-center space-x-2',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ChatProvider>
|
||||
</ClientOnly>
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
12
services/web-svc/src/app/library/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Metadata } from 'next';
|
||||
import React from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'History - GooSeek',
|
||||
};
|
||||
|
||||
const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
309
services/web-svc/src/app/library/page.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
'use client';
|
||||
|
||||
import DeleteChat from '@/components/DeleteChat';
|
||||
import { formatTimeDifference } from '@/lib/utils';
|
||||
import {
|
||||
ClockIcon,
|
||||
FileText,
|
||||
Globe2Icon,
|
||||
MessagesSquare,
|
||||
Search,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
sources: string[];
|
||||
files: { fileId: string; name: string }[];
|
||||
}
|
||||
|
||||
const SOURCE_KEYS = ['web', 'academic', 'discussions'] as const;
|
||||
type SourceKey = (typeof SOURCE_KEYS)[number];
|
||||
|
||||
type FilesFilter = 'all' | 'with' | 'without';
|
||||
|
||||
const Page = () => {
|
||||
const { t } = useTranslation();
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceKey | 'all'>('all');
|
||||
const [filesFilter, setFilesFilter] = useState<FilesFilter>('all');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChats = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const token =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
|
||||
: null;
|
||||
|
||||
try {
|
||||
if (token) {
|
||||
const res = await fetch('/api/v1/library/threads', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setChats(data.chats ?? []);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { getGuestChats } = await import('@/lib/guest-storage');
|
||||
const guestChats = getGuestChats();
|
||||
setChats(
|
||||
guestChats.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
createdAt: c.createdAt,
|
||||
sources: c.sources,
|
||||
files: c.files,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setChats([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchChats();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMigrated = () => {
|
||||
const token =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
|
||||
: null;
|
||||
if (token) {
|
||||
fetch('/api/v1/library/threads', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => setChats(data.chats ?? []))
|
||||
.catch(() => setChats([]));
|
||||
}
|
||||
};
|
||||
window.addEventListener('gooseek:guest-migrated', onMigrated);
|
||||
return () => window.removeEventListener('gooseek:guest-migrated', onMigrated);
|
||||
}, []);
|
||||
|
||||
const filteredChats = useMemo(() => {
|
||||
return chats.filter((chat) => {
|
||||
const matchesSearch =
|
||||
!searchQuery.trim() ||
|
||||
chat.title.toLowerCase().includes(searchQuery.trim().toLowerCase());
|
||||
|
||||
const matchesSource =
|
||||
sourceFilter === 'all' ||
|
||||
chat.sources.some((s) => s === sourceFilter);
|
||||
|
||||
const matchesFiles =
|
||||
filesFilter === 'all' ||
|
||||
(filesFilter === 'with' && chat.files.length > 0) ||
|
||||
(filesFilter === 'without' && chat.files.length === 0);
|
||||
|
||||
return matchesSearch && matchesSource && matchesFiles;
|
||||
});
|
||||
}, [chats, searchQuery, sourceFilter, filesFilter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col pt-10 border-b border-light-200/20 dark:border-dark-200/20 pb-6 px-2">
|
||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<MessagesSquare size={45} className="mb-2.5" />
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-5xl font-normal p-2 pb-0">
|
||||
{t('nav.messageHistory')}
|
||||
</h1>
|
||||
<div className="px-2 text-sm text-black/60 dark:text-white/60 text-center lg:text-left">
|
||||
Past chats, sources, and uploads.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center lg:justify-end gap-2 text-xs text-black/60 dark:text-white/60">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-black/20 dark:border-white/20 px-2 py-0.5">
|
||||
<MessagesSquare size={14} />
|
||||
{loading && chats.length === 0
|
||||
? '—'
|
||||
: `${chats.length} ${chats.length === 1 ? 'chat' : 'chats'}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(chats.length > 0 || loading) && (
|
||||
<div className="sticky top-0 z-10 flex flex-row flex-wrap items-center gap-2 pt-4 pb-2 px-2 bg-light-primary dark:bg-dark-primary border-b border-light-200/20 dark:border-dark-200/20">
|
||||
<div className="relative flex-1 min-w-[140px]">
|
||||
<Search
|
||||
size={18}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('app.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-2.5 text-sm bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 rounded-xl placeholder:text-black/40 dark:placeholder:text-white/40 text-black dark:text-white focus:outline-none focus:border-light-300 dark:focus:border-dark-300 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
<Filter size={16} className="text-black/50 dark:text-white/50" />
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) =>
|
||||
setSourceFilter(e.target.value as SourceKey | 'all')
|
||||
}
|
||||
className="text-xs py-1.5 px-3 rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-[#EA580C]/30"
|
||||
>
|
||||
<option value="all">{t('library.filterSourceAll')}</option>
|
||||
<option value="web">{t('library.filterSourceWeb')}</option>
|
||||
<option value="academic">{t('library.filterSourceAcademic')}</option>
|
||||
<option value="discussions">{t('library.filterSourceSocial')}</option>
|
||||
</select>
|
||||
<select
|
||||
value={filesFilter}
|
||||
onChange={(e) => setFilesFilter(e.target.value as FilesFilter)}
|
||||
className="text-xs py-1.5 px-3 rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-[#EA580C]/30"
|
||||
>
|
||||
<option value="all">{t('library.filterFilesAll')}</option>
|
||||
<option value="with">{t('library.filterFilesWith')}</option>
|
||||
<option value="without">{t('library.filterFilesWithout')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && chats.length === 0 ? (
|
||||
<div className="pt-6 pb-28 px-2">
|
||||
<div className="rounded-2xl border border-light-200 dark:border-dark-200 overflow-hidden bg-light-primary dark:bg-dark-primary">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-2 p-4 border-b border-light-200 dark:border-dark-200 last:border-b-0"
|
||||
>
|
||||
<div className="h-5 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div className="h-4 w-16 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
<div className="h-4 w-24 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] px-2 text-center">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-2xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary">
|
||||
<MessagesSquare className="text-black/70 dark:text-white/70" />
|
||||
</div>
|
||||
<p className="mt-2 text-black/70 dark:text-white/70 text-sm">
|
||||
No chats found.
|
||||
</p>
|
||||
<p className="mt-1 text-black/70 dark:text-white/70 text-sm">
|
||||
<Link href="/" className="text-[#EA580C]">
|
||||
Start a new chat
|
||||
</Link>{' '}
|
||||
to see it listed here.
|
||||
</p>
|
||||
</div>
|
||||
) : filteredChats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-2 text-center">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
{t('library.noResults')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSourceFilter('all');
|
||||
setFilesFilter('all');
|
||||
}}
|
||||
className="mt-2 text-sm text-[#EA580C] hover:underline"
|
||||
>
|
||||
{t('library.clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-6 pb-28 px-2">
|
||||
<div className="rounded-2xl border border-light-200 dark:border-dark-200 overflow-hidden bg-light-primary dark:bg-dark-primary">
|
||||
{filteredChats.map((chat, index) => {
|
||||
const sourcesLabel =
|
||||
chat.sources.length === 0
|
||||
? null
|
||||
: chat.sources.length <= 2
|
||||
? chat.sources
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join(', ')
|
||||
: `${chat.sources
|
||||
.slice(0, 2)
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join(', ')} + ${chat.sources.length - 2}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={
|
||||
'group flex flex-col gap-2 p-4 hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200 ' +
|
||||
(index !== chats.length - 1
|
||||
? 'border-b border-light-200 dark:border-dark-200'
|
||||
: '')
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Link
|
||||
href={`/c/${chat.id}`}
|
||||
className="flex-1 text-black dark:text-white text-base lg:text-lg font-medium leading-snug line-clamp-2 group-hover:text-[#EA580C] transition duration-200"
|
||||
title={chat.title}
|
||||
>
|
||||
{chat.title}
|
||||
</Link>
|
||||
<div className="pt-0.5 shrink-0">
|
||||
<DeleteChat
|
||||
chatId={chat.id}
|
||||
chats={chats}
|
||||
setChats={setChats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-black/70 dark:text-white/70">
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
<ClockIcon size={14} />
|
||||
{formatTimeDifference(new Date(), chat.createdAt)} Ago
|
||||
</span>
|
||||
|
||||
{sourcesLabel && (
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-black/20 dark:border-white/20 rounded-full px-2 py-0.5">
|
||||
<Globe2Icon size={14} />
|
||||
{sourcesLabel}
|
||||
</span>
|
||||
)}
|
||||
{chat.files.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-black/20 dark:border-white/20 rounded-full px-2 py-0.5">
|
||||
<FileText size={14} />
|
||||
{chat.files.length}{' '}
|
||||
{chat.files.length === 1 ? 'file' : 'files'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
54
services/web-svc/src/app/manifest.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'GooSeek - Chat with the internet',
|
||||
short_name: 'GooSeek',
|
||||
description:
|
||||
'GooSeek is an AI powered chatbot that is connected to the internet.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#0a0a0a',
|
||||
theme_color: '#0a0a0a',
|
||||
screenshots: [
|
||||
{
|
||||
src: '/screenshots/p1.png',
|
||||
form_factor: 'wide',
|
||||
sizes: '2560x1600',
|
||||
},
|
||||
{
|
||||
src: '/screenshots/p2.png',
|
||||
form_factor: 'wide',
|
||||
sizes: '2560x1600',
|
||||
},
|
||||
{
|
||||
src: '/screenshots/p1_small.png',
|
||||
form_factor: 'narrow',
|
||||
sizes: '828x1792',
|
||||
},
|
||||
{
|
||||
src: '/screenshots/p2_small.png',
|
||||
form_factor: 'narrow',
|
||||
sizes: '828x1792',
|
||||
},
|
||||
],
|
||||
icons: [
|
||||
{
|
||||
src: '/icon-50.png',
|
||||
sizes: '50x50',
|
||||
type: 'image/png' as const,
|
||||
},
|
||||
{
|
||||
src: '/icon-100.png',
|
||||
sizes: '100x100',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/icon.png',
|
||||
sizes: '440x440',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
29
services/web-svc/src/app/offline/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Offline fallback страница для PWA
|
||||
* docs/architecture: 05-gaps-and-best-practices.md §8, 06-roadmap-specification §1.7
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export default function OfflinePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-light-primary dark:bg-dark-primary flex flex-col items-center justify-center p-6">
|
||||
<div className="text-center max-w-md">
|
||||
<h1 className="text-2xl font-semibold text-black dark:text-white mb-2">
|
||||
You're offline
|
||||
</h1>
|
||||
<p className="text-black/70 dark:text-white/70 mb-6">
|
||||
Check your connection and try again. Some features require an internet connection.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-light-accent dark:bg-dark-accent text-white px-4 py-2 font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Retry
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
services/web-svc/src/app/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import ChatWindow from '@/components/ChatWindow';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chat - GooSeek',
|
||||
description: 'Chat with the internet, chat with GooSeek.',
|
||||
};
|
||||
|
||||
const Home = () => {
|
||||
return <ChatWindow />;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
726
services/web-svc/src/app/profile/page.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
User,
|
||||
CreditCard,
|
||||
Sliders,
|
||||
ToggleRight,
|
||||
Link2,
|
||||
ChevronLeft,
|
||||
Check,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
import { cn } from '@/lib/utils';
|
||||
import SettingsButton from '@/components/Settings/SettingsButton';
|
||||
import { authClient, clearStoredAuthToken } from '@/lib/auth-client';
|
||||
|
||||
function SignOutButton() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const handleSignOut = async () => {
|
||||
setLoading(true);
|
||||
clearStoredAuthToken();
|
||||
await authClient.signOut();
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
setLoading(false);
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg border border-light-200 dark:border-dark-200 px-3 py-2 text-sm text-black/70 dark:text-white/70 hover:bg-light-200 dark:hover:bg-dark-200 disabled:opacity-50"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Выйти
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface BillingPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
priceMonthly: number;
|
||||
priceYearly: number;
|
||||
currency: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
planId: string;
|
||||
status: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface ConnectorItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface MemoryItem {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
function PersonalizeSection({
|
||||
token,
|
||||
t,
|
||||
}: {
|
||||
token: string | null;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const [memories, setMemories] = useState<MemoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const fetchMemories = useCallback(() => {
|
||||
if (!token) {
|
||||
setMemories([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
fetch('/api/v1/memory', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => setMemories(d.items ?? []))
|
||||
.catch(() => setMemories([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchMemories();
|
||||
}, [fetchMemories]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!token) return;
|
||||
const res = await fetch(`/api/v1/memory/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) fetchMemories();
|
||||
};
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token || !newKey.trim() || !newValue.trim()) return;
|
||||
setAdding(true);
|
||||
const res = await fetch('/api/v1/memory', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ key: newKey.trim(), value: newValue.trim() }),
|
||||
});
|
||||
setAdding(false);
|
||||
if (res.ok) {
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
fetchMemories();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium dark:text-white">{t('profile.personalize')}</h2>
|
||||
<p className="text-sm text-black/60 dark:text-white/60">
|
||||
{t('profile.personalizeDesc')}
|
||||
</p>
|
||||
{!token ? (
|
||||
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6 space-y-3">
|
||||
<p className="text-black/60 dark:text-white/60">{t('profile.signInToView')}</p>
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="inline-flex items-center rounded-lg bg-[#EA580C] px-4 py-2 text-sm font-medium text-white hover:bg-[#EA580C]/90"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6 animate-pulse">
|
||||
<div className="h-4 w-48 bg-light-200 dark:bg-dark-200 rounded" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6">
|
||||
<h3 className="text-sm font-medium text-black/80 dark:text-white/80 mb-2">
|
||||
AI Memory (Pro)
|
||||
</h3>
|
||||
<form onSubmit={handleAdd} className="mb-4 flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Key (e.g. favorite_tech)"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
className="rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-1.5 text-sm min-w-[120px]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Value"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
className="rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-1.5 text-sm min-w-[160px] flex-1"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={adding || !newKey.trim() || !newValue.trim()}
|
||||
className="rounded-lg bg-[#EA580C] px-3 py-1.5 text-sm text-white hover:bg-[#EA580C]/90 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
{memories.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{memories.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex items-start justify-between gap-2 rounded-lg p-2 bg-light-primary dark:bg-dark-primary border border-light-200/50 dark:border-dark-200/50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{m.key}</p>
|
||||
<p className="text-xs text-black/60 dark:text-white/60 truncate">{m.value}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(m.id)}
|
||||
className="px-2 py-1 text-xs text-red-600 hover:bg-red-500/10 rounded"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-black/60 dark:text-white/60 text-sm">
|
||||
No memory items yet. Add above or use Pro search (balanced/quality) with an account.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectorsSection({
|
||||
t,
|
||||
}: {
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const [connectors, setConnectors] = useState<{
|
||||
available: ConnectorItem[];
|
||||
connected: ConnectorItem[];
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/connectors')
|
||||
.then((r) => r.json())
|
||||
.then((d) => setConnectors({ available: d.available ?? [], connected: d.connected ?? [] }))
|
||||
.catch(() => setConnectors({ available: [], connected: [] }))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const available = connectors?.available ?? [];
|
||||
const connected = connectors?.connected ?? [];
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium dark:text-white">{t('profile.connectors')}</h2>
|
||||
<p className="text-sm text-black/60 dark:text-white/60">
|
||||
{t('profile.connectorsDesc')}
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6 animate-pulse">
|
||||
<div className="h-4 w-48 bg-light-200 dark:bg-dark-200 rounded" />
|
||||
</div>
|
||||
) : available.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{available.map((c) => {
|
||||
const isConnected = connected.some((x) => x.id === c.id);
|
||||
const isComingSoon = c.status === 'coming_soon';
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center justify-between rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-4"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium dark:text-white">{c.name}</h3>
|
||||
<p className="text-sm text-black/60 dark:text-white/60 mt-0.5">{c.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isComingSoon}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-sm font-medium',
|
||||
isComingSoon &&
|
||||
'bg-light-200 dark:bg-dark-200 text-black/50 dark:text-white/50 cursor-not-allowed',
|
||||
isConnected && !isComingSoon &&
|
||||
'bg-green-500/20 text-green-700 dark:text-green-400',
|
||||
!isConnected && !isComingSoon &&
|
||||
'bg-[#EA580C] text-white hover:bg-[#EA580C]/90'
|
||||
)}
|
||||
>
|
||||
{isComingSoon ? t('profile.comingSoon') : isConnected ? t('profile.connected') ?? 'Connected' : t('profile.connect') ?? 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6">
|
||||
<p className="text-black/60 dark:text-white/60">{t('profile.comingSoon')}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileData {
|
||||
userId: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
timezone: string | null;
|
||||
locale: string | null;
|
||||
profileData: Record<string, unknown>;
|
||||
preferences: Record<string, unknown>;
|
||||
personalization: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function AccountProfileContent({
|
||||
session,
|
||||
token,
|
||||
SignOutButton,
|
||||
}: {
|
||||
session: { user?: { id: string; name?: string; email?: string; image?: string } };
|
||||
token: string | null;
|
||||
SignOutButton: React.ComponentType;
|
||||
}) {
|
||||
const [profile, setProfile] = useState<ProfileData | null>(null);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
fetch('/api/v1/profile', { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((p) => {
|
||||
if (p) {
|
||||
setProfile(p);
|
||||
setDisplayName(p.displayName ?? session.user?.name ?? '');
|
||||
} else {
|
||||
setDisplayName(session.user?.name ?? '');
|
||||
}
|
||||
})
|
||||
.catch(() => setDisplayName(session.user?.name ?? ''));
|
||||
}, [token, session.user?.name]);
|
||||
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/profile', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ displayName: displayName.trim() || null }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setProfile(data);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const avatarSrc = profile?.avatarUrl ?? session.user?.image ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
|
||||
<div className="flex items-center gap-4">
|
||||
{avatarSrc ? (
|
||||
<img src={avatarSrc} alt="" className="h-16 w-16 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-light-200 dark:bg-dark-200">
|
||||
<User size={28} className="text-black/50 dark:text-white/50" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-black dark:text-white">
|
||||
{profile?.displayName ?? session.user?.name ?? '—'}
|
||||
</p>
|
||||
<p className="text-sm text-black/60 dark:text-white/60">
|
||||
{session.user?.email ?? '—'}
|
||||
</p>
|
||||
<p className="text-xs text-black/50 dark:text-white/50 mt-1">
|
||||
ID: {session.user?.id}
|
||||
</p>
|
||||
</div>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
{token && (
|
||||
<form onSubmit={handleSaveProfile} className="flex flex-col gap-3 sm:border-l sm:border-light-200 sm:dark:border-dark-200 sm:pl-6">
|
||||
<label className="text-sm font-medium text-black dark:text-white">
|
||||
Отображаемое имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={session.user?.name ?? ''}
|
||||
className="rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-sm text-black dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="self-start rounded-lg bg-[#EA580C] px-4 py-2 text-sm font-medium text-white hover:bg-[#EA580C]/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Сохранение…' : 'Сохранить'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: 'account', labelKey: 'profile.account', icon: User },
|
||||
{ key: 'preferences', labelKey: 'profile.preferences', icon: Sliders },
|
||||
{ key: 'personalize', labelKey: 'profile.personalize', icon: ToggleRight },
|
||||
{ key: 'billing', labelKey: 'profile.billing', icon: CreditCard },
|
||||
{ key: 'connectors', labelKey: 'profile.connectors', icon: Link2 },
|
||||
] as const;
|
||||
|
||||
export default function ProfilePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const tab = (searchParams.get('tab') ?? 'account') as (typeof SECTIONS)[number]['key'];
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [session, setSession] = useState<{ user?: { id: string; name?: string; email?: string; image?: string } } | null>(null);
|
||||
const [plans, setPlans] = useState<BillingPlan[]>([]);
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null);
|
||||
const [payments, setPayments] = useState<{ id: string; planId: string; amount: number; currency: string; status: string; createdAt: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checkoutLoading, setCheckoutLoading] = useState<string | null>(null);
|
||||
|
||||
const token =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/get-session');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSession(data?.data ?? null);
|
||||
}
|
||||
} catch {
|
||||
setSession(null);
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/billing/plans');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPlans(Array.isArray(data) ? data : data.plans ?? []);
|
||||
}
|
||||
} catch {
|
||||
setPlans([]);
|
||||
}
|
||||
};
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const fetchSubscription = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/billing/subscription', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSubscription(data);
|
||||
}
|
||||
} catch {
|
||||
setSubscription(null);
|
||||
}
|
||||
};
|
||||
fetchSubscription();
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const fetchPayments = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/billing/payments', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPayments(data.payments ?? []);
|
||||
}
|
||||
} catch {
|
||||
setPayments([]);
|
||||
}
|
||||
};
|
||||
fetchPayments();
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [session, plans]);
|
||||
|
||||
const handleCheckout = async (planId: string, period: 'monthly' | 'yearly') => {
|
||||
if (!token) return;
|
||||
setCheckoutLoading(planId);
|
||||
try {
|
||||
const res = await fetch('/api/v1/billing/checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ planId, period }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.redirectUrl) {
|
||||
window.location.href = data.redirectUrl;
|
||||
} else if (res.ok) {
|
||||
setSubscription({ planId, status: 'pending', period });
|
||||
}
|
||||
} catch {
|
||||
// error handling
|
||||
} finally {
|
||||
setCheckoutLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (cents: number, currency: string) => {
|
||||
const val = cents / 100;
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: currency === 'RUB' ? 'RUB' : 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(val);
|
||||
};
|
||||
|
||||
const currentPlanId = subscription?.planId ?? 'free';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-light-primary dark:bg-dark-primary">
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="mb-6 inline-flex items-center gap-2 text-sm text-black/60 hover:text-black dark:text-white/60 hover:dark:text-white"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
{t('nav.home')}
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-8 lg:flex-row">
|
||||
<nav className="flex shrink-0 flex-col gap-1 lg:w-56">
|
||||
{SECTIONS.map((s) => (
|
||||
<Link
|
||||
key={s.key}
|
||||
href={`/profile?tab=${s.key}`}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors',
|
||||
tab === s.key
|
||||
? 'bg-light-200 text-black dark:bg-dark-200 dark:text-white'
|
||||
: 'text-black/70 hover:bg-light-200/50 dark:text-white/70 hover:dark:bg-dark-200/50'
|
||||
)}
|
||||
>
|
||||
<s.icon size={18} />
|
||||
{t(s.labelKey)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-4 border-t border-light-200 dark:border-dark-200 pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsButton />
|
||||
<span className="text-sm text-black/60 dark:text-white/60">
|
||||
{t('profile.appSettings')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="flex-1">
|
||||
{tab === 'account' && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium dark:text-white">{t('profile.account')}</h2>
|
||||
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6">
|
||||
{session?.user ? (
|
||||
<AccountProfileContent
|
||||
session={session}
|
||||
token={token}
|
||||
SignOutButton={SignOutButton}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-black/60 dark:text-white/60">
|
||||
{t('profile.signInToView')}
|
||||
</p>
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="inline-flex items-center rounded-lg bg-[#EA580C] px-4 py-2 text-sm font-medium text-white hover:bg-[#EA580C]/90"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'preferences' && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium dark:text-white">{t('profile.preferences')}</h2>
|
||||
<p className="text-sm text-black/60 dark:text-white/60">
|
||||
{t('profile.preferencesDesc')}
|
||||
</p>
|
||||
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6">
|
||||
<SettingsButton />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'personalize' && (
|
||||
<PersonalizeSection token={token} t={t} />
|
||||
)}
|
||||
|
||||
{tab === 'billing' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-medium dark:text-white">{t('profile.billing')}</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-32 animate-pulse rounded-xl bg-light-200 dark:bg-dark-200" />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{plans.map((plan) => {
|
||||
const isCurrent = currentPlanId === plan.id;
|
||||
const isFree = plan.id === 'free';
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
'rounded-xl border p-6 transition-colors',
|
||||
isCurrent
|
||||
? 'border-black dark:border-white bg-light-200/50 dark:bg-dark-200/50'
|
||||
: 'border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-black dark:text-white">
|
||||
{plan.name}
|
||||
</h3>
|
||||
{isCurrent && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-green-500/20 px-2 py-0.5 text-xs text-green-700 dark:text-green-400">
|
||||
<Check size={12} />
|
||||
{t('profile.current')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-2xl font-semibold text-black dark:text-white">
|
||||
{formatPrice(plan.priceMonthly, plan.currency)}
|
||||
</span>
|
||||
<span className="text-sm text-black/60 dark:text-white/60">
|
||||
/{t('profile.month')}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-4 space-y-1.5 text-sm text-black/80 dark:text-white/80">
|
||||
{plan.features.slice(0, 4).map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<Check size={14} className="mt-0.5 shrink-0 text-green-600" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!isFree && !isCurrent && token && (
|
||||
<button
|
||||
onClick={() => handleCheckout(plan.id, 'monthly')}
|
||||
disabled={!!checkoutLoading}
|
||||
className="mt-4 w-full rounded-lg bg-black px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50 dark:bg-white dark:text-black"
|
||||
>
|
||||
{checkoutLoading === plan.id
|
||||
? t('common.loading')
|
||||
: t('profile.upgrade')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{token && payments.length > 0 && (
|
||||
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6">
|
||||
<h3 className="mb-3 font-medium text-black dark:text-white">
|
||||
{t('profile.paymentHistory')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{payments.slice(0, 10).map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-light-200/50 dark:bg-dark-200/50 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-black dark:text-white">
|
||||
{p.planId} · {formatPrice(p.amount, p.currency)}
|
||||
</span>
|
||||
<span className="text-black/50 dark:text-white/50">
|
||||
{new Date(p.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5 text-xs capitalize',
|
||||
p.status === 'succeeded'
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-400'
|
||||
: p.status === 'pending'
|
||||
? 'bg-amber-500/20 text-amber-700 dark:text-amber-400'
|
||||
: 'bg-red-500/20 text-red-700 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'connectors' && (
|
||||
<ConnectorsSection t={t} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
services/web-svc/src/app/sign-in/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function SignInPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const { data, error: err } = await authClient.signIn.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
callbackURL: '/profile',
|
||||
},
|
||||
{
|
||||
onSuccess: (ctx) => {
|
||||
const token = (ctx as { response?: { headers?: { get?: (n: string) => string } } })?.response?.headers?.get?.('set-auth-token');
|
||||
if (token && typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
setLoading(false);
|
||||
if (err) {
|
||||
setError(err.message ?? 'Неверный email или пароль');
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
router.push('/profile');
|
||||
router.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-light-primary dark:bg-dark-primary px-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-black dark:text-white">
|
||||
Вход в GooSeek
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-black/60 dark:text-white/60">
|
||||
Войдите в аккаунт для доступа к профилю и памяти
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-black dark:text-white mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-[#EA580C]"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-black dark:text-white mb-1"
|
||||
>
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-[#EA580C]"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-[#EA580C] px-4 py-2.5 text-white font-medium hover:bg-[#EA580C]/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-black/60 dark:text-white/60">
|
||||
Нет аккаунта?{' '}
|
||||
<Link
|
||||
href="/sign-up"
|
||||
className="text-[#EA580C] hover:underline font-medium"
|
||||
>
|
||||
Зарегистрироваться
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-black/60 dark:text-white/60 hover:underline"
|
||||
>
|
||||
← На главную
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
services/web-svc/src/app/sign-up/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function SignUpPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const { data, error: err } = await authClient.signUp.email(
|
||||
{
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
callbackURL: '/profile',
|
||||
},
|
||||
{
|
||||
onSuccess: (ctx) => {
|
||||
const token = (ctx as { response?: { headers?: { get?: (n: string) => string } } })?.response?.headers?.get?.('set-auth-token');
|
||||
if (token && typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
setLoading(false);
|
||||
if (err) {
|
||||
setError(err.message ?? 'Ошибка регистрации');
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
router.push('/profile');
|
||||
router.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-light-primary dark:bg-dark-primary px-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-black dark:text-white">
|
||||
Регистрация в GooSeek
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-black/60 dark:text-white/60">
|
||||
Создайте аккаунт для сохранения истории и персонализации
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-black dark:text-white mb-1"
|
||||
>
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-[#EA580C]"
|
||||
placeholder="Иван"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-black dark:text-white mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-[#EA580C]"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-black dark:text-white mb-1"
|
||||
>
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-[#EA580C]"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-black/50 dark:text-white/50">
|
||||
Минимум 8 символов
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-[#EA580C] px-4 py-2.5 text-white font-medium hover:bg-[#EA580C]/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-black/60 dark:text-white/60">
|
||||
Уже есть аккаунт?{' '}
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="text-[#EA580C] hover:underline font-medium"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-black/60 dark:text-white/60 hover:underline"
|
||||
>
|
||||
← На главную
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
services/web-svc/src/app/spaces/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Collection {
|
||||
id: string;
|
||||
title: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const Page = () => {
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCollections = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/collections');
|
||||
if (!res.ok) throw new Error('Failed to fetch');
|
||||
const data = await res.json();
|
||||
setCollections(data.items ?? []);
|
||||
} catch {
|
||||
toast.error('Failed to load collections');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCollections();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-4xl mx-auto w-full">
|
||||
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
|
||||
<FolderOpen size={40} className="text-[#7C3AED]" />
|
||||
<h1 className="text-4xl font-normal">Spaces</h1>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-4">
|
||||
<Link
|
||||
href="/spaces/templates"
|
||||
className="text-sm text-[#7C3AED] hover:underline"
|
||||
>
|
||||
Space Templates →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-black/60 dark:text-white/60">
|
||||
Popular collections for research and analysis.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 animate-pulse h-24"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : collections.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6">
|
||||
{collections.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/collections/${c.id}`}
|
||||
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-5 shadow-sm border border-light-200/20 dark:border-dark-200/20 block hover:border-[#7C3AED]/30 transition-colors"
|
||||
>
|
||||
<h2 className="font-medium text-lg">{c.title}</h2>
|
||||
{c.description && (
|
||||
<p className="text-sm text-black/60 dark:text-white/60 mt-1">{c.description}</p>
|
||||
)}
|
||||
{c.category && (
|
||||
<span className="inline-block mt-2 text-xs px-2 py-0.5 rounded bg-light-200 dark:bg-dark-200">
|
||||
{c.category}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary text-center text-black/60 dark:text-white/60">
|
||||
<p>No collections available. Enable projects-svc in deploy.config.yaml</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
93
services/web-svc/src/app/spaces/templates/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { LayoutTemplate } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
title: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const Page = () => {
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/templates');
|
||||
if (!res.ok) throw new Error('Failed to fetch');
|
||||
const data = await res.json();
|
||||
setTemplates(data.items ?? []);
|
||||
} catch {
|
||||
toast.error('Failed to load templates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-4xl mx-auto w-full">
|
||||
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
|
||||
<LayoutTemplate size={40} className="text-[#7C3AED]" />
|
||||
<h1 className="text-4xl font-normal">Space Templates</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-black/60 dark:text-white/60">
|
||||
Ready-made Spaces for finance, marketing, product, and travel.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 animate-pulse h-24"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : templates.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6">
|
||||
{templates.map((t) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
href={`/?template=${t.id}`}
|
||||
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-5 shadow-sm border border-light-200/20 dark:border-dark-200/20 block hover:border-[#7C3AED]/30 transition-colors"
|
||||
>
|
||||
<h2 className="font-medium text-lg">{t.title}</h2>
|
||||
{t.description && (
|
||||
<p className="text-sm text-black/60 dark:text-white/60 mt-1">{t.description}</p>
|
||||
)}
|
||||
{t.category && (
|
||||
<span className="inline-block mt-2 text-xs px-2 py-0.5 rounded bg-light-200 dark:bg-dark-200">
|
||||
{t.category}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary text-center text-black/60 dark:text-white/60">
|
||||
<p>No templates available. Enable projects-svc in deploy.config.yaml</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href="/spaces"
|
||||
className="text-sm text-[#7C3AED] hover:underline"
|
||||
>
|
||||
← Back to Spaces
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
184
services/web-svc/src/app/travel/page.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import { Plane, MapPin } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '@/lib/utils';
|
||||
import DataFetchError from '@/components/DataFetchError';
|
||||
import TravelStepper from '@/components/TravelStepper';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
interface TrendItem {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface InspirationItem {
|
||||
title: string;
|
||||
summary: string;
|
||||
image?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const Page = () => {
|
||||
const [trending, setTrending] = useState<{ items?: TrendItem[] } | null>(null);
|
||||
const [inspiration, setInspiration] = useState<{ items?: InspirationItem[] } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stepperOpen, setStepperOpen] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [trRes, inRes] = await Promise.all([
|
||||
fetch('/api/v1/travel/trending', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }),
|
||||
fetch('/api/v1/travel/inspiration', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }),
|
||||
]);
|
||||
const trData = await trRes.json().catch(() => ({}));
|
||||
const inData = await inRes.json().catch(() => ({}));
|
||||
setTrending(trData);
|
||||
setInspiration(inData);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Error';
|
||||
const isTimeout = msg.includes('timeout') || (err as Error)?.name === 'AbortError';
|
||||
setError(isTimeout ? 'Request timed out. Try again.' : msg);
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const trendItems = trending?.items ?? [];
|
||||
const inspItems = inspiration?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-5xl mx-auto w-full">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Plane size={40} className="text-[#EA580C]" />
|
||||
<h1 className="text-4xl font-normal">Travel</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStepperOpen(true)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-xl font-medium',
|
||||
'bg-[#EA580C] text-white hover:opacity-90 transition'
|
||||
)}
|
||||
>
|
||||
<MapPin size={18} />
|
||||
Plan a trip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stepperOpen && <TravelStepper onClose={() => setStepperOpen(false)} />}
|
||||
|
||||
{error ? (
|
||||
<DataFetchError message={error} onRetry={fetchData} />
|
||||
) : loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl overflow-hidden bg-light-secondary dark:bg-dark-secondary animate-pulse"
|
||||
>
|
||||
<div className="aspect-video bg-light-200 dark:bg-dark-200" />
|
||||
<div className="p-4">
|
||||
<div className="h-5 w-3/4 bg-light-200 dark:bg-dark-200 rounded mb-2" />
|
||||
<div className="h-4 w-1/2 bg-light-200 dark:bg-dark-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{trendItems.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<h2 className="text-xl font-medium mb-4">Trending Destinations</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{trendItems.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/?q=${encodeURIComponent(`travel to ${item.name}`)}&answerMode=travel`}
|
||||
className="rounded-2xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm border border-light-200/20 dark:border-dark-200/20 hover:opacity-90 transition"
|
||||
>
|
||||
<div className="relative aspect-video">
|
||||
<Image
|
||||
src={item.image || 'https://placehold.co/400x225/e5e7eb/6b7280?text='}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 100vw, 33vw"
|
||||
unoptimized={item.image?.includes('placehold')}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-medium">{item.name}</h3>
|
||||
{item.description && (
|
||||
<p className="text-sm text-black/60 dark:text-white/60 mt-1 line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{inspItems.length > 0 && (
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xl font-medium mb-4">Inspiration</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{inspItems.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-2xl p-4 bg-light-secondary dark:bg-dark-secondary',
|
||||
'border border-light-200/20 dark:border-dark-200/20'
|
||||
)}
|
||||
>
|
||||
<h3 className="font-medium">{item.title}</h3>
|
||||
{item.summary && (
|
||||
<p className="text-sm text-black/60 dark:text-white/60 mt-2 line-clamp-2">
|
||||
{item.summary}
|
||||
</p>
|
||||
)}
|
||||
{item.url && (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-[#EA580C] mt-2 inline-block hover:underline"
|
||||
>
|
||||
Read more →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && trendItems.length === 0 && inspItems.length === 0 && (
|
||||
<div className="mt-6 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary text-center text-black/60 dark:text-white/60">
|
||||
<p>Travel content will appear here when travel-svc is running.</p>
|
||||
<p className="text-sm mt-2">Enable travel-svc in deploy.config.yaml</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
282
services/web-svc/src/components/AssistantSteps.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Brain,
|
||||
Search,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
BookSearch,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ResearchBlock, ResearchBlockSubStep } from '@/lib/types';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
|
||||
const getStepIcon = (step: ResearchBlockSubStep) => {
|
||||
if (step.type === 'reasoning') {
|
||||
return <Brain className="w-4 h-4" />;
|
||||
} else if (step.type === 'searching' || step.type === 'upload_searching') {
|
||||
return <Search className="w-4 h-4" />;
|
||||
} else if (
|
||||
step.type === 'search_results' ||
|
||||
step.type === 'upload_search_results'
|
||||
) {
|
||||
return <FileText className="w-4 h-4" />;
|
||||
} else if (step.type === 'reading') {
|
||||
return <BookSearch className="w-4 h-4" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStepTitle = (
|
||||
step: ResearchBlockSubStep,
|
||||
isStreaming: boolean,
|
||||
t: (key: string) => string,
|
||||
): string => {
|
||||
if (step.type === 'reasoning') {
|
||||
return isStreaming && !step.reasoning ? t('chat.brainstorming') : t('chat.thinking');
|
||||
} else if (step.type === 'searching') {
|
||||
const n = step.searching.length;
|
||||
return t('chat.searchingQueries').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.query') : t('chat.queries'));
|
||||
} else if (step.type === 'search_results') {
|
||||
const n = step.reading.length;
|
||||
return t('chat.foundResults').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.result') : t('chat.results'));
|
||||
} else if (step.type === 'reading') {
|
||||
const n = step.reading.length;
|
||||
return t('chat.readingSources').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.source') : t('chat.sources'));
|
||||
} else if (step.type === 'upload_searching') {
|
||||
return t('chat.scanningDocs');
|
||||
} else if (step.type === 'upload_search_results') {
|
||||
const n = step.results.length;
|
||||
return t('chat.readingDocs').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.document') : t('chat.documents'));
|
||||
}
|
||||
|
||||
return t('chat.processing');
|
||||
};
|
||||
|
||||
const AssistantSteps = ({
|
||||
block,
|
||||
status,
|
||||
isLast,
|
||||
}: {
|
||||
block: ResearchBlock;
|
||||
status: 'answering' | 'completed' | 'error';
|
||||
isLast: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
isLast && status === 'answering' ? true : false,
|
||||
);
|
||||
const { researchEnded, loading } = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
if (researchEnded && isLast) {
|
||||
setIsExpanded(false);
|
||||
} else if (status === 'answering' && isLast) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [researchEnded, status]);
|
||||
|
||||
if (!block || block.data.subSteps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-black dark:text-white" />
|
||||
<span className="text-sm font-medium text-black dark:text-white">
|
||||
{t('chat.researchProgress')} ({block.data.subSteps.length}{' '}
|
||||
{block.data.subSteps.length === 1 ? t('chat.step') : t('chat.steps')})
|
||||
{status === 'answering' && (
|
||||
<span className="ml-2 font-normal text-black/60 dark:text-white/60">
|
||||
(~30–90 sec)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-black/70 dark:text-white/70" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-black/70 dark:text-white/70" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="border-t border-light-200 dark:border-dark-200"
|
||||
>
|
||||
<div className="p-3 space-y-2">
|
||||
{block.data.subSteps.map((step, index) => {
|
||||
const isLastStep = index === block.data.subSteps.length - 1;
|
||||
const isStreaming = loading && isLastStep && !researchEnded;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0 }}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<div className="flex flex-col items-center -mt-0.5">
|
||||
<div
|
||||
className={`rounded-full p-1.5 bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 ${isStreaming ? 'animate-pulse' : ''}`}
|
||||
>
|
||||
{getStepIcon(step)}
|
||||
</div>
|
||||
{index < block.data.subSteps.length - 1 && (
|
||||
<div className="w-0.5 flex-1 min-h-[20px] bg-light-200 dark:bg-dark-200 mt-1.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pb-1">
|
||||
<span className="text-sm font-medium text-black dark:text-white">
|
||||
{getStepTitle(step, isStreaming, t)}
|
||||
</span>
|
||||
|
||||
{step.type === 'reasoning' && (
|
||||
<>
|
||||
{step.reasoning && (
|
||||
<p className="text-xs text-black/70 dark:text-white/70 mt-0.5">
|
||||
{step.reasoning}
|
||||
</p>
|
||||
)}
|
||||
{isStreaming && !step.reasoning && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step.type === 'searching' &&
|
||||
step.searching.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{step.searching.map((query, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
|
||||
>
|
||||
{query}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(step.type === 'search_results' ||
|
||||
step.type === 'reading') &&
|
||||
step.reading.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{step.reading.slice(0, 4).map((result, idx) => {
|
||||
const url = typeof result.metadata?.url === 'string' ? result.metadata.url : '';
|
||||
const title = String(result.metadata?.title ?? 'Untitled');
|
||||
let domain = '';
|
||||
try {
|
||||
if (url) domain = new URL(url).hostname;
|
||||
} catch {
|
||||
/* invalid url */
|
||||
}
|
||||
const faviconUrl = domain
|
||||
? `https://s2.googleusercontent.com/s2/favicons?domain=${domain}&sz=128`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
|
||||
>
|
||||
{faviconUrl && (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-3 h-3 rounded-sm flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="line-clamp-1">{title}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.type === 'upload_searching' &&
|
||||
step.queries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{step.queries.map((query, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
|
||||
>
|
||||
{query}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.type === 'upload_search_results' &&
|
||||
step.results.length > 0 && (
|
||||
<div className="mt-1.5 grid gap-3 lg:grid-cols-3">
|
||||
{step.results.slice(0, 4).map((result, idx) => {
|
||||
const raw =
|
||||
result.metadata?.title ?? result.metadata?.fileName;
|
||||
const title =
|
||||
typeof raw === 'string' ? raw : 'Untitled document';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-row space-x-3 rounded-lg border border-light-200 dark:border-dark-200 bg-light-100 dark:bg-dark-100 p-2 cursor-pointer"
|
||||
>
|
||||
<div className="mt-0.5 h-10 w-10 rounded-md bg-[#EA580C]/20 text-[#EA580C] dark:bg-[#EA580C] dark:text-white flex items-center justify-center">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<p className="text-[13px] text-black dark:text-white line-clamp-1">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantSteps;
|
||||
108
services/web-svc/src/components/Chat.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import MessageInput from './MessageInput';
|
||||
import MessageBox from './MessageBox';
|
||||
import MessageBoxLoading from './MessageBoxLoading';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
|
||||
const Chat = () => {
|
||||
const { sections, loading, messageAppeared, messages } = useChat();
|
||||
|
||||
const [dividerWidth, setDividerWidth] = useState(0);
|
||||
const dividerRef = useRef<HTMLDivElement | null>(null);
|
||||
const messageEnd = useRef<HTMLDivElement | null>(null);
|
||||
const lastScrolledRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const updateDividerWidth = () => {
|
||||
if (dividerRef.current) {
|
||||
setDividerWidth(dividerRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
updateDividerWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateDividerWidth();
|
||||
});
|
||||
|
||||
const currentRef = dividerRef.current;
|
||||
if (currentRef) {
|
||||
resizeObserver.observe(currentRef);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateDividerWidth);
|
||||
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
resizeObserver.unobserve(currentRef);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', updateDividerWidth);
|
||||
};
|
||||
}, [sections.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const scroll = () => {
|
||||
messageEnd.current?.scrollIntoView({ behavior: 'auto' });
|
||||
};
|
||||
|
||||
if (messages.length === 1) {
|
||||
document.title = `${messages[0].query.substring(0, 30)} - GooSeek`;
|
||||
}
|
||||
|
||||
if (sections.length > lastScrolledRef.current) {
|
||||
scroll();
|
||||
lastScrolledRef.current = sections.length;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-6 pt-8 pb-44 lg:pb-28 sm:mx-4 md:mx-8">
|
||||
{sections.map((section, i) => {
|
||||
const isLast = i === sections.length - 1;
|
||||
|
||||
return (
|
||||
<Fragment key={section.message.messageId}>
|
||||
<MessageBox
|
||||
section={section}
|
||||
sectionIndex={i}
|
||||
dividerRef={isLast ? dividerRef : undefined}
|
||||
isLast={isLast}
|
||||
/>
|
||||
{!isLast && (
|
||||
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{loading && !messageAppeared && <MessageBoxLoading />}
|
||||
<div ref={messageEnd} className="h-0" />
|
||||
{dividerWidth > 0 && (
|
||||
<div
|
||||
className="fixed z-40 bottom-24 lg:bottom-6"
|
||||
style={{ width: dividerWidth }}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute -bottom-6 left-0 right-0 h-[calc(100%+24px+24px)] dark:hidden"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, #ffffff 0%, #ffffff 35%, rgba(255,255,255,0.95) 45%, rgba(255,255,255,0.85) 55%, rgba(255,255,255,0.7) 65%, rgba(255,255,255,0.5) 75%, rgba(255,255,255,0.3) 85%, rgba(255,255,255,0.1) 92%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute -bottom-6 left-0 right-0 h-[calc(100%+24px+24px)] hidden dark:block"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, #0d1117 0%, #0d1117 35%, rgba(13,17,23,0.95) 45%, rgba(13,17,23,0.85) 55%, rgba(13,17,23,0.7) 65%, rgba(13,17,23,0.5) 75%, rgba(13,17,23,0.3) 85%, rgba(13,17,23,0.1) 92%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
<MessageInput />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
77
services/web-svc/src/components/ChatWindow.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import Navbar from './Navbar';
|
||||
import Chat from './Chat';
|
||||
import EmptyChat from './EmptyChat';
|
||||
import NextError from 'next/error';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import SettingsButtonMobile from './Settings/SettingsButtonMobile';
|
||||
import { Block } from '@/lib/types';
|
||||
|
||||
export interface BaseMessage {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Message extends BaseMessage {
|
||||
backendId: string;
|
||||
query: string;
|
||||
responseBlocks: Block[];
|
||||
status: 'answering' | 'completed' | 'error';
|
||||
/** Заголовок статьи при переходе из Discover (Summary) */
|
||||
articleTitle?: string;
|
||||
}
|
||||
|
||||
export interface File {
|
||||
fileName: string;
|
||||
fileExtension: string;
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
export interface Widget {
|
||||
widgetType: string;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
const ChatWindow = () => {
|
||||
const { hasError, notFound, messages, isReady } = useChat();
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
|
||||
<SettingsButtonMobile />
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="dark:text-white/70 text-black/70 text-sm">
|
||||
Failed to connect to the server. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isReady ? (
|
||||
notFound ? (
|
||||
<NextError statusCode={404} />
|
||||
) : (
|
||||
<div>
|
||||
{messages.length > 0 ? (
|
||||
<>
|
||||
<Navbar />
|
||||
<Chat />
|
||||
</>
|
||||
) : (
|
||||
<EmptyChat />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="min-h-screen w-full">
|
||||
<EmptyChat />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatWindow;
|
||||
25
services/web-svc/src/components/ClientOnly.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Откладывает рендер детей до момента монтирования на клиенте.
|
||||
* Устраняет ошибку "Can't perform a React state update on a component that hasn't mounted yet"
|
||||
* при гидрации с next-themes и другими провайдерами.
|
||||
*/
|
||||
export function ClientOnly({
|
||||
children,
|
||||
fallback = null,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return fallback;
|
||||
return <>{children}</>;
|
||||
}
|
||||
30
services/web-svc/src/components/DataFetchError.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Ошибка загрузки с кнопкой Retry
|
||||
* docs/architecture: 05-gaps-and-best-practices.md §8
|
||||
*/
|
||||
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
interface DataFetchErrorProps {
|
||||
message?: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export default function DataFetchError({ message = 'Failed to load. Check your connection.', onRetry }: DataFetchErrorProps) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary border border-red-500/20 dark:border-red-500/30"
|
||||
>
|
||||
<p className="text-black/70 dark:text-white/70 mb-4">{message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-light-accent dark:bg-dark-accent text-white text-sm font-medium hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#EA580C]"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
services/web-svc/src/components/DeleteChat.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Trash } from 'lucide-react';
|
||||
import {
|
||||
Description,
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from '@headlessui/react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Chat } from '@/app/library/page';
|
||||
|
||||
const DeleteChat = ({
|
||||
chatId,
|
||||
chats,
|
||||
setChats,
|
||||
redirect = false,
|
||||
}: {
|
||||
chatId: string;
|
||||
chats: Chat[];
|
||||
setChats: (chats: Chat[]) => void;
|
||||
redirect?: boolean;
|
||||
}) => {
|
||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
|
||||
: null;
|
||||
|
||||
if (!token) {
|
||||
const { deleteGuestChat } = await import('@/lib/guest-storage');
|
||||
deleteGuestChat(chatId);
|
||||
const newChats = chats.filter((chat) => chat.id !== chatId);
|
||||
setChats(newChats);
|
||||
if (redirect) window.location.href = '/';
|
||||
setConfirmationDialogOpen(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const libRes = await fetch(`/api/v1/library/threads/${chatId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!libRes.ok) {
|
||||
throw new Error('Failed to delete chat');
|
||||
}
|
||||
|
||||
const newChats = chats.filter((chat) => chat.id !== chatId);
|
||||
|
||||
setChats(newChats);
|
||||
|
||||
if (redirect) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setConfirmationDialogOpen(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmationDialogOpen(true);
|
||||
}}
|
||||
className="bg-transparent text-red-400 hover:scale-105 transition duration-200"
|
||||
>
|
||||
<Trash size={17} />
|
||||
</button>
|
||||
<Transition appear show={confirmationDialogOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-50"
|
||||
onClose={() => {
|
||||
if (!loading) {
|
||||
setConfirmationDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogBackdrop className="fixed inset-0 bg-black/30" />
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-100"
|
||||
leaveFrom="opacity-100 scale-200"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle className="text-lg font-medium leading-6 dark:text-white">
|
||||
Delete Confirmation
|
||||
</DialogTitle>
|
||||
<Description className="text-sm dark:text-white/70 text-black/70">
|
||||
Are you sure you want to delete this chat?
|
||||
</Description>
|
||||
<div className="flex flex-row items-end justify-end space-x-4 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!loading) {
|
||||
setConfirmationDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
className="text-black/50 dark:text-white/50 text-sm hover:text-black/70 hover:dark:text-white/70 transition duration-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-red-400 text-sm hover:text-red-500 transition duration200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteChat;
|
||||
60
services/web-svc/src/components/Discover/MajorNewsCard.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Discover } from '@/app/discover/page';
|
||||
import Link from 'next/link';
|
||||
|
||||
const MajorNewsCard = ({
|
||||
item,
|
||||
isLeft = true,
|
||||
}: {
|
||||
item: Discover;
|
||||
isLeft?: boolean;
|
||||
}) => (
|
||||
<Link
|
||||
href={`/?q=${encodeURIComponent(`Summary: ${item.url}`)}&title=${encodeURIComponent(item.title)}`}
|
||||
className="w-full group flex flex-row items-stretch gap-6 h-60 py-3"
|
||||
target="_blank"
|
||||
>
|
||||
{isLeft ? (
|
||||
<>
|
||||
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0">
|
||||
<img
|
||||
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-500"
|
||||
src={item.thumbnail}
|
||||
alt={item.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center flex-1 py-4">
|
||||
<h2
|
||||
className="text-3xl font-light mb-3 leading-tight line-clamp-3 group-hover:text-[#EA580C] transition duration-200"
|
||||
>
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="text-black/60 dark:text-white/60 text-base leading-relaxed line-clamp-4">
|
||||
{item.content}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col justify-center flex-1 py-4">
|
||||
<h2
|
||||
className="text-3xl font-light mb-3 leading-tight line-clamp-3 group-hover:text-[#EA580C] transition duration-200"
|
||||
>
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="text-black/60 dark:text-white/60 text-base leading-relaxed line-clamp-4">
|
||||
{item.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0">
|
||||
<img
|
||||
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-500"
|
||||
src={item.thumbnail}
|
||||
alt={item.title}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default MajorNewsCard;
|
||||
28
services/web-svc/src/components/Discover/SmallNewsCard.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Discover } from '@/app/discover/page';
|
||||
import Link from 'next/link';
|
||||
|
||||
const SmallNewsCard = ({ item }: { item: Discover }) => (
|
||||
<Link
|
||||
href={`/?q=${encodeURIComponent(`Summary: ${item.url}`)}&title=${encodeURIComponent(item.title)}`}
|
||||
className="rounded-3xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm shadow-light-200/10 dark:shadow-black/25 group flex flex-col"
|
||||
target="_blank"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden">
|
||||
<img
|
||||
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-300"
|
||||
src={item.thumbnail}
|
||||
alt={item.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-sm mb-2 leading-tight line-clamp-2 group-hover:text-[#EA580C] transition duration-200">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-black/60 dark:text-white/60 text-xs leading-relaxed line-clamp-2">
|
||||
{item.content}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default SmallNewsCard;
|
||||
81
services/web-svc/src/components/EmptyChat.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import EmptyChatMessageInput from './EmptyChatMessageInput';
|
||||
import WeatherWidget from './WeatherWidget';
|
||||
import NewsArticleWidget from './NewsArticleWidget';
|
||||
import SettingsButtonMobile from '@/components/Settings/SettingsButtonMobile';
|
||||
import {
|
||||
getShowNewsWidget,
|
||||
getShowWeatherWidget,
|
||||
} from '@/lib/config/clientRegistry';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
|
||||
const EmptyChat = () => {
|
||||
const { t } = useTranslation();
|
||||
const [showWeather, setShowWeather] = useState(() =>
|
||||
typeof window !== 'undefined' ? getShowWeatherWidget() : true,
|
||||
);
|
||||
const [showNews, setShowNews] = useState(() =>
|
||||
typeof window !== 'undefined' ? getShowNewsWidget() : true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updateWidgetVisibility = () => {
|
||||
setShowWeather(getShowWeatherWidget());
|
||||
setShowNews(getShowNewsWidget());
|
||||
};
|
||||
|
||||
updateWidgetVisibility();
|
||||
|
||||
window.addEventListener('client-config-changed', updateWidgetVisibility);
|
||||
window.addEventListener('storage', updateWidgetVisibility);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'client-config-changed',
|
||||
updateWidgetVisibility,
|
||||
);
|
||||
window.removeEventListener('storage', updateWidgetVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
|
||||
<SettingsButtonMobile />
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen max-w-screen-sm mx-auto p-2 space-y-4">
|
||||
<div className="flex flex-col items-center justify-center w-full space-y-6">
|
||||
<div className="flex flex-row items-center justify-center -mt-8 mb-2">
|
||||
<img
|
||||
src={`/logo.svg?v=${process.env.NEXT_PUBLIC_VERSION ?? '1'}`}
|
||||
alt="GooSeek"
|
||||
className="h-12 sm:h-14 w-auto select-none"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium">
|
||||
{t('empty.subtitle')}
|
||||
</h2>
|
||||
{(showWeather || showNews) && (
|
||||
<div className="flex flex-row flex-wrap w-full gap-2 sm:gap-3 justify-center items-stretch">
|
||||
{showWeather && (
|
||||
<div className="flex-1 min-w-[120px] shrink-0">
|
||||
<WeatherWidget />
|
||||
</div>
|
||||
)}
|
||||
{showNews && (
|
||||
<div className="flex-1 min-w-[120px] shrink-0">
|
||||
<NewsArticleWidget />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<EmptyChatMessageInput />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyChat;
|
||||
96
services/web-svc/src/components/EmptyChatMessageInput.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import Sources from './MessageInputActions/Sources';
|
||||
import Optimization from './MessageInputActions/Optimization';
|
||||
import AnswerMode from './MessageInputActions/AnswerMode';
|
||||
import InputBarPlus from './MessageInputActions/InputBarPlus';
|
||||
import Attach from './MessageInputActions/Attach';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
import ModelSelector from './MessageInputActions/ChatModelSelector';
|
||||
|
||||
const EmptyChatMessageInput = () => {
|
||||
const { sendMessage } = useChat();
|
||||
const { t } = useTranslation();
|
||||
|
||||
/* const [copilotEnabled, setCopilotEnabled] = useState(false); */
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
const isInputFocused =
|
||||
activeElement?.tagName === 'INPUT' ||
|
||||
activeElement?.tagName === 'TEXTAREA' ||
|
||||
activeElement?.hasAttribute('contenteditable');
|
||||
|
||||
if (e.key === '/' && !isInputFocused) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
inputRef.current?.focus();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendMessage(message);
|
||||
setMessage('');
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage(message);
|
||||
setMessage('');
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex flex-col bg-light-secondary dark:bg-dark-secondary px-3 pt-5 pb-3 rounded-2xl w-full border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/20 transition-all duration-200 focus-within:border-light-300 dark:focus-within:border-dark-300">
|
||||
<TextareaAutosize
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
minRows={2}
|
||||
className="px-2 bg-transparent placeholder:text-[15px] placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
|
||||
placeholder={t('input.askAnything')}
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between mt-4">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<InputBarPlus />
|
||||
<Optimization />
|
||||
<AnswerMode />
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<Sources />
|
||||
<ModelSelector />
|
||||
<Attach />
|
||||
</div>
|
||||
<button
|
||||
disabled={message.trim().length === 0}
|
||||
className="bg-[#EA580C] text-white disabled:text-black/50 dark:disabled:text-white/50 disabled:bg-[#e0e0dc] dark:disabled:bg-[#ececec21] hover:bg-opacity-85 transition duration-100 rounded-full p-2"
|
||||
>
|
||||
<ArrowRight className="bg-background" size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyChatMessageInput;
|
||||
48
services/web-svc/src/components/GuestMigration.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { hasGuestData } from '@/lib/guest-storage';
|
||||
import { migrateGuestToProfile } from '@/lib/guest-migration';
|
||||
|
||||
export default function GuestMigration() {
|
||||
const migratedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const token =
|
||||
localStorage.getItem('auth_token') ?? localStorage.getItem('access_token');
|
||||
if (!token || !hasGuestData() || migratedRef.current) return;
|
||||
|
||||
migratedRef.current = true;
|
||||
|
||||
migrateGuestToProfile(token)
|
||||
.then(({ migrated }) => {
|
||||
if (migrated > 0) {
|
||||
toast.success('Ваши чаты сохранены в профиль');
|
||||
window.dispatchEvent(new CustomEvent('gooseek:guest-migrated'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
migratedRef.current = false;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === 'auth_token' || e.key === 'access_token') {
|
||||
const token =
|
||||
localStorage.getItem('auth_token') ??
|
||||
localStorage.getItem('access_token');
|
||||
if (token && hasGuestData()) {
|
||||
migratedRef.current = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
55
services/web-svc/src/components/GuestWarningBanner.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Предупреждение гостям: история теряется при закрытии
|
||||
* docs/architecture: 05-gaps-and-best-practices.md §8
|
||||
*/
|
||||
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function GuestWarningBanner() {
|
||||
const { messages } = useChat();
|
||||
const [isGuest, setIsGuest] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
|
||||
: null;
|
||||
setIsGuest(!token);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGuest || messages.length === 0) return;
|
||||
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [isGuest, messages.length]);
|
||||
|
||||
if (!isGuest || messages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 max-w-lg w-[calc(100%-2rem)]"
|
||||
>
|
||||
<div className="rounded-xl bg-amber-500/95 dark:bg-amber-600/95 text-amber-950 dark:text-amber-50 px-4 py-3 shadow-lg flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Your chat history will be lost when you close the browser.
|
||||
</span>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="text-sm font-semibold underline underline-offset-2 hover:no-underline whitespace-nowrap"
|
||||
>
|
||||
Save to account →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
services/web-svc/src/components/Layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<main className="lg:pl-16 bg-light-primary dark:bg-dark-primary min-h-screen">
|
||||
<div className="max-w-screen-lg lg:mx-auto mx-4">{children}</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
51
services/web-svc/src/components/MessageActions/Copy.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Check, ClipboardList } from 'lucide-react';
|
||||
import { Message } from '../ChatWindow';
|
||||
import { useState } from 'react';
|
||||
import { Section } from '@/lib/hooks/useChat';
|
||||
import { SourceBlock } from '@/lib/types';
|
||||
|
||||
const Copy = ({
|
||||
section,
|
||||
initialMessage,
|
||||
}: {
|
||||
section: Section;
|
||||
initialMessage: string;
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
const sources = section.message.responseBlocks.filter(
|
||||
(b) => b.type === 'source' && b.data.length > 0,
|
||||
) as SourceBlock[];
|
||||
|
||||
const contentToCopy = `${initialMessage}${
|
||||
sources.length > 0
|
||||
? `\n\nCitations:\n${sources
|
||||
.map((source) => source.data)
|
||||
.flat()
|
||||
.map((s, i) => {
|
||||
const url = String(s.metadata?.url ?? '');
|
||||
const fileName = String(s.metadata?.fileName ?? '');
|
||||
const label =
|
||||
url.startsWith('file_id://') ? (fileName || 'Uploaded File') : url;
|
||||
return `[${i + 1}] ${label}`;
|
||||
})
|
||||
.join(`\n`)}`
|
||||
: ''
|
||||
}`;
|
||||
|
||||
navigator.clipboard.writeText(contentToCopy);
|
||||
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
}}
|
||||
className="p-2 text-black/70 dark:text-white/70 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <ClipboardList size={16} />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Copy;
|
||||
20
services/web-svc/src/components/MessageActions/Rewrite.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ArrowLeftRight, Repeat } from 'lucide-react';
|
||||
|
||||
const Rewrite = ({
|
||||
rewrite,
|
||||
messageId,
|
||||
}: {
|
||||
rewrite: (messageId: string) => void;
|
||||
messageId: string;
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => rewrite(messageId)}
|
||||
className="p-2 text-black/70 dark:text-white/70 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white flex flex-row items-center space-x-1"
|
||||
>
|
||||
<Repeat size={16} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
1;
|
||||
export default Rewrite;
|
||||
325
services/web-svc/src/components/MessageBox.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import React, { MutableRefObject } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
BookCopy,
|
||||
Disc3,
|
||||
Volume2,
|
||||
StopCircle,
|
||||
Layers3,
|
||||
Plus,
|
||||
CornerDownRight,
|
||||
} from 'lucide-react';
|
||||
import Markdown, { MarkdownToJSX, RuleType } from 'markdown-to-jsx';
|
||||
import Copy from './MessageActions/Copy';
|
||||
import Rewrite from './MessageActions/Rewrite';
|
||||
import MessageSources from './MessageSources';
|
||||
import SearchImages from './SearchImages';
|
||||
import SearchVideos from './SearchVideos';
|
||||
import { useSpeech } from 'react-text-to-speech';
|
||||
import ThinkBox from './ThinkBox';
|
||||
import { useChat, Section } from '@/lib/hooks/useChat';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
import Citation from './MessageRenderer/Citation';
|
||||
import AssistantSteps from './AssistantSteps';
|
||||
import { ResearchBlock } from '@/lib/types';
|
||||
import Renderer from './Widgets/Renderer';
|
||||
import CodeBlock from './MessageRenderer/CodeBlock';
|
||||
|
||||
const ThinkTagProcessor = ({
|
||||
children,
|
||||
thinkingEnded,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
thinkingEnded: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<ThinkBox content={children as string} thinkingEnded={thinkingEnded} />
|
||||
);
|
||||
};
|
||||
|
||||
const MessageBox = ({
|
||||
section,
|
||||
sectionIndex,
|
||||
dividerRef,
|
||||
isLast,
|
||||
}: {
|
||||
section: Section;
|
||||
sectionIndex: number;
|
||||
dividerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isLast: boolean;
|
||||
}) => {
|
||||
const {
|
||||
loading,
|
||||
sendMessage,
|
||||
rewrite,
|
||||
messages,
|
||||
researchEnded,
|
||||
chatHistory,
|
||||
} = useChat();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const parsedMessage = section.parsedTextBlocks.join('\n\n');
|
||||
const speechMessage = section.speechMessage || '';
|
||||
const thinkingEnded = section.thinkingEnded;
|
||||
|
||||
const sourceBlocks = section.message.responseBlocks.filter(
|
||||
(block): block is typeof block & { type: 'source' } =>
|
||||
block.type === 'source',
|
||||
);
|
||||
|
||||
const sources = sourceBlocks.flatMap((block) => block.data);
|
||||
|
||||
const hasContent =
|
||||
section.parsedTextBlocks.some(
|
||||
(t) => typeof t === 'string' && t.trim().length > 0,
|
||||
);
|
||||
|
||||
const { speechStatus, start, stop } = useSpeech({ text: speechMessage });
|
||||
|
||||
const markdownOverrides: MarkdownToJSX.Options = {
|
||||
renderRule(next, node, renderChildren, state) {
|
||||
if (node.type === RuleType.codeInline) {
|
||||
return `\`${node.text}\``;
|
||||
}
|
||||
|
||||
if (node.type === RuleType.codeBlock) {
|
||||
return (
|
||||
<CodeBlock key={state.key} language={node.lang || ''}>
|
||||
{node.text}
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
},
|
||||
overrides: {
|
||||
think: {
|
||||
component: ThinkTagProcessor,
|
||||
props: {
|
||||
thinkingEnded: thinkingEnded,
|
||||
},
|
||||
},
|
||||
citation: {
|
||||
component: Citation,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const isSummaryArticle =
|
||||
section.message.query.startsWith('Summary: ') &&
|
||||
section.message.articleTitle;
|
||||
const summaryUrl = isSummaryArticle
|
||||
? section.message.query.slice(9)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="w-full pt-8 break-words lg:max-w-[60%]">
|
||||
{isSummaryArticle ? (
|
||||
<div className="space-y-1 min-w-0 overflow-hidden">
|
||||
<h2 className="text-black dark:text-white font-medium text-3xl">
|
||||
{section.message.articleTitle}
|
||||
</h2>
|
||||
<a
|
||||
href={summaryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-sm text-black/60 dark:text-white/60 hover:text-black/80 dark:hover:text-white/80 truncate min-w-0"
|
||||
title={summaryUrl}
|
||||
>
|
||||
{summaryUrl}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<h2 className="text-black dark:text-white font-medium text-3xl">
|
||||
{section.message.query}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-9 lg:space-y-0 lg:flex-row lg:justify-between lg:space-x-9">
|
||||
<div
|
||||
ref={dividerRef}
|
||||
className="flex flex-col space-y-6 w-full lg:w-9/12"
|
||||
>
|
||||
{sources.length > 0 && (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<BookCopy className="text-black dark:text-white" size={20} />
|
||||
<h3 className="text-black dark:text-white font-medium text-xl">
|
||||
{t('chat.sources')}
|
||||
</h3>
|
||||
</div>
|
||||
<MessageSources sources={sources} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.message.responseBlocks
|
||||
.filter(
|
||||
(block): block is ResearchBlock =>
|
||||
block.type === 'research' && block.data.subSteps.length > 0,
|
||||
)
|
||||
.map((researchBlock) => (
|
||||
<div key={researchBlock.id} className="flex flex-col space-y-2">
|
||||
<AssistantSteps
|
||||
block={researchBlock}
|
||||
status={section.message.status}
|
||||
isLast={isLast}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLast &&
|
||||
loading &&
|
||||
!researchEnded &&
|
||||
!section.message.responseBlocks.some(
|
||||
(b) => b.type === 'research' && b.data.subSteps.length > 0,
|
||||
) && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200">
|
||||
<Disc3 className="w-4 h-4 text-black dark:text-white" />
|
||||
<span className="text-sm text-black/70 dark:text-white/70">
|
||||
{t('chat.brainstorming')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.widgets.length > 0 && <Renderer widgets={section.widgets} />}
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
{sources.length > 0 && (
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<Disc3 className="text-black dark:text-white" size={20} />
|
||||
<h3 className="text-black dark:text-white font-medium text-xl">
|
||||
{t('chat.answer')}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasContent && sources.length > 0 && isLast && loading && (
|
||||
<p className="text-sm text-black/60 dark:text-white/60 italic">
|
||||
{t('chat.formingAnswer')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!hasContent && sources.length > 0 && isLast && !loading && (
|
||||
<p className="text-sm text-black/60 dark:text-white/60 italic">
|
||||
{t('chat.answerFailed')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasContent && (
|
||||
<>
|
||||
<Markdown
|
||||
className={cn(
|
||||
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
|
||||
'max-w-none break-words text-black dark:text-white',
|
||||
)}
|
||||
options={markdownOverrides}
|
||||
>
|
||||
{parsedMessage}
|
||||
</Markdown>
|
||||
|
||||
{loading && isLast ? null : (
|
||||
<div className="flex flex-row items-center justify-between w-full text-black dark:text-white py-4">
|
||||
<div className="flex flex-row items-center -ml-2">
|
||||
<Rewrite
|
||||
rewrite={rewrite}
|
||||
messageId={section.message.messageId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center -mr-2">
|
||||
<Copy initialMessage={parsedMessage} section={section} />
|
||||
<button
|
||||
onClick={() => {
|
||||
if (speechStatus === 'started') {
|
||||
stop();
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
}}
|
||||
className="p-2 text-black/70 dark:text-white/70 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
{speechStatus === 'started' ? (
|
||||
<StopCircle size={16} />
|
||||
) : (
|
||||
<Volume2 size={16} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLast &&
|
||||
section.suggestions &&
|
||||
section.suggestions.length > 0 &&
|
||||
hasContent &&
|
||||
!loading && (
|
||||
<div className="mt-6">
|
||||
<div className="flex flex-row items-center space-x-2 mb-4">
|
||||
<Layers3
|
||||
className="text-black dark:text-white"
|
||||
size={20}
|
||||
/>
|
||||
<h3 className="text-black dark:text-white font-medium text-xl">
|
||||
{t('chat.related')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{section.suggestions.map(
|
||||
(suggestion: string, i: number) => (
|
||||
<div key={i}>
|
||||
<div className="h-px bg-light-200/40 dark:bg-dark-200/40" />
|
||||
<button
|
||||
onClick={() => sendMessage(suggestion)}
|
||||
className="group w-full py-4 text-left transition-colors duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-row space-x-3 items-center">
|
||||
<CornerDownRight
|
||||
size={15}
|
||||
className="group-hover:text-[#EA580C] transition-colors duration-200 flex-shrink-0"
|
||||
/>
|
||||
<p className="text-sm text-black/70 dark:text-white/70 group-hover:text-[#EA580C] transition-colors duration-200 leading-relaxed">
|
||||
{suggestion}
|
||||
</p>
|
||||
</div>
|
||||
<Plus
|
||||
size={16}
|
||||
className="text-black/40 dark:text-white/40 group-hover:text-[#EA580C] transition-colors duration-200 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasContent && (
|
||||
<div className="lg:sticky lg:top-20 flex flex-col items-center space-y-3 w-full lg:w-3/12 z-30 h-full pb-4">
|
||||
<SearchImages
|
||||
query={section.message.query}
|
||||
chatHistory={chatHistory}
|
||||
messageId={section.message.messageId}
|
||||
/>
|
||||
<SearchVideos
|
||||
chatHistory={chatHistory}
|
||||
query={section.message.query}
|
||||
messageId={section.message.messageId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBox;
|
||||
11
services/web-svc/src/components/MessageBoxLoading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
const MessageBoxLoading = () => {
|
||||
return (
|
||||
<div className="flex flex-col space-y-2 w-full lg:w-9/12 bg-light-primary dark:bg-dark-primary animate-pulse rounded-lg py-3">
|
||||
<div className="h-2 rounded-full w-full bg-light-secondary dark:bg-dark-secondary" />
|
||||
<div className="h-2 rounded-full w-9/12 bg-light-secondary dark:bg-dark-secondary" />
|
||||
<div className="h-2 rounded-full w-10/12 bg-light-secondary dark:bg-dark-secondary" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBoxLoading;
|
||||
104
services/web-svc/src/components/MessageInput.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import AttachSmall from './MessageInputActions/AttachSmall';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
|
||||
const MessageInput = () => {
|
||||
const { loading, sendMessage } = useChat();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [copilotEnabled, setCopilotEnabled] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [textareaRows, setTextareaRows] = useState(1);
|
||||
const [mode, setMode] = useState<'multi' | 'single'>('single');
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRows >= 2 && message && mode === 'single') {
|
||||
setMode('multi');
|
||||
} else if (!message && mode === 'multi') {
|
||||
setMode('single');
|
||||
}
|
||||
}, [textareaRows, mode, message]);
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
const isInputFocused =
|
||||
activeElement?.tagName === 'INPUT' ||
|
||||
activeElement?.tagName === 'TEXTAREA' ||
|
||||
activeElement?.hasAttribute('contenteditable');
|
||||
|
||||
if (e.key === '/' && !isInputFocused) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
if (loading) return;
|
||||
e.preventDefault();
|
||||
sendMessage(message);
|
||||
setMessage('');
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !loading) {
|
||||
e.preventDefault();
|
||||
sendMessage(message);
|
||||
setMessage('');
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'relative bg-light-secondary dark:bg-dark-secondary p-4 flex items-center overflow-visible border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/20 transition-all duration-200 focus-within:border-light-300 dark:focus-within:border-dark-300',
|
||||
mode === 'multi' ? 'flex-col rounded-2xl' : 'flex-row rounded-full',
|
||||
)}
|
||||
>
|
||||
{mode === 'single' && <AttachSmall />}
|
||||
<TextareaAutosize
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onHeightChange={(height, props) => {
|
||||
setTextareaRows(Math.ceil(height / props.rowHeight));
|
||||
}}
|
||||
className="transition bg-transparent dark:placeholder:text-white/50 placeholder:text-sm text-sm dark:text-white resize-none focus:outline-none w-full px-2 max-h-24 lg:max-h-36 xl:max-h-48 flex-grow flex-shrink"
|
||||
placeholder={t('input.askFollowUp')}
|
||||
/>
|
||||
{mode === 'single' && (
|
||||
<button
|
||||
disabled={message.trim().length === 0 || loading}
|
||||
className="bg-[#EA580C] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2"
|
||||
>
|
||||
<ArrowUp className="bg-background" size={17} />
|
||||
</button>
|
||||
)}
|
||||
{mode === 'multi' && (
|
||||
<div className="flex flex-row items-center justify-between w-full pt-2">
|
||||
<AttachSmall />
|
||||
<button
|
||||
disabled={message.trim().length === 0 || loading}
|
||||
className="bg-[#EA580C] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2"
|
||||
>
|
||||
<ArrowUp className="bg-background" size={17} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageInput;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ChevronDown, Globe, Plane, TrendingUp, BookOpen, PenLine } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
} from '@headlessui/react';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
type AnswerModeKey = 'standard' | 'focus' | 'academic' | 'writing' | 'travel' | 'finance';
|
||||
|
||||
const AnswerModes: { key: AnswerModeKey; title: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'standard', title: 'Standard', icon: <Globe size={16} className="text-[#EA580C]" /> },
|
||||
{ key: 'travel', title: 'Travel', icon: <Plane size={16} className="text-[#EA580C]" /> },
|
||||
{ key: 'finance', title: 'Finance', icon: <TrendingUp size={16} className="text-[#EA580C]" /> },
|
||||
{ key: 'academic', title: 'Academic', icon: <BookOpen size={16} className="text-[#EA580C]" /> },
|
||||
{ key: 'writing', title: 'Writing', icon: <PenLine size={16} className="text-[#EA580C]" /> },
|
||||
{ key: 'focus', title: 'Focus', icon: <Globe size={16} className="text-[#EA580C]" /> },
|
||||
];
|
||||
|
||||
const AnswerMode = () => {
|
||||
const { answerMode, setAnswerMode } = useChat();
|
||||
const current = AnswerModes.find((m) => m.key === answerMode) ?? AnswerModes[0];
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white focus:outline-none"
|
||||
title="Answer mode"
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{current.icon}
|
||||
<span className="text-xs hidden sm:inline">{current.title}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn(open ? 'rotate-180' : 'rotate-0', 'transition')}
|
||||
/>
|
||||
</div>
|
||||
</PopoverButton>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
className="absolute z-10 w-48 left-0 bottom-full mb-2"
|
||||
static
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-bottom-left flex flex-col bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 p-2"
|
||||
>
|
||||
{AnswerModes.map((mode) => (
|
||||
<PopoverButton
|
||||
key={mode.key}
|
||||
onClick={() => setAnswerMode(mode.key)}
|
||||
className={cn(
|
||||
'p-2 rounded-lg flex flex-row items-center gap-2 text-start cursor-pointer transition focus:outline-none',
|
||||
answerMode === mode.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
)}
|
||||
>
|
||||
{mode.icon}
|
||||
<span className="text-sm font-medium">{mode.title}</span>
|
||||
</PopoverButton>
|
||||
))}
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnswerMode;
|
||||
218
services/web-svc/src/components/MessageInputActions/Attach.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import {
|
||||
CopyPlus,
|
||||
File,
|
||||
Link,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Trash,
|
||||
} from 'lucide-react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 300000; // 5 min — docs/architecture: 05-gaps-and-best-practices.md §8
|
||||
|
||||
const Attach = () => {
|
||||
const { files, setFiles, setFileIds, fileIds, embeddingModelProvider } = useChat();
|
||||
|
||||
if (!embeddingModelProvider?.providerId) return null;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const fileList = e.target.files;
|
||||
if (!fileList?.length) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
|
||||
setLoading(true);
|
||||
setUploadError(null);
|
||||
|
||||
const data = new FormData();
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
data.append('files', fileList[i]);
|
||||
}
|
||||
const provider = localStorage.getItem('embeddingModelProviderId');
|
||||
const model = localStorage.getItem('embeddingModelKey');
|
||||
data.append('embedding_model_provider_id', provider!);
|
||||
data.append('embedding_model_key', model!);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/uploads`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const resData = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(resData.error ?? 'Upload failed');
|
||||
}
|
||||
|
||||
setFiles([...files, ...(resData.files ?? [])]);
|
||||
setFileIds([...fileIds, ...(resData.files ?? []).map((f: { fileId: string }) => f.fileId)]);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Upload failed';
|
||||
const isAbort = (err as Error)?.name === 'AbortError';
|
||||
setUploadError(isAbort ? 'Upload cancelled' : msg);
|
||||
if (!isAbort) toast.error(msg);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setLoading(false);
|
||||
abortRef.current = null;
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
};
|
||||
|
||||
return loading ? (
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg text-black/50 dark:text-white/50 text-xs">
|
||||
<span className="text-[#EA580C]">Uploading…</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="text-red-500 hover:underline focus:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : uploadError ? (
|
||||
<div className="p-2 rounded-lg text-xs text-red-500">
|
||||
{uploadError}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadError(null)}
|
||||
className="ml-2 underline hover:no-underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<File size={16} className="text-[#EA580C]" />
|
||||
</PopoverButton>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
className="absolute z-10 w-64 md:w-[350px] right-0 bottom-full mb-2"
|
||||
static
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-bottom-right bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col"
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||
<h4 className="text-black/70 dark:text-white/70 text-sm">
|
||||
Attached files
|
||||
</h4>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Plus size={16} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
|
||||
>
|
||||
<Trash size={13} />
|
||||
<p className="text-xs">Clear</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||
<div className="flex flex-col items-center">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-9 h-9 rounded-md">
|
||||
<File
|
||||
size={16}
|
||||
className="text-black/70 dark:text-white/70"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{file.fileName.length > 25
|
||||
? file.fileName
|
||||
.replace(/\.\w+$/, '')
|
||||
.substring(0, 25) +
|
||||
'...' +
|
||||
file.fileExtension
|
||||
: file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'flex items-center justify-center active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Attach;
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { File, Paperclip, Plus, Trash } from 'lucide-react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 300000;
|
||||
|
||||
const AttachSmall = () => {
|
||||
const { files, setFiles, setFileIds, fileIds, embeddingModelProvider } = useChat();
|
||||
|
||||
if (!embeddingModelProvider?.providerId) return null;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const fileList = e.target.files;
|
||||
if (!fileList?.length) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
|
||||
setLoading(true);
|
||||
setUploadError(null);
|
||||
|
||||
const data = new FormData();
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
data.append('files', fileList[i]);
|
||||
}
|
||||
const provider = localStorage.getItem('embeddingModelProviderId');
|
||||
const model = localStorage.getItem('embeddingModelKey');
|
||||
data.append('embedding_model_provider_id', provider!);
|
||||
data.append('embedding_model_key', model!);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/uploads`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const resData = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(resData.error ?? 'Upload failed');
|
||||
|
||||
setFiles([...files, ...(resData.files ?? [])]);
|
||||
setFileIds([...fileIds, ...(resData.files ?? []).map((f: { fileId: string }) => f.fileId)]);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Upload failed';
|
||||
const isAbort = (err as Error)?.name === 'AbortError';
|
||||
setUploadError(isAbort ? 'Cancelled' : msg);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setLoading(false);
|
||||
abortRef.current = null;
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return loading ? (
|
||||
<div className="flex flex-row items-center space-x-2 p-1 text-xs text-[#EA580C]">
|
||||
Uploading…
|
||||
</div>
|
||||
) : uploadError ? (
|
||||
<div className="p-1 text-xs text-red-500">
|
||||
{uploadError}
|
||||
<button type="button" onClick={() => setUploadError(null)} className="ml-1 underline">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<Popover className="max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="flex flex-row items-center justify-between space-x-1 p-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<File size={20} className="text-[#EA580C]" />
|
||||
</PopoverButton>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
className="absolute z-10 w-64 md:w-[350px] right-0 bottom-full mb-2"
|
||||
static
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-bottom-right bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col"
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||
<h4 className="text-black/70 dark:text-white/70 font-medium text-sm">
|
||||
Attached files
|
||||
</h4>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Plus size={16} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<Trash size={13} />
|
||||
<p className="text-xs">Clear</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||
<div className="flex flex-col items-center">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-9 h-9 rounded-md">
|
||||
<File
|
||||
size={16}
|
||||
className="text-black/70 dark:text-white/70"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{file.fileName.length > 25
|
||||
? file.fileName
|
||||
.replace(/\.\w+$/, '')
|
||||
.substring(0, 25) +
|
||||
'...' +
|
||||
file.fileExtension
|
||||
: file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white p-1"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachSmall;
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { Cpu, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { MinimalProvider } from '@/lib/types-ui';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
const ModelSelector = () => {
|
||||
const [providers, setProviders] = useState<MinimalProvider[]>([]);
|
||||
const [envOnlyMode, setEnvOnlyMode] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const { setChatModelProvider, chatModelProvider } = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await fetch('/api/providers');
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch providers');
|
||||
}
|
||||
|
||||
const data: { providers: MinimalProvider[]; envOnlyMode?: boolean } = await res.json();
|
||||
setProviders(data.providers);
|
||||
setEnvOnlyMode(data.envOnlyMode ?? false);
|
||||
} catch (error) {
|
||||
console.error('Error loading providers:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProviders();
|
||||
}, []);
|
||||
|
||||
const orderedProviders = useMemo(() => {
|
||||
if (!chatModelProvider?.providerId) return providers;
|
||||
|
||||
const currentProviderIndex = providers.findIndex(
|
||||
(p) => p.id === chatModelProvider.providerId,
|
||||
);
|
||||
|
||||
if (currentProviderIndex === -1) {
|
||||
return providers;
|
||||
}
|
||||
|
||||
const selectedProvider = providers[currentProviderIndex];
|
||||
const remainingProviders = providers.filter(
|
||||
(_, index) => index !== currentProviderIndex,
|
||||
);
|
||||
|
||||
return [selectedProvider, ...remainingProviders];
|
||||
}, [providers, chatModelProvider]);
|
||||
|
||||
const handleModelSelect = (providerId: string, modelKey: string) => {
|
||||
setChatModelProvider({ providerId, key: modelKey });
|
||||
localStorage.setItem('chatModelProviderId', providerId);
|
||||
localStorage.setItem('chatModelKey', modelKey);
|
||||
};
|
||||
|
||||
const filteredProviders = orderedProviders
|
||||
.map((provider) => ({
|
||||
...provider,
|
||||
chatModels: provider.chatModels.filter(
|
||||
(model) =>
|
||||
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
provider.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
),
|
||||
}))
|
||||
.filter((provider) => provider.chatModels.length > 0);
|
||||
|
||||
if (envOnlyMode) return null;
|
||||
|
||||
return (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<Cpu size={16} className="text-[#EA580C]" />
|
||||
</PopoverButton>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
className="absolute z-10 w-[230px] sm:w-[270px] md:w-[300px] right-0 bottom-full mb-2"
|
||||
static
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-bottom-right bg-light-primary dark:bg-dark-primary max-h-[300px] sm:max-w-none border rounded-lg border-light-200 dark:border-dark-200 w-full flex flex-col shadow-lg overflow-hidden"
|
||||
>
|
||||
<div className="p-2 border-b border-light-200 dark:border-dark-200">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-2 bg-light-secondary dark:bg-dark-secondary rounded-lg placeholder:text-xs placeholder:-translate-y-[1.5px] text-xs text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none border border-transparent transition duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[320px] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-2 py-16 px-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filteredProviders.length === 0 ? (
|
||||
<div className="text-center py-16 px-4 text-black/60 dark:text-white/60 text-sm">
|
||||
{searchQuery
|
||||
? 'No models found'
|
||||
: 'No chat models configured'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{filteredProviders.map((provider, providerIndex) => (
|
||||
<div key={provider.id}>
|
||||
<div className="px-4 py-2.5 sticky top-0 bg-light-primary dark:bg-dark-primary border-b border-light-200/50 dark:border-dark-200/50">
|
||||
<p className="text-xs text-black/50 dark:text-white/50 uppercase tracking-wider">
|
||||
{provider.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col px-2 py-2 space-y-0.5">
|
||||
{provider.chatModels.map((model) => (
|
||||
<button
|
||||
key={model.key}
|
||||
onClick={() =>
|
||||
handleModelSelect(provider.id, model.key)
|
||||
}
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-3 py-2 flex items-center justify-between text-start duration-200 cursor-pointer transition rounded-lg group',
|
||||
chatModelProvider?.providerId ===
|
||||
provider.id &&
|
||||
chatModelProvider?.key === model.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
|
||||
<Cpu
|
||||
size={15}
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
chatModelProvider?.providerId ===
|
||||
provider.id &&
|
||||
chatModelProvider?.key === model.key
|
||||
? 'text-[#EA580C]'
|
||||
: 'text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70',
|
||||
)}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs truncate',
|
||||
chatModelProvider?.providerId ===
|
||||
provider.id &&
|
||||
chatModelProvider?.key === model.key
|
||||
? 'text-[#EA580C] font-medium'
|
||||
: 'text-black/70 dark:text-white/70 group-hover:text-black dark:group-hover:text-white',
|
||||
)}
|
||||
>
|
||||
{model.name}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{providerIndex < filteredProviders.length - 1 && (
|
||||
<div className="h-px bg-light-200 dark:bg-dark-200" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSelector;
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Input bar «+» — меню: режимы, источники, Learn, Create, Model Council
|
||||
* docs/architecture: 01-perplexity-analogue-design.md §2.2.A
|
||||
*/
|
||||
|
||||
import { Plus, Zap, Sliders, Star, Globe, GraduationCap, Network, BookOpen, Users } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const MODES = [
|
||||
{ key: 'speed', label: 'Quick', icon: Zap },
|
||||
{ key: 'balanced', label: 'Pro', icon: Sliders },
|
||||
{ key: 'quality', label: 'Deep', icon: Star },
|
||||
] as const;
|
||||
|
||||
const SOURCES = [
|
||||
{ key: 'web', label: 'Web', icon: Globe },
|
||||
{ key: 'academic', label: 'Academic', icon: GraduationCap },
|
||||
{ key: 'discussions', label: 'Social', icon: Network },
|
||||
] as const;
|
||||
|
||||
const InputBarPlus = () => {
|
||||
const { optimizationMode, setOptimizationMode, sources, setSources } = useChat();
|
||||
const [learningMode, setLearningModeState] = useState(false);
|
||||
const [modelCouncil, setModelCouncilState] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLearningModeState(typeof window !== 'undefined' && localStorage.getItem('learningMode') === 'true');
|
||||
setModelCouncilState(typeof window !== 'undefined' && localStorage.getItem('modelCouncil') === 'true');
|
||||
const handler = () => {
|
||||
setLearningModeState(localStorage.getItem('learningMode') === 'true');
|
||||
setModelCouncilState(localStorage.getItem('modelCouncil') === 'true');
|
||||
};
|
||||
window.addEventListener('client-config-changed', handler);
|
||||
return () => window.removeEventListener('client-config-changed', handler);
|
||||
}, []);
|
||||
|
||||
const setLearningMode = useCallback((v: boolean) => {
|
||||
localStorage.setItem('learningMode', String(v));
|
||||
setLearningModeState(v);
|
||||
window.dispatchEvent(new Event('client-config-changed'));
|
||||
}, []);
|
||||
|
||||
const setModelCouncil = useCallback((v: boolean) => {
|
||||
localStorage.setItem('modelCouncil', String(v));
|
||||
setModelCouncilState(v);
|
||||
window.dispatchEvent(new Event('client-config-changed'));
|
||||
}, []);
|
||||
|
||||
const toggleSource = useCallback(
|
||||
(key: string) => {
|
||||
if (sources.includes(key)) {
|
||||
setSources(sources.filter((s) => s !== key));
|
||||
} else {
|
||||
setSources([...sources, key]);
|
||||
}
|
||||
},
|
||||
[sources, setSources]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton
|
||||
type="button"
|
||||
title="More options"
|
||||
className={cn(
|
||||
'p-2 rounded-xl transition duration-200 focus:outline-none',
|
||||
'text-black/50 dark:text-white/50 hover:text-black dark:hover:text-white',
|
||||
'hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95',
|
||||
open && 'bg-light-secondary dark:bg-dark-secondary text-[#EA580C]'
|
||||
)}
|
||||
>
|
||||
<Plus size={18} strokeWidth={2.5} />
|
||||
</PopoverButton>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
static
|
||||
className="absolute z-20 w-72 left-0 bottom-full mb-2"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="origin-bottom-left rounded-xl border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary shadow-xl overflow-hidden"
|
||||
>
|
||||
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
|
||||
<p className="text-xs font-medium text-black/60 dark:text-white/60 px-2 py-1">Mode</p>
|
||||
<div className="flex gap-1">
|
||||
{MODES.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = optimizationMode === m.key;
|
||||
return (
|
||||
<button
|
||||
key={m.key}
|
||||
type="button"
|
||||
onClick={() => setOptimizationMode(m.key)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-sm transition',
|
||||
isActive
|
||||
? 'bg-[#EA580C]/20 text-[#EA580C]'
|
||||
: 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
|
||||
)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{m.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
|
||||
<p className="text-xs font-medium text-black/60 dark:text-white/60 px-2 py-1">Sources</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{SOURCES.map((s) => {
|
||||
const Icon = s.icon;
|
||||
const checked = sources.includes(s.key);
|
||||
return (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
onClick={() => toggleSource(s.key)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 py-1.5 px-2.5 rounded-lg text-sm transition',
|
||||
checked
|
||||
? 'bg-[#EA580C]/20 text-[#EA580C]'
|
||||
: 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
|
||||
)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLearningMode(!learningMode)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 py-2 px-3 rounded-lg text-sm transition',
|
||||
learningMode ? 'bg-[#EA580C]/20 text-[#EA580C]' : 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
|
||||
)}
|
||||
>
|
||||
<BookOpen size={16} />
|
||||
Step-by-step Learning
|
||||
{learningMode && <span className="ml-auto text-xs">On</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModelCouncil(!modelCouncil)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 py-2 px-3 rounded-lg text-sm transition',
|
||||
modelCouncil ? 'bg-[#EA580C]/20 text-[#EA580C]' : 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
|
||||
)}
|
||||
title="Model Council (Max): 3 models in parallel → synthesis"
|
||||
>
|
||||
<Users size={16} />
|
||||
Model Council
|
||||
{modelCouncil && <span className="ml-auto text-xs">On</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<p className="text-xs font-medium text-black/60 dark:text-white/60 px-2 py-1">Create</p>
|
||||
<p className="text-xs text-black/50 dark:text-white/50 px-2 pb-2">
|
||||
Ask in chat: "Create a table about..." or "Generate an image of..."
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputBarPlus;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ChevronDown, Sliders, Star, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
const OptimizationModes = [
|
||||
{
|
||||
key: 'speed',
|
||||
title: 'Speed',
|
||||
description: 'Prioritize speed and get the quickest possible answer.',
|
||||
icon: <Zap size={16} className="text-[#EA580C]" />,
|
||||
},
|
||||
{
|
||||
key: 'balanced',
|
||||
title: 'Balanced',
|
||||
description: 'Find the right balance between speed and accuracy',
|
||||
icon: <Sliders size={16} className="text-[#EA580C]" />,
|
||||
},
|
||||
{
|
||||
key: 'quality',
|
||||
title: 'Quality',
|
||||
description: 'Get the most thorough and accurate answer',
|
||||
icon: (
|
||||
<Star
|
||||
size={16}
|
||||
className="text-[#EA580C] fill-[#EA580C]"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const Optimization = () => {
|
||||
const { optimizationMode, setOptimizationMode } = useChat();
|
||||
|
||||
return (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white focus:outline-none"
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{
|
||||
OptimizationModes.find((mode) => mode.key === optimizationMode)
|
||||
?.icon
|
||||
}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={cn(
|
||||
open ? 'rotate-180' : 'rotate-0',
|
||||
'transition duration:200',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PopoverButton>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
className="absolute z-10 w-64 md:w-[250px] left-0 bottom-full mb-2"
|
||||
static
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-bottom-left flex flex-col space-y-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-2 max-h-[200px] md:max-h-none overflow-y-auto"
|
||||
>
|
||||
{OptimizationModes.map((mode, i) => (
|
||||
<PopoverButton
|
||||
onClick={() => setOptimizationMode(mode.key)}
|
||||
key={i}
|
||||
className={cn(
|
||||
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition focus:outline-none',
|
||||
optimizationMode === mode.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row justify-between w-full text-black dark:text-white">
|
||||
<div className="flex flex-row space-x-1">
|
||||
{mode.icon}
|
||||
<p className="text-xs font-medium">{mode.title}</p>
|
||||
</div>
|
||||
{mode.key === 'quality' && (
|
||||
<span className="bg-[#EA580C]/70 dark:bg-[#EA580C]/40 border border-[#EA580C] px-1 rounded-full text-[10px] text-white">
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{mode.description}
|
||||
</p>
|
||||
</PopoverButton>
|
||||
))}
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default Optimization;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Switch,
|
||||
} from '@headlessui/react';
|
||||
import {
|
||||
GlobeIcon,
|
||||
GraduationCapIcon,
|
||||
NetworkIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
const sourcesList = [
|
||||
{
|
||||
name: 'Web',
|
||||
key: 'web',
|
||||
icon: <GlobeIcon className="h-[16px] w-auto" />,
|
||||
},
|
||||
{
|
||||
name: 'Academic',
|
||||
key: 'academic',
|
||||
icon: <GraduationCapIcon className="h-[16px] w-auto" />,
|
||||
},
|
||||
{
|
||||
name: 'Social',
|
||||
key: 'discussions',
|
||||
icon: <NetworkIcon className="h-[16px] w-auto" />,
|
||||
},
|
||||
];
|
||||
|
||||
const Sources = () => {
|
||||
const { sources, setSources } = useChat();
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton className="flex items-center justify-center active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white">
|
||||
<GlobeIcon className="h-[18px] w-auto" />
|
||||
</PopoverButton>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
static
|
||||
className="absolute z-10 w-64 md:w-[225px] right-0 bottom-full mb-2"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-bottom-right flex flex-col bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-1 max-h-[200px] md:max-h-none overflow-y-auto shadow-lg"
|
||||
>
|
||||
{sourcesList.map((source, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row justify-between hover:bg-light-100 hover:dark:bg-dark-100 rounded-md py-3 px-2 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (!sources.includes(source.key)) {
|
||||
setSources([...sources, source.key]);
|
||||
} else {
|
||||
setSources(sources.filter((s) => s !== source.key));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row space-x-1.5 text-black/80 dark:text-white/80">
|
||||
{source.icon}
|
||||
<p className="text-xs">{source.name}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sources.includes(source.key)}
|
||||
className="group relative flex h-4 w-7 shrink-0 cursor-pointer rounded-full bg-light-200 dark:bg-white/10 p-0.5 duration-200 ease-in-out focus:outline-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed data-[checked]:bg-[#EA580C] dark:data-[checked]:bg-[#EA580C]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none inline-block size-3 translate-x-[1px] group-data-[checked]:translate-x-3 rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out"
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sources;
|
||||
19
services/web-svc/src/components/MessageRenderer/Citation.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
const Citation = ({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default Citation;
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const darkTheme = {
|
||||
'hljs-comment': {
|
||||
color: '#8b949e',
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: '#8b949e',
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-tag': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-name': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-selector-id': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-selector-class': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-regexp': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-deletion': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-number': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-built_in': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-builtin-name': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-type': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-params': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-link': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: '#58a6ff',
|
||||
},
|
||||
'hljs-string': {
|
||||
color: '#7ee787',
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: '#7ee787',
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: '#7ee787',
|
||||
},
|
||||
'hljs-addition': {
|
||||
color: '#7ee787',
|
||||
},
|
||||
'hljs-title': {
|
||||
color: '#79c0ff',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: '#79c0ff',
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: '#c297ff',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: '#c297ff',
|
||||
},
|
||||
hljs: {
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
padding: '0.75em',
|
||||
border: '1px solid #21262d',
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'hljs-strong': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
} satisfies Record<string, CSSProperties>;
|
||||
|
||||
export default darkTheme;
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const lightTheme = {
|
||||
'hljs-comment': {
|
||||
color: '#6e7781',
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: '#6e7781',
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-tag': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-name': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-selector-id': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-selector-class': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-regexp': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-deletion': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-number': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-built_in': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-builtin-name': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-type': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-params': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-link': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: '#0a64ae',
|
||||
},
|
||||
'hljs-string': {
|
||||
color: '#22863a',
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: '#22863a',
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: '#22863a',
|
||||
},
|
||||
'hljs-addition': {
|
||||
color: '#22863a',
|
||||
},
|
||||
'hljs-title': {
|
||||
color: '#005cc5',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: '#005cc5',
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: '#6f42c1',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: '#6f42c1',
|
||||
},
|
||||
hljs: {
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
background: '#ffffff',
|
||||
color: '#24292f',
|
||||
padding: '0.75em',
|
||||
border: '1px solid #e8edf1',
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'hljs-strong': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
} satisfies Record<string, CSSProperties>;
|
||||
|
||||
export default lightTheme;
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { CheckIcon, CopyIcon } from '@phosphor-icons/react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import darkTheme from './CodeBlockDarkTheme';
|
||||
import lightTheme from './CodeBlockLightTheme';
|
||||
|
||||
const CodeBlock = ({
|
||||
language,
|
||||
children,
|
||||
}: {
|
||||
language: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const syntaxTheme = useMemo(() => {
|
||||
if (!mounted) return lightTheme;
|
||||
return resolvedTheme === 'dark' ? darkTheme : lightTheme;
|
||||
}, [mounted, resolvedTheme]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="absolute top-2 right-2 p-1"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(children as string);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon
|
||||
size={16}
|
||||
className="absolute top-2 right-2 text-black/70 dark:text-white/70"
|
||||
/>
|
||||
) : (
|
||||
<CopyIcon
|
||||
size={16}
|
||||
className="absolute top-2 right-2 transition duration-200 text-black/70 dark:text-white/70 hover:text-gray-800/70 hover:dark:text-gray-300/70"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={syntaxTheme}
|
||||
showInlineLineNumbers
|
||||
>
|
||||
{children as string}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeBlock;
|
||||
175
services/web-svc/src/components/MessageSources.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from '@headlessui/react';
|
||||
import { File } from 'lucide-react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Chunk } from '@/lib/types';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
|
||||
const MessageSources = ({ sources }: { sources: Chunk[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsDialogOpen(false);
|
||||
document.body.classList.remove('overflow-hidden-scrollable');
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsDialogOpen(true);
|
||||
document.body.classList.add('overflow-hidden-scrollable');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{sources.slice(0, 3).map((source, i) => {
|
||||
const url = source.metadata.url ?? '';
|
||||
const title = source.metadata.title ?? '';
|
||||
return (
|
||||
<a
|
||||
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
|
||||
key={i}
|
||||
href={url || '#'}
|
||||
target="_blank"
|
||||
>
|
||||
<p className="dark:text-white text-xs overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{url.includes('file_id://') ? (
|
||||
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full">
|
||||
<File size={12} className="text-white/70" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${url}`}
|
||||
width={16}
|
||||
height={16}
|
||||
alt="favicon"
|
||||
className="rounded-lg h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-black/50 dark:text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{url.includes('file_id://')
|
||||
? t('chat.uploadedFile')
|
||||
: url.replace(/.+\/\/|www.|\..+/g, '')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 text-xs">
|
||||
<div className="bg-black/50 dark:bg-white/50 h-[4px] w-[4px] rounded-full" />
|
||||
<span>{i + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
{sources.length > 3 && (
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{sources.slice(3, 6).map((source, i) => {
|
||||
const u = source.metadata.url ?? '';
|
||||
return u === 'File' ? (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full"
|
||||
>
|
||||
<File size={12} className="text-white/70" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
key={i}
|
||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${u}`}
|
||||
width={16}
|
||||
height={16}
|
||||
alt="favicon"
|
||||
className="rounded-lg h-4 w-4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-black/50 dark:text-white/50">
|
||||
{t('chat.viewMore').replace('{count}', String(sources.length - 3))}
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
<Transition appear show={isDialogOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={closeModal}>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-100"
|
||||
leaveFrom="opacity-100 scale-200"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle className="text-lg font-medium leading-6 dark:text-white">
|
||||
{t('chat.sources')}
|
||||
</DialogTitle>
|
||||
<div className="grid grid-cols-2 gap-2 overflow-auto max-h-[300px] mt-2 pr-2">
|
||||
{sources.map((source, i) => {
|
||||
const u = source.metadata.url ?? '';
|
||||
const tit = source.metadata.title ?? '';
|
||||
return (
|
||||
<a
|
||||
className="bg-light-secondary hover:bg-light-200 dark:bg-dark-secondary dark:hover:bg-dark-200 border border-light-200 dark:border-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
|
||||
key={i}
|
||||
href={u || '#'}
|
||||
target="_blank"
|
||||
>
|
||||
<p className="dark:text-white text-xs overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{tit}
|
||||
</p>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{u === 'File' ? (
|
||||
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full">
|
||||
<File size={12} className="text-white/70" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${u}`}
|
||||
width={16}
|
||||
height={16}
|
||||
alt="favicon"
|
||||
className="rounded-lg h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-black/50 dark:text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{u.replace(
|
||||
/.+\/\/|www.|\..+/g,
|
||||
'',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 text-xs">
|
||||
<div className="bg-black/50 dark:bg-white/50 h-[4px] w-[4px] rounded-full" />
|
||||
<span>{i + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
); })}
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageSources;
|
||||
403
services/web-svc/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import { Clock, Edit, Share, Trash, FileText, FileDown } from 'lucide-react';
|
||||
import { Message } from './ChatWindow';
|
||||
import { useEffect, useState, Fragment } from 'react';
|
||||
import { formatTimeDifference } from '@/lib/utils';
|
||||
import DeleteChat from './DeleteChat';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { useChat, Section } from '@/lib/hooks/useChat';
|
||||
import { SourceBlock } from '@/lib/types';
|
||||
|
||||
const downloadFile = (filename: string, content: string, type: string) => {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const serializeSections = (sections: Section[]) =>
|
||||
sections.map((s) => ({
|
||||
query: s.message.query,
|
||||
createdAt: s.message.createdAt,
|
||||
parsedTextBlocks: s.parsedTextBlocks,
|
||||
responseBlocks: s.message.responseBlocks,
|
||||
}));
|
||||
|
||||
const exportViaApi = async (
|
||||
format: 'pdf' | 'md',
|
||||
sections: Section[],
|
||||
title: string,
|
||||
chatId?: string
|
||||
) => {
|
||||
if (chatId) {
|
||||
const token =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
|
||||
: null;
|
||||
const res = await fetch(
|
||||
`/api/v1/library/threads/${encodeURIComponent(chatId)}/export?format=${format}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const disposition = res.headers.get('Content-Disposition');
|
||||
const match = disposition?.match(/filename="?([^";]+)"?/);
|
||||
const filename = match?.[1] ?? `${title || 'chat'}.${format}`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const res = await fetch('/api/v1/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
format,
|
||||
title: title || 'chat',
|
||||
sections: serializeSections(sections),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'chat'}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const exportAsMarkdown = (sections: Section[], title: string) => {
|
||||
const date = new Date(
|
||||
sections[0].message.createdAt || Date.now(),
|
||||
).toLocaleString();
|
||||
let md = `# 💬 Chat Export: ${title}\n\n`;
|
||||
md += `*Exported on: ${date}*\n\n---\n`;
|
||||
|
||||
sections.forEach((section, idx) => {
|
||||
md += `\n---\n`;
|
||||
md += `**🧑 User**
|
||||
`;
|
||||
md += `*${new Date(section.message.createdAt).toLocaleString()}*\n\n`;
|
||||
md += `> ${section.message.query.replace(/\n/g, '\n> ')}\n`;
|
||||
|
||||
if (section.message.responseBlocks.length > 0) {
|
||||
md += `\n---\n`;
|
||||
md += `**🤖 Assistant**
|
||||
`;
|
||||
md += `*${new Date(section.message.createdAt).toLocaleString()}*\n\n`;
|
||||
md += `> ${section.message.responseBlocks
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((block) => block.data)
|
||||
.join('\n')
|
||||
.replace(/\n/g, '\n> ')}\n`;
|
||||
}
|
||||
|
||||
const sourceResponseBlock = section.message.responseBlocks.find(
|
||||
(block) => block.type === 'source',
|
||||
) as SourceBlock | undefined;
|
||||
|
||||
if (
|
||||
sourceResponseBlock &&
|
||||
sourceResponseBlock.data &&
|
||||
sourceResponseBlock.data.length > 0
|
||||
) {
|
||||
md += `\n**Citations:**\n`;
|
||||
sourceResponseBlock.data.forEach((src: any, i: number) => {
|
||||
const url = src.metadata?.url || '';
|
||||
md += `- [${i + 1}] [${url}](${url})\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
md += '\n---\n';
|
||||
downloadFile(`${title || 'chat'}.md`, md, 'text/markdown');
|
||||
};
|
||||
|
||||
const exportAsPDF = async (sections: Section[], title: string) => {
|
||||
const { default: jsPDF } = await import('jspdf');
|
||||
const doc = new jsPDF();
|
||||
const date = new Date(
|
||||
sections[0]?.message?.createdAt || Date.now(),
|
||||
).toLocaleString();
|
||||
let y = 15;
|
||||
const pageHeight = doc.internal.pageSize.height;
|
||||
doc.setFontSize(18);
|
||||
doc.text(`Chat Export: ${title}`, 10, y);
|
||||
y += 8;
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(100);
|
||||
doc.text(`Exported on: ${date}`, 10, y);
|
||||
y += 8;
|
||||
doc.setDrawColor(200);
|
||||
doc.line(10, y, 200, y);
|
||||
y += 6;
|
||||
doc.setTextColor(30);
|
||||
|
||||
sections.forEach((section, idx) => {
|
||||
if (y > pageHeight - 30) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('User', 10, y);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(120);
|
||||
doc.text(`${new Date(section.message.createdAt).toLocaleString()}`, 40, y);
|
||||
y += 6;
|
||||
doc.setTextColor(30);
|
||||
doc.setFontSize(12);
|
||||
const userLines = doc.splitTextToSize(section.message.query, 180);
|
||||
for (let i = 0; i < userLines.length; i++) {
|
||||
if (y > pageHeight - 20) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.text(userLines[i], 12, y);
|
||||
y += 6;
|
||||
}
|
||||
y += 6;
|
||||
doc.setDrawColor(230);
|
||||
if (y > pageHeight - 10) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.line(10, y, 200, y);
|
||||
y += 4;
|
||||
|
||||
if (section.message.responseBlocks.length > 0) {
|
||||
if (y > pageHeight - 30) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Assistant', 10, y);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(120);
|
||||
doc.text(
|
||||
`${new Date(section.message.createdAt).toLocaleString()}`,
|
||||
40,
|
||||
y,
|
||||
);
|
||||
y += 6;
|
||||
doc.setTextColor(30);
|
||||
doc.setFontSize(12);
|
||||
const assistantLines = doc.splitTextToSize(
|
||||
section.parsedTextBlocks.join('\n'),
|
||||
180,
|
||||
);
|
||||
for (let i = 0; i < assistantLines.length; i++) {
|
||||
if (y > pageHeight - 20) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.text(assistantLines[i], 12, y);
|
||||
y += 6;
|
||||
}
|
||||
|
||||
const sourceResponseBlock = section.message.responseBlocks.find(
|
||||
(block) => block.type === 'source',
|
||||
) as SourceBlock | undefined;
|
||||
|
||||
if (
|
||||
sourceResponseBlock &&
|
||||
sourceResponseBlock.data &&
|
||||
sourceResponseBlock.data.length > 0
|
||||
) {
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(80);
|
||||
if (y > pageHeight - 20) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.text('Citations:', 12, y);
|
||||
y += 5;
|
||||
sourceResponseBlock.data.forEach((src: any, i: number) => {
|
||||
const url = src.metadata?.url || '';
|
||||
if (y > pageHeight - 15) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.text(`- [${i + 1}] ${url}`, 15, y);
|
||||
y += 5;
|
||||
});
|
||||
doc.setTextColor(30);
|
||||
}
|
||||
y += 6;
|
||||
doc.setDrawColor(230);
|
||||
if (y > pageHeight - 10) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.line(10, y, 200, y);
|
||||
y += 4;
|
||||
}
|
||||
});
|
||||
doc.save(`${title || 'chat'}.pdf`);
|
||||
};
|
||||
|
||||
const Navbar = () => {
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [timeAgo, setTimeAgo] = useState<string>('');
|
||||
|
||||
const { sections, chatId } = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
if (sections.length > 0 && sections[0].message) {
|
||||
const newTitle =
|
||||
sections[0].message.query.length > 30
|
||||
? `${sections[0].message.query.substring(0, 30).trim()}...`
|
||||
: sections[0].message.query || 'New Conversation';
|
||||
|
||||
setTitle(newTitle);
|
||||
const newTimeAgo = formatTimeDifference(
|
||||
new Date(),
|
||||
sections[0].message.createdAt,
|
||||
);
|
||||
setTimeAgo(newTimeAgo);
|
||||
}
|
||||
}, [sections]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
if (sections.length > 0 && sections[0].message) {
|
||||
const newTimeAgo = formatTimeDifference(
|
||||
new Date(),
|
||||
sections[0].message.createdAt,
|
||||
);
|
||||
setTimeAgo(newTimeAgo);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="sticky -mx-4 lg:mx-0 top-0 z-40 bg-light-primary/95 dark:bg-dark-primary/95 backdrop-blur-sm border-b border-light-200/50 dark:border-dark-200/30">
|
||||
<div className="px-4 lg:px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center min-w-0">
|
||||
<a
|
||||
href="/"
|
||||
className="lg:hidden mr-3 p-2 -ml-2 rounded-lg hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
|
||||
>
|
||||
<Edit size={18} className="text-black/70 dark:text-white/70" />
|
||||
</a>
|
||||
<div className="hidden lg:flex items-center gap-2 text-black/50 dark:text-white/50 min-w-0">
|
||||
<Clock size={14} />
|
||||
<span className="text-xs whitespace-nowrap">{timeAgo} ago</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 mx-4 min-w-0">
|
||||
<h1 className="text-center text-sm font-medium text-black/80 dark:text-white/90 truncate">
|
||||
{title || 'New Conversation'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="p-2 rounded-lg hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200">
|
||||
<Share size={16} className="text-black/60 dark:text-white/60" />
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute right-0 mt-2 w-64 origin-top-right rounded-2xl bg-light-primary dark:bg-dark-primary border border-light-200 dark:border-dark-200 shadow-xl shadow-black/10 dark:shadow-black/30 z-50">
|
||||
<div className="p-3">
|
||||
<div className="mb-2">
|
||||
<p className="text-xs font-medium text-black/40 dark:text-white/40 uppercase tracking-wide">
|
||||
Export Chat
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
|
||||
onClick={async () => {
|
||||
const ok = await exportViaApi('md', sections, title || '', chatId);
|
||||
if (!ok) exportAsMarkdown(sections, title || '');
|
||||
}}
|
||||
>
|
||||
<FileText size={16} className="text-[#EA580C]" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-black dark:text-white">
|
||||
Markdown
|
||||
</p>
|
||||
<p className="text-xs text-black/50 dark:text-white/50">
|
||||
.md format
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
|
||||
onClick={async () => {
|
||||
const ok = await exportViaApi('pdf', sections, title || '', chatId);
|
||||
if (!ok) void exportAsPDF(sections, title || '');
|
||||
}}
|
||||
>
|
||||
<FileDown size={16} className="text-[#EA580C]" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-black dark:text-white">
|
||||
PDF
|
||||
</p>
|
||||
<p className="text-xs text-black/50 dark:text-white/50">
|
||||
Document format
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
<DeleteChat
|
||||
redirect
|
||||
chatId={chatId!}
|
||||
chats={[]}
|
||||
setChats={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
71
services/web-svc/src/components/NewsArticleWidget.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Article {
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
const NewsArticleWidget = () => {
|
||||
const [article, setArticle] = useState<Article | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/discover?mode=preview')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const articles = (data.blogs || []).filter((a: Article) => a.thumbnail);
|
||||
setArticle(articles[Math.floor(Math.random() * articles.length)]);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-light-secondary dark:bg-dark-secondary rounded-2xl border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-row items-stretch w-full h-20 min-h-[80px] max-h-[80px] p-0 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="animate-pulse flex flex-row items-stretch w-full h-full">
|
||||
<div className="w-20 min-w-20 max-w-20 h-full bg-light-200 dark:bg-dark-200" />
|
||||
<div className="flex flex-col justify-center flex-1 px-2.5 py-1.5 gap-1">
|
||||
<div className="h-3 w-full max-w-[80%] rounded bg-light-200 dark:bg-dark-200" />
|
||||
<div className="h-3 w-16 rounded bg-light-200 dark:bg-dark-200" />
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="w-full text-xs text-red-400">Could not load news.</div>
|
||||
) : article ? (
|
||||
<a
|
||||
href={`/?q=${encodeURIComponent(`Summary: ${article.url}`)}&title=${encodeURIComponent(article.title)}`}
|
||||
className="flex flex-row items-stretch w-full h-full relative overflow-hidden group"
|
||||
>
|
||||
<div className="relative w-20 min-w-20 max-w-20 h-full overflow-hidden">
|
||||
<img
|
||||
className="object-cover w-full h-full bg-light-200 dark:bg-dark-200 group-hover:scale-110 transition-transform duration-300"
|
||||
src={
|
||||
new URL(article.thumbnail).origin +
|
||||
new URL(article.thumbnail).pathname +
|
||||
`?id=${new URL(article.thumbnail).searchParams.get('id')}`
|
||||
}
|
||||
alt={article.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center flex-1 px-2.5 py-1.5 min-w-0">
|
||||
<div className="font-semibold text-[11px] text-black dark:text-white leading-tight line-clamp-2 mb-0.5">
|
||||
{article.title}
|
||||
</div>
|
||||
<p className="text-black/60 dark:text-white/60 text-[9px] leading-relaxed line-clamp-2">
|
||||
{article.content}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsArticleWidget;
|
||||
165
services/web-svc/src/components/SearchImages.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { ImagesIcon, PlusIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Lightbox from 'yet-another-react-lightbox';
|
||||
import 'yet-another-react-lightbox/styles.css';
|
||||
import { Message } from './ChatWindow';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
|
||||
type Image = {
|
||||
url: string;
|
||||
img_src: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const MEDIA_TIMEOUT_MS = 15000; // docs/architecture: 05-gaps-and-best-practices.md §8
|
||||
|
||||
const SearchImages = ({
|
||||
query,
|
||||
chatHistory,
|
||||
messageId,
|
||||
}: {
|
||||
query: string;
|
||||
chatHistory: [string, string][];
|
||||
messageId: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [images, setImages] = useState<Image[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [slides, setSlides] = useState<any[]>([]);
|
||||
|
||||
const searchImages = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/images`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
chatHistory,
|
||||
chatModel: {
|
||||
providerId: localStorage.getItem('chatModelProviderId'),
|
||||
key: localStorage.getItem('chatModelKey'),
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(MEDIA_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
const imgs = data.images ?? [];
|
||||
setImages(imgs);
|
||||
setSlides(imgs.map((img: Image) => ({ src: img.img_src })));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Search failed';
|
||||
const isTimeout = msg.includes('timeout') || (err as Error)?.name === 'AbortError';
|
||||
setError(isTimeout ? 'Request timed out. Try again.' : msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loading && images === null && !error && (
|
||||
<button
|
||||
id={`search-images-${messageId}`}
|
||||
onClick={searchImages}
|
||||
className="border border-dashed border-light-200 dark:border-dark-200 hover:bg-light-200 dark:hover:bg-dark-200 active:scale-95 duration-200 transition px-4 py-2 flex flex-row items-center justify-between rounded-lg dark:text-white text-sm w-full"
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<ImagesIcon size={17} />
|
||||
<p>{t('chat.searchImages')}</p>
|
||||
</div>
|
||||
<PlusIcon className="text-[#EA580C]" size={17} />
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 dark:border-red-500/30 bg-light-secondary dark:bg-dark-secondary p-3 text-sm">
|
||||
<p className="text-red-500 mb-2">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={searchImages}
|
||||
className="text-[#EA580C] hover:underline text-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-light-secondary dark:bg-dark-secondary h-32 w-full rounded-lg animate-pulse aspect-video object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{images !== null && images.length > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{images.length > 4
|
||||
? images.slice(0, 3).map((image, i) => (
|
||||
<img
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
setSlides([
|
||||
slides[i],
|
||||
...slides.slice(0, i),
|
||||
...slides.slice(i + 1),
|
||||
]);
|
||||
}}
|
||||
key={i}
|
||||
src={image.img_src}
|
||||
alt={image.title}
|
||||
className="h-full w-full aspect-video object-cover rounded-lg transition duration-200 active:scale-95 hover:scale-[1.02] cursor-zoom-in"
|
||||
/>
|
||||
))
|
||||
: images.map((image, i) => (
|
||||
<img
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
setSlides([
|
||||
slides[i],
|
||||
...slides.slice(0, i),
|
||||
...slides.slice(i + 1),
|
||||
]);
|
||||
}}
|
||||
key={i}
|
||||
src={image.img_src}
|
||||
alt={image.title}
|
||||
className="h-full w-full aspect-video object-cover rounded-lg transition duration-200 active:scale-95 hover:scale-[1.02] cursor-zoom-in"
|
||||
/>
|
||||
))}
|
||||
{images.length > 4 && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 active:scale-95 hover:scale-[1.02] h-auto w-full rounded-lg flex flex-col justify-between text-white p-2"
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{images.slice(3, 6).map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.img_src}
|
||||
alt={image.title}
|
||||
className="h-6 w-12 rounded-md lg:h-3 lg:w-6 lg:rounded-sm aspect-video object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
View {images.length - 3} more
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Lightbox open={open} close={() => setOpen(false)} slides={slides} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchImages;
|
||||