Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ build/
coverage/
apps/pwa/cypress/screenshots/
apps/pwa/cypress/videos/
apps/pwa/cypress/downloads/
11 changes: 0 additions & 11 deletions apps/pwa/cypress/downloads/studyos.ics

This file was deleted.

103 changes: 103 additions & 0 deletions apps/pwa/cypress/e2e/library-loop.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// M4 acceptance: search (stubbed sources), attach a video to a topic, see it on the
// topic, and find things via the global FTS search. Network is fully intercepted.
describe('library and content loop', () => {
const stamp = Date.now();
const trackTitle = `trilha conteúdo ${stamp}`;
const topicTitle = `Controle difuso ${stamp}`;
const videoTitle = `Aula de controle difuso ${stamp}`;

function stubSources(): void {
cy.intercept('GET', 'https://pt.wikipedia.org/w/api.php*', {
body: {
query: {
search: [
{
pageid: 101,
title: 'Controle de constitucionalidade',
snippet: 'exame',
wordcount: 900,
},
],
},
},
}).as('wiki');
cy.intercept('GET', 'https://api.stackexchange.com/**', { body: { items: [] } }).as('se');
cy.intercept('GET', '/proxy/youtube/search*', {
body: {
items: [
{
id: 'dQw4w9WgXcQ',
title: videoTitle,
channel: 'prof e2e',
thumbnail: null,
duration: null,
},
],
},
}).as('yt');
}

it('sets up a track with one topic', () => {
cy.visit('/tracks');
cy.get('[data-testid="track-title-input"]').type(trackTitle);
cy.get('[data-testid="track-submit"]').click();
cy.get('[data-testid="track-item"]').contains(trackTitle).click();
cy.get('[data-testid="topic-form"] [data-testid="topic-title-input"]').type(topicTitle);
cy.get('[data-testid="topic-submit"]').click();
cy.get('[data-testid="topic-title"]').contains(topicTitle);
});

it('searches the library and attaches a video to the topic', () => {
stubSources();
cy.visit('/library');
cy.get('[data-testid="library-search-input"]').type('controle difuso');
cy.get('[data-testid="library-search-submit"]').click();
cy.wait(['@wiki', '@yt']);

cy.get('[data-testid="library-result"]').contains(videoTitle);
cy.get('[data-testid="library-result"]')
.contains(videoTitle)
.closest('[data-testid="library-result"]')
.find('[data-testid="library-attach"]')
.click();
cy.get('[data-testid="attach-track-select"]').select(trackTitle);
cy.get('[data-testid="attach-topic-select"]').select(topicTitle);
cy.get('[data-testid="attach-confirm"]').click();
cy.contains('anexado ·');
});

it('shows the attached video on the topic and plays it with transcript', () => {
cy.visit('/tracks');
cy.get('[data-testid="track-item"]').contains(trackTitle).click();
cy.get('[data-testid="topic-title"]').contains(topicTitle).click();
cy.get('[data-testid="topic-content-list"] [data-testid="topic-content-item"]')
.contains(videoTitle)
.should('have.attr', 'href')
.and('include', '/library/watch/dQw4w9WgXcQ');

cy.intercept('GET', '/proxy/youtube/transcript*', {
headers: { 'content-type': 'text/xml' },
body: '<?xml version="1.0"?><transcript><text start="1.0" dur="2.0">primeira fala</text><text start="3.5" dur="2.0">segunda fala</text></transcript>',
}).as('transcript');
cy.visit('/library/watch/dQw4w9WgXcQ');
cy.wait('@transcript');
cy.get('[data-testid="video-player"]').should('be.visible');
cy.get('[data-testid="transcript-cue"]')
.should('have.length', 2)
.first()
.contains('primeira fala');
});

it('finds the topic and the content via global search', () => {
cy.visit('/');
cy.get('[data-testid="global-search-input"]').type('Controle difuso');
cy.get('[data-testid="global-search-results"] [data-testid="global-search-result"]').contains(
topicTitle,
);
cy.get('[data-testid="global-search-input"]').clear();
cy.get('[data-testid="global-search-input"]').type('Aula de controle');
cy.get('[data-testid="global-search-results"] [data-testid="global-search-result"]').contains(
videoTitle,
);
});
});
1 change: 1 addition & 0 deletions apps/pwa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
"dependencies": {
"@journeyapps/wa-sqlite": "^1.7.0",
"@studyos/connectors": "workspace:*",
"@studyos/core": "workspace:*",
"@studyos/db": "workspace:*",
"@studyos/shared": "workspace:*",
Expand Down
148 changes: 148 additions & 0 deletions apps/pwa/src/lib/components/GlobalSearch.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import type { SearchHit } from '@studyos/db';
import { createSearchStore } from '$lib/stores/search.svelte';

const store = createSearchStore();
let activeIndex = $state(-1);
let blurTimer: ReturnType<typeof setTimeout> | null = null;

const KIND_LABEL: Record<SearchHit['kind'], string> = {
topic: 'tópico',
card: 'card',
content: 'conteúdo',
};

// The fts snippet marks matches with [ ]; render plain text.
function stripMarkers(snippet: string): string {
return snippet.replaceAll('[', '').replaceAll(']', '');
}

function clear(): void {
store.close();
store.query = '';
store.run();
activeIndex = -1;
}

async function activate(hit: SearchHit): Promise<void> {
const href = await store.resolveHref(hit);
clear();
if (href === null) return;
if (href.startsWith('http')) {
window.open(href, '_blank', 'noopener,noreferrer');
} else {
await goto(href);
}
}

function oninput(event: Event & { currentTarget: HTMLInputElement }): void {
store.query = event.currentTarget.value;
activeIndex = -1;
store.run();
}

function onkeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
store.close();
activeIndex = -1;
return;
}
if (!store.open || store.results.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
activeIndex = (activeIndex + 1) % store.results.length;
} else if (event.key === 'ArrowUp') {
event.preventDefault();
activeIndex = activeIndex <= 0 ? store.results.length - 1 : activeIndex - 1;
} else if (event.key === 'Enter') {
event.preventDefault();
const hit = store.results[activeIndex] ?? store.results[0];
if (hit !== undefined) void activate(hit);
}
}

function onblur(): void {
// Delay so a click on a result lands before the dropdown closes.
blurTimer = setTimeout(() => {
blurTimer = null;
store.close();
activeIndex = -1;
}, 150);
}

function onfocus(): void {
if (blurTimer !== null) {
clearTimeout(blurTimer);
blurTimer = null;
}
if (store.query.trim() !== '') store.run();
}

onDestroy(() => {
if (blurTimer !== null) clearTimeout(blurTimer);
});
</script>

<div class="relative">
<label class="sr-only" for="global-search-input">buscar</label>
<input
id="global-search-input"
data-testid="global-search-input"
type="text"
role="combobox"
aria-expanded={store.open}
aria-controls="global-search-results"
aria-autocomplete="list"
aria-activedescendant={activeIndex >= 0 ? `global-search-option-${activeIndex}` : undefined}
placeholder="buscar"
autocomplete="off"
value={store.query}
{oninput}
{onkeydown}
{onblur}
{onfocus}
class="type-meta h-8 w-36 rounded-micro border border-border bg-surface px-3 text-text-body placeholder:text-text-low"
/>

{#if store.open}
<ul
id="global-search-results"
data-testid="global-search-results"
role="listbox"
aria-label="resultados da busca"
class="absolute top-full right-0 z-10 mt-2 w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-base border border-hairline bg-surface"
>
{#if store.results.length === 0}
<li class="type-meta px-3 py-2.5 text-text-soft">nada encontrado</li>
{:else}
{#each store.results as hit, i (`${hit.kind}:${hit.ref_id}`)}
{@const snippet = stripMarkers(hit.snippet)}
<li role="presentation" class="border-b border-hairline last:border-b-0">
<button
id={`global-search-option-${i}`}
data-testid="global-search-result"
type="button"
role="option"
aria-selected={i === activeIndex}
onclick={() => void activate(hit)}
class="flex w-full cursor-pointer items-baseline gap-2 px-3 py-2.5 text-left transition-colors duration-(--dur-base) ease-brand {i ===
activeIndex
? 'bg-surface-2'
: 'hover:bg-surface-2'}"
>
<span class="type-meta shrink-0 text-text-low">{KIND_LABEL[hit.kind]}</span>
<span class="min-w-0 flex-1">
<span class="type-item block truncate text-text-body">{hit.title}</span>
{#if snippet !== ''}
<span class="type-meta block truncate text-text-soft">{snippet}</span>
{/if}
</span>
</button>
</li>
{/each}
{/if}
</ul>
{/if}
</div>
17 changes: 15 additions & 2 deletions apps/pwa/src/lib/db/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { browser } from '$app/environment';
import type { DbDriver, Row, SqlValue, Stmt } from '@studyos/db';
import {
ensureSearchIndex,
reindexAll,
type DbDriver,
type Row,
type SqlValue,
type Stmt,
} from '@studyos/db';
import type { DbReady, DbRequest, DbResponse } from './rpc';

let instance: Promise<DbDriver> | null = null;
Expand Down Expand Up @@ -64,7 +71,7 @@ async function createDriver(): Promise<DbDriver> {
});
}

return {
const driver: DbDriver = {
exec(sql: string, params?: SqlValue[]): Promise<Row[]> {
const id = nextId++;
return send(
Expand All @@ -76,4 +83,10 @@ async function createDriver(): Promise<DbDriver> {
await send({ id, kind: 'batch', stmts, mutates: mutatedTables(stmts) });
},
};

// Local-only FTS index (see packages/db/src/search.ts): create after migrate,
// then rebuild in the background so global search reflects the current data.
await ensureSearchIndex(driver);
void reindexAll(driver);
return driver;
}
Loading