aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend-vanilla/src')
-rw-r--r--frontend-vanilla/src/api.ts29
-rw-r--r--frontend-vanilla/src/components/FeedItem.test.ts23
-rw-r--r--frontend-vanilla/src/components/FeedItem.ts11
-rw-r--r--frontend-vanilla/src/counter.ts9
-rw-r--r--frontend-vanilla/src/main.ts241
-rw-r--r--frontend-vanilla/src/router.ts40
-rw-r--r--frontend-vanilla/src/setupTests.ts1
-rw-r--r--frontend-vanilla/src/store.test.ts48
-rw-r--r--frontend-vanilla/src/store.ts41
-rw-r--r--frontend-vanilla/src/style.css240
-rw-r--r--frontend-vanilla/src/types.ts24
-rw-r--r--frontend-vanilla/src/typescript.svg1
12 files changed, 613 insertions, 95 deletions
diff --git a/frontend-vanilla/src/api.ts b/frontend-vanilla/src/api.ts
new file mode 100644
index 0000000..c32299d
--- /dev/null
+++ b/frontend-vanilla/src/api.ts
@@ -0,0 +1,29 @@
+export function getCookie(name: string): string | undefined {
+ const value = `; ${document.cookie}`;
+ const parts = value.split(`; ${name}=`);
+ if (parts.length === 2) return parts.pop()?.split(';').shift();
+}
+
+/**
+ * A wrapper around fetch that automatically includes the CSRF token
+ * for state-changing requests (POST, PUT, DELETE).
+ */
+export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
+ const method = init?.method?.toUpperCase() || 'GET';
+ const isStateChanging = ['POST', 'PUT', 'DELETE'].includes(method);
+
+ const headers = new Headers(init?.headers || {});
+
+ if (isStateChanging) {
+ const token = getCookie('csrf_token');
+ if (token) {
+ headers.set('X-CSRF-Token', token);
+ }
+ }
+
+ return fetch(input, {
+ ...init,
+ headers,
+ credentials: 'include', // Ensure cookies are sent
+ });
+}
diff --git a/frontend-vanilla/src/components/FeedItem.test.ts b/frontend-vanilla/src/components/FeedItem.test.ts
new file mode 100644
index 0000000..708a871
--- /dev/null
+++ b/frontend-vanilla/src/components/FeedItem.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect } from 'vitest';
+import { createFeedItem } from './FeedItem';
+
+describe('FeedItem Component', () => {
+ const mockFeed = { _id: 1, title: 'My Feed', url: 'http://test', web_url: 'http://test', category: 'tag' };
+
+ it('should render a feed item correctly', () => {
+ const html = createFeedItem(mockFeed, false);
+ expect(html).toContain('My Feed');
+ expect(html).toContain('data-id="1"');
+ expect(html).not.toContain('active');
+ });
+
+ it('should apply active class when isActive is true', () => {
+ const html = createFeedItem(mockFeed, true);
+ expect(html).toContain('active');
+ });
+
+ it('should fallback to URL if title is missing', () => {
+ const html = createFeedItem({ ...mockFeed, title: '' }, false);
+ expect(html).toContain('http://test');
+ });
+});
diff --git a/frontend-vanilla/src/components/FeedItem.ts b/frontend-vanilla/src/components/FeedItem.ts
new file mode 100644
index 0000000..3bf72c2
--- /dev/null
+++ b/frontend-vanilla/src/components/FeedItem.ts
@@ -0,0 +1,11 @@
+import type { Feed } from '../types';
+
+export function createFeedItem(feed: Feed, isActive: boolean): string {
+ return `
+ <li class="feed-item ${isActive ? 'active' : ''}" data-id="${feed._id}">
+ <a href="/v3/feed/${feed._id}" class="feed-link" onclick="event.preventDefault(); window.app.navigate('/feed/${feed._id}')">
+ ${feed.title || feed.url}
+ </a>
+ </li>
+ `;
+}
diff --git a/frontend-vanilla/src/counter.ts b/frontend-vanilla/src/counter.ts
deleted file mode 100644
index 09e5afd..0000000
--- a/frontend-vanilla/src/counter.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export function setupCounter(element: HTMLButtonElement) {
- let counter = 0
- const setCounter = (count: number) => {
- counter = count
- element.innerHTML = `count is ${counter}`
- }
- element.addEventListener('click', () => setCounter(counter + 1))
- setCounter(0)
-}
diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts
index 6396b50..6846a67 100644
--- a/frontend-vanilla/src/main.ts
+++ b/frontend-vanilla/src/main.ts
@@ -1,24 +1,221 @@
-import './style.css'
-import typescriptLogo from './typescript.svg'
-import viteLogo from '/vite.svg'
-import { setupCounter } from './counter.ts'
-
-document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
- <div>
- <a href="https://vite.dev" target="_blank">
- <img src="${viteLogo}" class="logo" alt="Vite logo" />
- </a>
- <a href="https://www.typescriptlang.org/" target="_blank">
- <img src="${typescriptLogo}" class="logo vanilla" alt="TypeScript logo" />
- </a>
- <h1>Vite + TypeScript</h1>
- <div class="card">
- <button id="counter" type="button"></button>
- </div>
- <p class="read-the-docs">
- Click on the Vite and TypeScript logos to learn more
- </p>
+import './style.css';
+import { apiFetch } from './api';
+import { store } from './store';
+import { router } from './router';
+import type { Feed, Item } from './types';
+import { createFeedItem } from './components/FeedItem';
+
+// Cache elements
+const appEl = document.querySelector<HTMLDivElement>('#app')!;
+
+// Initial Layout
+appEl.innerHTML = `
+ <div class="layout">
+ <aside class="sidebar">
+ <div class="sidebar-header">
+ <h2>Neko v3</h2>
+ </div>
+ <ul id="feed-list" class="feed-list"></ul>
+ </aside>
+ <section class="item-list-pane">
+ <header class="top-bar">
+ <h1 id="view-title">All Items</h1>
+ </header>
+ <div id="item-list-container" class="item-list-container"></div>
+ </section>
+ <main class="item-detail-pane">
+ <div id="item-detail-content" class="item-detail-content">
+ <div class="empty-state">Select an item to read</div>
+ </div>
+ </main>
</div>
-`
+`;
+
+const feedListEl = document.getElementById('feed-list')!;
+const viewTitleEl = document.getElementById('view-title')!;
+const itemListEl = document.getElementById('item-list-container')!;
+const itemDetailEl = document.getElementById('item-detail-content')!;
+
+// --- Rendering Functions ---
+
+function renderFeeds() {
+ const { feeds, activeFeedId } = store;
+ feedListEl.innerHTML = feeds.map((feed: Feed) =>
+ createFeedItem(feed, feed._id === activeFeedId)
+ ).join('');
+}
+
+function renderItems() {
+ const { items, loading } = store;
+
+ if (loading) {
+ itemListEl.innerHTML = '<p class="loading">Loading items...</p>';
+ return;
+ }
+
+ if (items.length === 0) {
+ itemListEl.innerHTML = '<p class="empty">No items found.</p>';
+ return;
+ }
+
+ itemListEl.innerHTML = `
+ <ul class="item-list">
+ ${items.map((item: Item) => `
+ <li class="item-row ${item.read ? 'read' : ''}" data-id="${item._id}">
+ <div class="item-title">${item.title}</div>
+ <div class="item-meta">${item.feed_title || ''}</div>
+ </li>
+ `).join('')}
+ </ul>
+ `;
+
+ // Add click listeners to items
+ itemListEl.querySelectorAll('.item-row').forEach(row => {
+ row.addEventListener('click', () => {
+ const id = parseInt(row.getAttribute('data-id') || '0');
+ selectItem(id);
+ });
+ });
+}
+
+async function selectItem(id: number) {
+ const item = store.items.find((i: Item) => i._id === id);
+ if (!item) return;
+
+ // Mark active row
+ itemListEl.querySelectorAll('.item-row').forEach(row => {
+ row.classList.toggle('active', parseInt(row.getAttribute('data-id') || '0') === id);
+ });
+
+ // Render basic detail
+ itemDetailEl.innerHTML = `
+ <article class="item-detail">
+ <header>
+ <h1><a href="${item.url}" target="_blank">${item.title}</a></h1>
+ <div class="item-meta">
+ From ${item.feed_title || 'Unknown'} on ${new Date(item.publish_date).toLocaleString()}
+ </div>
+ </header>
+ <div id="full-content" class="full-content">
+ ${item.description || 'No description available.'}
+ </div>
+ </article>
+ `;
+
+ // Mark as read if not already
+ if (!item.read) {
+ try {
+ await apiFetch(`/api/item/${item._id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ read: true })
+ });
+ item.read = true;
+ const row = itemListEl.querySelector(`.item-row[data-id="${id}"]`);
+ if (row) row.classList.add('read');
+ } catch (err) {
+ console.error('Failed to mark as read', err);
+ }
+ }
+
+ // Fetch full content if missing
+ if (item.url && (!item.full_content || item.full_content === item.description)) {
+ try {
+ const res = await apiFetch(`/api/item/${item._id}/content`);
+ if (res.ok) {
+ const data = await res.json();
+ if (data.full_content) {
+ item.full_content = data.full_content;
+ const contentEl = document.getElementById('full-content');
+ if (contentEl) contentEl.innerHTML = data.full_content;
+ }
+ }
+ } catch (err) {
+ console.error('Failed to fetch full content', err);
+ }
+ }
+}
+
+// --- Data Actions ---
+
+async function fetchFeeds() {
+ try {
+ const res = await apiFetch('/api/feed/');
+ if (!res.ok) throw new Error('Failed to fetch feeds');
+ const feeds = await res.json();
+ store.setFeeds(feeds);
+ } catch (err) {
+ console.error(err);
+ }
+}
+
+async function fetchItems(feedId?: string, tagName?: string) {
+ store.setLoading(true);
+ try {
+ let url = '/api/stream';
+ const params = new URLSearchParams();
+ if (feedId) params.append('feed_id', feedId);
+ if (tagName) params.append('tag', tagName);
+
+ const res = await apiFetch(`${url}?${params.toString()}`);
+ if (!res.ok) throw new Error('Failed to fetch items');
+ const items = await res.json();
+ store.setItems(items);
+ itemDetailEl.innerHTML = '<div class="empty-state">Select an item to read</div>';
+ } catch (err) {
+ console.error(err);
+ store.setItems([]);
+ } finally {
+ store.setLoading(false);
+ }
+}
+
+// --- App Logic ---
+
+function handleRoute() {
+ const route = router.getCurrentRoute();
+
+ if (route.path === '/feed' && route.params.feedId) {
+ const id = parseInt(route.params.feedId);
+ store.setActiveFeed(id);
+ const feed = store.feeds.find((f: Feed) => f._id === id);
+ viewTitleEl.textContent = feed ? feed.title : `Feed ${id}`;
+ fetchItems(route.params.feedId);
+ } else if (route.path === '/tag' && route.params.tagName) {
+ store.setActiveFeed(null);
+ viewTitleEl.textContent = `Tag: ${route.params.tagName}`;
+ fetchItems(undefined, route.params.tagName);
+ } else {
+ store.setActiveFeed(null);
+ viewTitleEl.textContent = 'All Items';
+ fetchItems();
+ }
+}
+
+// Subscribe to store
+store.on('feeds-updated', renderFeeds);
+store.on('active-feed-updated', renderFeeds);
+store.on('items-updated', renderItems);
+store.on('loading-state-changed', renderItems);
+
+// Subscribe to router
+router.addEventListener('route-changed', handleRoute);
+
+// Global app object for inline handlers
+(window as any).app = {
+ navigate: (path: string) => router.navigate(path)
+};
+
+// Start
+async function init() {
+ const authRes = await apiFetch('/api/auth');
+ if (authRes.status === 401) {
+ window.location.href = '/login/';
+ return;
+ }
+
+ await fetchFeeds();
+ handleRoute(); // handles initial route
+}
-setupCounter(document.querySelector<HTMLButtonElement>('#counter')!)
+init();
diff --git a/frontend-vanilla/src/router.ts b/frontend-vanilla/src/router.ts
new file mode 100644
index 0000000..08a9e02
--- /dev/null
+++ b/frontend-vanilla/src/router.ts
@@ -0,0 +1,40 @@
+export type Route = {
+ path: string;
+ params: Record<string, string>;
+};
+
+export class Router extends EventTarget {
+ constructor() {
+ super();
+ window.addEventListener('popstate', () => this.handleRouteChange());
+ }
+
+ private handleRouteChange() {
+ this.dispatchEvent(new CustomEvent('route-changed', { detail: this.getCurrentRoute() }));
+ }
+
+ getCurrentRoute(): Route {
+ const path = window.location.pathname.replace(/^\/v3\//, '');
+ const segments = path.split('/').filter(Boolean);
+
+ let routePath = '/';
+ const params: Record<string, string> = {};
+
+ if (segments[0] === 'feed' && segments[1]) {
+ routePath = '/feed';
+ params.feedId = segments[1];
+ } else if (segments[0] === 'tag' && segments[1]) {
+ routePath = '/tag';
+ params.tagName = segments[1];
+ }
+
+ return { path: routePath, params };
+ }
+
+ navigate(path: string) {
+ window.history.pushState({}, '', `/v3${path}`);
+ this.handleRouteChange();
+ }
+}
+
+export const router = new Router();
diff --git a/frontend-vanilla/src/setupTests.ts b/frontend-vanilla/src/setupTests.ts
new file mode 100644
index 0000000..7b0828b
--- /dev/null
+++ b/frontend-vanilla/src/setupTests.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/frontend-vanilla/src/store.test.ts b/frontend-vanilla/src/store.test.ts
new file mode 100644
index 0000000..688e43e
--- /dev/null
+++ b/frontend-vanilla/src/store.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect, vi } from 'vitest';
+import { Store } from './store';
+
+describe('Store', () => {
+ it('should store and notify about feeds', () => {
+ const store = new Store();
+ const mockFeeds = [
+ { _id: 1, title: 'Feed 1', url: 'http://1', web_url: 'http://1', category: 'cat' }
+ ];
+
+ const callback = vi.fn();
+ store.addEventListener('feeds-updated', callback);
+
+ store.setFeeds(mockFeeds);
+
+ expect(store.feeds).toEqual(mockFeeds);
+ expect(callback).toHaveBeenCalled();
+ });
+
+ it('should handle items and loading state', () => {
+ const store = new Store();
+ const mockItems = [{ _id: 1, title: 'Item 1' } as any];
+
+ const itemCallback = vi.fn();
+ const loadingCallback = vi.fn();
+
+ store.addEventListener('items-updated', itemCallback);
+ store.addEventListener('loading-state-changed', loadingCallback);
+
+ store.setLoading(true);
+ expect(store.loading).toBe(true);
+ expect(loadingCallback).toHaveBeenCalled();
+
+ store.setItems(mockItems);
+ expect(store.items).toEqual(mockItems);
+ expect(itemCallback).toHaveBeenCalled();
+ });
+
+ it('should notify when active feed changes', () => {
+ const store = new Store();
+ const callback = vi.fn();
+ store.addEventListener('active-feed-updated', callback);
+
+ store.setActiveFeed(123);
+ expect(store.activeFeedId).toBe(123);
+ expect(callback).toHaveBeenCalled();
+ });
+});
diff --git a/frontend-vanilla/src/store.ts b/frontend-vanilla/src/store.ts
new file mode 100644
index 0000000..d274c5d
--- /dev/null
+++ b/frontend-vanilla/src/store.ts
@@ -0,0 +1,41 @@
+import type { Feed, Item } from './types.ts';
+
+export type StoreEvent = 'feeds-updated' | 'items-updated' | 'active-feed-updated' | 'loading-state-changed';
+
+export class Store extends EventTarget {
+ feeds: Feed[] = [];
+ items: Item[] = [];
+ activeFeedId: number | null = null;
+ loading: boolean = false;
+
+ setFeeds(feeds: Feed[]) {
+ this.feeds = feeds;
+ this.emit('feeds-updated');
+ }
+
+ setItems(items: Item[]) {
+ this.items = items;
+ this.emit('items-updated');
+ }
+
+ setActiveFeed(id: number | null) {
+ this.activeFeedId = id;
+ this.emit('active-feed-updated');
+ }
+
+ setLoading(loading: boolean) {
+ this.loading = loading;
+ this.emit('loading-state-changed');
+ }
+
+ private emit(type: StoreEvent, detail?: any) {
+ this.dispatchEvent(new CustomEvent(type, { detail }));
+ }
+
+ // Helper to add typed listeners
+ on(type: StoreEvent, callback: (e: CustomEvent) => void) {
+ this.addEventListener(type, callback as EventListener);
+ }
+}
+
+export const store = new Store();
diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css
index 3bcdbd0..a9c1c61 100644
--- a/frontend-vanilla/src/style.css
+++ b/frontend-vanilla/src/style.css
@@ -1,96 +1,210 @@
:root {
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
+ --bg-color: #ffffff;
+ --text-color: #213547;
+ --sidebar-bg: #f8f9fa;
+ --border-color: #e9ecef;
+ --accent-color: #007bff;
+ --hover-color: #e2e6ea;
+ --sidebar-width: 250px;
+ --item-list-width: 350px;
+}
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg-color: #1a1a1a;
+ --text-color: #e9ecef;
+ --sidebar-bg: #2d2d2d;
+ --border-color: #444;
+ --accent-color: #375a7f;
+ --hover-color: #3e3e3e;
+ }
}
-a {
- font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
+body {
+ margin: 0;
+ color: var(--text-color);
+ background-color: var(--bg-color);
+ height: 100vh;
+ overflow: hidden;
}
-a:hover {
- color: #535bf2;
+
+#app {
+ height: 100%;
}
-body {
+.layout {
+ display: flex;
+ height: 100%;
+}
+
+/* Sidebar */
+.sidebar {
+ width: var(--sidebar-width);
+ background-color: var(--sidebar-bg);
+ border-right: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+}
+
+.sidebar-header {
+ padding: 1rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.sidebar-header h2 {
margin: 0;
+ font-size: 1.1rem;
+}
+
+.feed-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.feed-link {
+ display: block;
+ padding: 0.5rem 1rem;
+ text-decoration: none;
+ color: var(--text-color);
+ font-size: 0.9rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.feed-item:hover {
+ background-color: var(--hover-color);
+}
+
+.feed-item.active {
+ background-color: var(--hover-color);
+ font-weight: bold;
+}
+
+/* Item List Pane */
+.item-list-pane {
+ width: var(--item-list-width);
+ border-right: 1px solid var(--border-color);
display: flex;
- place-items: center;
- min-width: 320px;
- min-height: 100vh;
+ flex-direction: column;
+ background-color: var(--bg-color);
}
-h1 {
- font-size: 3.2em;
- line-height: 1.1;
+.top-bar {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border-color);
}
-#app {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- text-align: center;
+.top-bar h1 {
+ margin: 0;
+ font-size: 1rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-.logo {
- height: 6em;
- padding: 1.5em;
- will-change: filter;
- transition: filter 300ms;
+.item-list-container {
+ flex: 1;
+ overflow-y: auto;
}
-.logo:hover {
- filter: drop-shadow(0 0 2em #646cffaa);
+
+.item-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.item-row {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.item-row:hover {
+ background-color: var(--hover-color);
+}
+
+.item-row.active {
+ background-color: var(--hover-color);
+ border-left: 3px solid var(--accent-color);
}
-.logo.vanilla:hover {
- filter: drop-shadow(0 0 2em #3178c6aa);
+
+.item-row.read {
+ opacity: 0.6;
}
-.card {
- padding: 2em;
+.item-title {
+ font-weight: 600;
+ font-size: 0.95rem;
+ margin-bottom: 0.2rem;
+ line-height: 1.3;
}
-.read-the-docs {
+.item-meta {
+ font-size: 0.8rem;
color: #888;
}
-button {
- border-radius: 8px;
- border: 1px solid transparent;
- padding: 0.6em 1.2em;
- font-size: 1em;
- font-weight: 500;
- font-family: inherit;
- background-color: #1a1a1a;
- cursor: pointer;
- transition: border-color 0.25s;
+/* Item Detail Pane */
+.item-detail-pane {
+ flex: 1;
+ overflow-y: auto;
+ background-color: var(--bg-color);
}
-button:hover {
- border-color: #646cff;
+
+.item-detail-content {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 2rem;
}
-button:focus,
-button:focus-visible {
- outline: 4px auto -webkit-focus-ring-color;
+
+.item-detail header {
+ margin-bottom: 2rem;
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 1rem;
}
-@media (prefers-color-scheme: light) {
- :root {
- color: #213547;
- background-color: #ffffff;
- }
- a:hover {
- color: #747bff;
- }
- button {
- background-color: #f9f9f9;
- }
+.item-detail h1 {
+ font-size: 1.8rem;
+ margin: 0 0 0.5rem 0;
+}
+
+.item-detail h1 a {
+ color: var(--text-color);
+ text-decoration: none;
+}
+
+.full-content {
+ font-size: 1.1rem;
+ line-height: 1.6;
+}
+
+.full-content img {
+ max-width: 100%;
+ height: auto;
+}
+
+.empty-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: #888;
+ font-size: 1.2rem;
}
+
+.loading,
+.empty {
+ padding: 1rem;
+ text-align: center;
+ color: #888;
+} \ No newline at end of file
diff --git a/frontend-vanilla/src/types.ts b/frontend-vanilla/src/types.ts
new file mode 100644
index 0000000..4c1110f
--- /dev/null
+++ b/frontend-vanilla/src/types.ts
@@ -0,0 +1,24 @@
+export interface Feed {
+ _id: number;
+ url: string;
+ web_url: string;
+ title: string;
+ category: string;
+}
+
+export interface Item {
+ _id: number;
+ feed_id: number;
+ title: string;
+ url: string;
+ description: string;
+ publish_date: string;
+ read: boolean;
+ starred: boolean;
+ full_content?: string;
+ header_image?: string;
+ feed_title?: string;
+}
+export interface Category {
+ title: string;
+}
diff --git a/frontend-vanilla/src/typescript.svg b/frontend-vanilla/src/typescript.svg
deleted file mode 100644
index d91c910..0000000
--- a/frontend-vanilla/src/typescript.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg> \ No newline at end of file