Compare commits

...

4 commits

15 changed files with 1018 additions and 38 deletions

View file

@ -1,6 +1,7 @@
import logging import logging
import uuid import uuid
from ...languages import SUPPORTED_LANGUAGES
from ...outbound.anthropic.adventure_prompts import ( from ...outbound.anthropic.adventure_prompts import (
build_conversation_messages, build_conversation_messages,
build_entry_system_prompt, build_entry_system_prompt,
@ -21,8 +22,11 @@ from ...outbound.postgres.repositories.adventure_repository import (
PostgresAdventureRepository, PostgresAdventureRepository,
) )
from ...storage import upload_audio from ...storage import upload_audio
from ..models.adventure import Adventure, AdventureEntry, AdventureEntryPossibleChoiceDecision from ..models.adventure import (
from ...languages import SUPPORTED_LANGUAGES Adventure,
AdventureEntry,
AdventureEntryPossibleChoiceDecision,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -60,6 +64,7 @@ class AdventureService:
setting: list[str], setting: list[str],
vibes: list[str], vibes: list[str],
protagonist: list[str], protagonist: list[str],
entry_word_count_range: list[int],
max_entry_count: int = 6, max_entry_count: int = 6,
) -> tuple[Adventure, AdventureEntry]: ) -> tuple[Adventure, AdventureEntry]:
"""Creates the adventure and a placeholder for the first entry. """Creates the adventure and a placeholder for the first entry.
@ -76,7 +81,10 @@ class AdventureService:
vibes=vibes, vibes=vibes,
protagonist=protagonist, protagonist=protagonist,
max_entry_count=max_entry_count, max_entry_count=max_entry_count,
entry_story_text_target_length={"min": 700, "max": 800}, entry_story_text_target_length={
"min": entry_word_count_range[0],
"max": entry_word_count_range[1],
},
) )
first_entry = await self.entry_repo.create( first_entry = await self.entry_repo.create(
adventure_id=uuid.UUID(adventure.id), adventure_id=uuid.UUID(adventure.id),
@ -154,10 +162,14 @@ class AdventureService:
is_final_entry = current_entry.entry_index + 1 == adventure.max_entry_count is_final_entry = current_entry.entry_index + 1 == adventure.max_entry_count
prior_entries = await self._load_prior_entries_with_metadata( prior_entries = await self._load_prior_entries_with_metadata(
all_entries=[e for e in all_entries if e.entry_index < current_entry.entry_index], all_entries=[
e for e in all_entries if e.entry_index < current_entry.entry_index
],
) )
language_name = SUPPORTED_LANGUAGES.get(adventure.language, adventure.language) language_name = SUPPORTED_LANGUAGES.get(
adventure.language, adventure.language
)
competency = adventure.competencies[0] if adventure.competencies else "B1" competency = adventure.competencies[0] if adventure.competencies else "B1"
system_prompt = build_entry_system_prompt( system_prompt = build_entry_system_prompt(
language_name=language_name, language_name=language_name,
@ -193,10 +205,15 @@ class AdventureService:
if not is_final_entry: if not is_final_entry:
await self.choice_repo.create_many( await self.choice_repo.create_many(
entry_id=entry_id, entry_id=entry_id,
choices=[(i, label, text) for i, (label, text) in enumerate(choices_parsed)], choices=[
(i, label, text)
for i, (label, text) in enumerate(choices_parsed)
],
) )
translated = await self.deepl_client.translate(story_text, adventure.source_language) translated = await self.deepl_client.translate(
story_text, adventure.source_language
)
await self.translation_repo.create( await self.translation_repo.create(
entry_id=entry_id, entry_id=entry_id,
component_type="story_text", component_type="story_text",
@ -218,7 +235,9 @@ class AdventureService:
if is_first_entry: if is_first_entry:
title_system = build_title_system_prompt() title_system = build_title_system_prompt()
title_user = build_title_user_message(story_text, language_name, adventure.genres) title_user = build_title_user_message(
story_text, language_name, adventure.genres
)
title_raw, _ = await self.anthropic_client.complete( title_raw, _ = await self.anthropic_client.complete(
system_prompt=title_system, system_prompt=title_system,
messages=[{"role": "user", "content": title_user}], messages=[{"role": "user", "content": title_user}],
@ -230,13 +249,17 @@ class AdventureService:
) )
new_status = "complete" if is_final_entry else "active" new_status = "complete" if is_final_entry else "active"
await self.adventure_repo.update_status(adventure_id=adventure_id, status=new_status) await self.adventure_repo.update_status(
adventure_id=adventure_id, status=new_status
)
except Exception: except Exception:
logger.exception("Entry pipeline failed for entry %s", entry_id) logger.exception("Entry pipeline failed for entry %s", entry_id)
try: try:
await self.entry_repo.update_status(entry_id=entry_id, status="error") await self.entry_repo.update_status(entry_id=entry_id, status="error")
await self.adventure_repo.update_status(adventure_id=adventure_id, status="error") await self.adventure_repo.update_status(
adventure_id=adventure_id, status="error"
)
except Exception: except Exception:
logger.exception("Failed to mark entry/adventure as error") logger.exception("Failed to mark entry/adventure as error")
@ -258,7 +281,11 @@ class AdventureService:
next_entry = sorted_entries[i + 1] next_entry = sorted_entries[i + 1]
if next_entry.generated_from_choice_id: if next_entry.generated_from_choice_id:
chosen = next( chosen = next(
(c for c in choices if c.id == next_entry.generated_from_choice_id), (
c
for c in choices
if c.id == next_entry.generated_from_choice_id
),
None, None,
) )
if chosen: if chosen:

View file

@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ... import worker
from ...auth import verify_token from ...auth import verify_token
from ...config import settings from ...config import settings
from ...domain.services.adventure_service import AdventureService from ...domain.services.adventure_service import AdventureService
@ -23,7 +24,6 @@ from ...outbound.postgres.repositories.adventure_repository import (
PostgresAdventureEntryTranslationRepository, PostgresAdventureEntryTranslationRepository,
PostgresAdventureRepository, PostgresAdventureRepository,
) )
from ... import worker
router = APIRouter(prefix="/adventures", tags=["adventures"]) router = APIRouter(prefix="/adventures", tags=["adventures"])
@ -44,8 +44,7 @@ _STUB_ENTRY_RESPONSE = (
"no notes" "no notes"
) )
_STUB_TITLE_RESPONSE = ( _STUB_TITLE_RESPONSE = (
"La Nuit Parisienne\n" "La Nuit Parisienne\nUne aventure mystérieuse dans les rues sombres de Paris."
"Une aventure mystérieuse dans les rues sombres de Paris."
) )
@ -57,7 +56,12 @@ class _StubAnthropicClient:
model: str = "claude-sonnet-4-6", model: str = "claude-sonnet-4-6",
max_tokens: int = 2048, max_tokens: int = 2048,
) -> tuple[str, dict]: ) -> tuple[str, dict]:
usage = {"provider": "stub", "model": "stub", "input_tokens": 0, "output_tokens": 0} usage = {
"provider": "stub",
"model": "stub",
"input_tokens": 0,
"output_tokens": 0,
}
if "game master" in system_prompt.lower(): if "game master" in system_prompt.lower():
return _STUB_ENTRY_RESPONSE, usage return _STUB_ENTRY_RESPONSE, usage
return _STUB_TITLE_RESPONSE, usage return _STUB_TITLE_RESPONSE, usage
@ -67,7 +71,9 @@ class _StubDeepLClient:
def can_translate_to(self, lang: str) -> bool: def can_translate_to(self, lang: str) -> bool:
return True return True
async def translate(self, text: str, to_language: str, context: str | None = None) -> str: async def translate(
self, text: str, to_language: str, context: str | None = None
) -> str:
return f"[STUB] {text[:120]}" return f"[STUB] {text[:120]}"
@ -89,6 +95,7 @@ class _StubGeminiClient:
# Service factory # Service factory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _make_service(db: AsyncSession) -> AdventureService: def _make_service(db: AsyncSession) -> AdventureService:
if settings.stub_generation: if settings.stub_generation:
anthropic = _StubAnthropicClient() # type: ignore[assignment] anthropic = _StubAnthropicClient() # type: ignore[assignment]
@ -123,6 +130,7 @@ async def _run_entry_pipeline_task(
# Request / response models # Request / response models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class CreateAdventureRequest(BaseModel): class CreateAdventureRequest(BaseModel):
language: str language: str
source_language: str source_language: str
@ -131,6 +139,7 @@ class CreateAdventureRequest(BaseModel):
setting: list[str] setting: list[str]
vibes: list[str] vibes: list[str]
protagonist: list[str] protagonist: list[str]
entry_word_count_range: str
max_entry_count: int = 6 max_entry_count: int = 6
@ -196,6 +205,7 @@ class EntryDetailResponse(BaseModel):
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _to_adventure_response(adventure) -> AdventureResponse: def _to_adventure_response(adventure) -> AdventureResponse:
return AdventureResponse( return AdventureResponse(
id=adventure.id, id=adventure.id,
@ -226,6 +236,7 @@ def _parse_adventure_id(adventure_id: str) -> uuid.UUID:
# Endpoints # Endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("", response_model=AdventureResponse, status_code=201) @router.post("", response_model=AdventureResponse, status_code=201)
async def create_adventure( async def create_adventure(
body: CreateAdventureRequest, body: CreateAdventureRequest,
@ -240,13 +251,30 @@ async def create_adventure(
detail=f"Unsupported language '{body.language}'. Supported: {list(SUPPORTED_LANGUAGES)}", detail=f"Unsupported language '{body.language}'. Supported: {list(SUPPORTED_LANGUAGES)}",
) )
deepl_client = DeepLClient(settings.deepl_api_key) if not settings.stub_generation else _StubDeepLClient() # type: ignore[assignment] deepl_client = (
DeepLClient(settings.deepl_api_key)
if not settings.stub_generation
else _StubDeepLClient()
) # type: ignore[assignment]
if not deepl_client.can_translate_to(body.source_language): if not deepl_client.can_translate_to(body.source_language):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Cannot translate to source language '{body.source_language}'", detail=f"Cannot translate to source language '{body.source_language}'",
) )
# Word count is e.g. "100-200 Words", convert to a tuple of ints (100, 200)
try:
word_count_range = tuple(
int(x.strip().split()[0]) for x in body.entry_word_count_range.split("-")
)
if len(word_count_range) != 2 or word_count_range[0] >= word_count_range[1]:
raise ValueError()
except ValueError:
raise HTTPException(
status_code=400,
detail="Invalid entry_word_count_range. Expected format 'min-max Words', e.g. '100-200 Words'",
)
adventure, first_entry = await _make_service(db).create_adventure_for_user( adventure, first_entry = await _make_service(db).create_adventure_for_user(
user_id=user_id, user_id=user_id,
language=body.language, language=body.language,
@ -257,9 +285,12 @@ async def create_adventure(
vibes=body.vibes, vibes=body.vibes,
protagonist=body.protagonist, protagonist=body.protagonist,
max_entry_count=body.max_entry_count, max_entry_count=body.max_entry_count,
entry_word_count_range=word_count_range,
) )
await worker.enqueue( await worker.enqueue(
partial(_run_entry_pipeline_task, uuid.UUID(adventure.id), uuid.UUID(first_entry.id)) partial(
_run_entry_pipeline_task, uuid.UUID(adventure.id), uuid.UUID(first_entry.id)
)
) )
return _to_adventure_response(adventure) return _to_adventure_response(adventure)
@ -281,7 +312,9 @@ async def get_adventure(
token_data: dict = Depends(verify_token), token_data: dict = Depends(verify_token),
) -> AdventureResponse: ) -> AdventureResponse:
user_id = uuid.UUID(token_data["sub"]) user_id = uuid.UUID(token_data["sub"])
adventure = await PostgresAdventureRepository(db).get_by_id(_parse_adventure_id(adventure_id)) adventure = await PostgresAdventureRepository(db).get_by_id(
_parse_adventure_id(adventure_id)
)
if adventure is None or adventure.user_id != str(user_id): if adventure is None or adventure.user_id != str(user_id):
raise HTTPException(status_code=404, detail="Adventure not found") raise HTTPException(status_code=404, detail="Adventure not found")
return _to_adventure_response(adventure) return _to_adventure_response(adventure)
@ -301,7 +334,9 @@ async def delete_adventure(
await repo.soft_delete(uuid.UUID(adventure.id)) await repo.soft_delete(uuid.UUID(adventure.id))
@router.post("/{adventure_id}/decisions", response_model=DecisionResponse, status_code=201) @router.post(
"/{adventure_id}/decisions", response_model=DecisionResponse, status_code=201
)
async def record_decision( async def record_decision(
adventure_id: str, adventure_id: str,
body: CreateDecisionRequest, body: CreateDecisionRequest,
@ -316,7 +351,9 @@ async def record_decision(
raise HTTPException(status_code=400, detail="Invalid choice_id") raise HTTPException(status_code=400, detail="Invalid choice_id")
try: try:
decision, next_entry = await _make_service(db).record_decision_and_prepare_next_entry( decision, next_entry = await _make_service(
db
).record_decision_and_prepare_next_entry(
adventure_id=_parse_adventure_id(adventure_id), adventure_id=_parse_adventure_id(adventure_id),
choice_id=choice_id, choice_id=choice_id,
user_id=user_id, user_id=user_id,
@ -418,7 +455,8 @@ async def get_entry(
story_text=entry.story_text, story_text=entry.story_text,
created_at=entry.created_at.isoformat(), created_at=entry.created_at.isoformat(),
choices=[ choices=[
ChoiceResponse(id=c.id, index=c.index, label=c.label, text=c.text) for c in choices ChoiceResponse(id=c.id, index=c.index, label=c.label, text=c.text)
for c in choices
], ],
translation=translation.translated_text if translation else None, translation=translation.translated_text if translation else None,
audio_file_name=audio.file_name if audio else None, audio_file_name=audio.file_name if audio else None,

File diff suppressed because one or more lines are too long

View file

@ -312,6 +312,12 @@ body {
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
} }
.field-fieldset {
display: flex;
flex-direction: column;
padding: var(--space-2);
}
.field-input { .field-input {
background-color: var(--color-surface-container-high); background-color: var(--color-surface-container-high);
color: var(--color-on-surface); color: var(--color-on-surface);
@ -516,3 +522,24 @@ body {
color: #b3261e; color: #b3261e;
border-left: 3px solid #b3261e; border-left: 3px solid #b3261e;
} }
/*
LAYOUT: APP PAGE
*/
.app-page {
max-width: 52rem;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
.page-title {
font-family: var(--font-display);
font-size: var(--text-headline-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--color-on-surface);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -192,6 +192,68 @@ export type AddWordRequest = {
source_article_id?: string | null; source_article_id?: string | null;
}; };
/**
* AdventureResponse
*/
export type AdventureResponse = {
/**
* Id
*/
id: string;
/**
* User Id
*/
user_id: string;
/**
* Status
*/
status: string;
/**
* Language
*/
language: string;
/**
* Source Language
*/
source_language: string;
/**
* Competencies
*/
competencies: Array<string>;
/**
* Max Entry Count
*/
max_entry_count: number;
/**
* Title
*/
title: string;
/**
* Description
*/
description: string | null;
/**
* Genres
*/
genres: Array<string>;
/**
* Setting
*/
setting: Array<string>;
/**
* Vibes
*/
vibes: Array<string>;
/**
* Protagonist
*/
protagonist: Array<string>;
/**
* Created At
*/
created_at: string;
};
/** /**
* ArticleDetail * ArticleDetail
*/ */
@ -300,6 +362,76 @@ export type ArticleListResponse = {
articles: Array<ArticleItem>; articles: Array<ArticleItem>;
}; };
/**
* ChoiceResponse
*/
export type ChoiceResponse = {
/**
* Id
*/
id: string;
/**
* Index
*/
index: number;
/**
* Label
*/
label: string;
/**
* Text
*/
text: string;
};
/**
* CreateAdventureRequest
*/
export type CreateAdventureRequest = {
/**
* Language
*/
language: string;
/**
* Source Language
*/
source_language: string;
/**
* Competencies
*/
competencies: Array<string>;
/**
* Genres
*/
genres: Array<string>;
/**
* Setting
*/
setting: Array<string>;
/**
* Vibes
*/
vibes: Array<string>;
/**
* Protagonist
*/
protagonist: Array<string>;
/**
* Max Entry Count
*/
max_entry_count?: number;
};
/**
* CreateDecisionRequest
*/
export type CreateDecisionRequest = {
/**
* Choice Id
*/
choice_id: string;
};
/** /**
* CreatePackRequest * CreatePackRequest
*/ */
@ -334,6 +466,108 @@ export type CreatePackRequest = {
proficiencies?: Array<string>; proficiencies?: Array<string>;
}; };
/**
* DecisionResponse
*/
export type DecisionResponse = {
/**
* Id
*/
id: string;
/**
* Choice Id
*/
choice_id: string;
/**
* User Id
*/
user_id: string;
/**
* Created At
*/
created_at: string;
};
/**
* EntryDetailResponse
*/
export type EntryDetailResponse = {
/**
* Id
*/
id: string;
/**
* Adventure Id
*/
adventure_id: string;
/**
* Generated From Choice Id
*/
generated_from_choice_id: string | null;
/**
* Status
*/
status: string;
/**
* Entry Index
*/
entry_index: number;
/**
* Story Text
*/
story_text: string | null;
/**
* Created At
*/
created_at: string;
/**
* Choices
*/
choices: Array<ChoiceResponse>;
/**
* Translation
*/
translation: string | null;
/**
* Audio File Name
*/
audio_file_name: string | null;
};
/**
* EntryResponse
*/
export type EntryResponse = {
/**
* Id
*/
id: string;
/**
* Adventure Id
*/
adventure_id: string;
/**
* Generated From Choice Id
*/
generated_from_choice_id: string | null;
/**
* Status
*/
status: string;
/**
* Entry Index
*/
entry_index: number;
/**
* Story Text
*/
story_text: string | null;
/**
* Created At
*/
created_at: string;
};
/** /**
* FlashcardEventResponse * FlashcardEventResponse
*/ */
@ -1019,6 +1253,14 @@ export type SenseCandidateResponse = {
tags: Array<string>; tags: Array<string>;
}; };
/**
* SenseMatch
*/
export type SenseMatch = {
sense: SenseResponse;
lemma: LemmaResponse;
};
/** /**
* SenseResponse * SenseResponse
*/ */
@ -1558,6 +1800,42 @@ export type SearchWordformsPrefixApiDictionarySearchGetResponses = {
export type SearchWordformsPrefixApiDictionarySearchGetResponse = SearchWordformsPrefixApiDictionarySearchGetResponses[keyof SearchWordformsPrefixApiDictionarySearchGetResponses]; export type SearchWordformsPrefixApiDictionarySearchGetResponse = SearchWordformsPrefixApiDictionarySearchGetResponses[keyof SearchWordformsPrefixApiDictionarySearchGetResponses];
export type SearchSensesApiDictionarySensesGetData = {
body?: never;
path?: never;
query: {
/**
* Lang Code
*/
lang_code: string;
/**
* Text
*/
text: string;
};
url: '/api/dictionary/senses';
};
export type SearchSensesApiDictionarySensesGetErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type SearchSensesApiDictionarySensesGetError = SearchSensesApiDictionarySensesGetErrors[keyof SearchSensesApiDictionarySensesGetErrors];
export type SearchSensesApiDictionarySensesGetResponses = {
/**
* Response Search Senses Api Dictionary Senses Get
*
* Successful Response
*/
200: Array<SenseMatch>;
};
export type SearchSensesApiDictionarySensesGetResponse = SearchSensesApiDictionarySensesGetResponses[keyof SearchSensesApiDictionarySensesGetResponses];
export type SearchWordformsApiDictionaryWordformsGetData = { export type SearchWordformsApiDictionaryWordformsGetData = {
body?: never; body?: never;
path?: never; path?: never;
@ -2380,6 +2658,205 @@ export type RemoveFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTe
export type RemoveFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTemplateIdDeleteResponse = RemoveFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTemplateIdDeleteResponses[keyof RemoveFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTemplateIdDeleteResponses]; export type RemoveFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTemplateIdDeleteResponse = RemoveFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTemplateIdDeleteResponses[keyof RemoveFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTemplateIdDeleteResponses];
export type ListAdventuresApiAdventuresGetData = {
body?: never;
path?: never;
query?: never;
url: '/api/adventures';
};
export type ListAdventuresApiAdventuresGetResponses = {
/**
* Response List Adventures Api Adventures Get
*
* Successful Response
*/
200: Array<AdventureResponse>;
};
export type ListAdventuresApiAdventuresGetResponse = ListAdventuresApiAdventuresGetResponses[keyof ListAdventuresApiAdventuresGetResponses];
export type CreateAdventureApiAdventuresPostData = {
body: CreateAdventureRequest;
path?: never;
query?: never;
url: '/api/adventures';
};
export type CreateAdventureApiAdventuresPostErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateAdventureApiAdventuresPostError = CreateAdventureApiAdventuresPostErrors[keyof CreateAdventureApiAdventuresPostErrors];
export type CreateAdventureApiAdventuresPostResponses = {
/**
* Successful Response
*/
201: AdventureResponse;
};
export type CreateAdventureApiAdventuresPostResponse = CreateAdventureApiAdventuresPostResponses[keyof CreateAdventureApiAdventuresPostResponses];
export type DeleteAdventureApiAdventuresAdventureIdDeleteData = {
body?: never;
path: {
/**
* Adventure Id
*/
adventure_id: string;
};
query?: never;
url: '/api/adventures/{adventure_id}';
};
export type DeleteAdventureApiAdventuresAdventureIdDeleteErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DeleteAdventureApiAdventuresAdventureIdDeleteError = DeleteAdventureApiAdventuresAdventureIdDeleteErrors[keyof DeleteAdventureApiAdventuresAdventureIdDeleteErrors];
export type DeleteAdventureApiAdventuresAdventureIdDeleteResponses = {
/**
* Successful Response
*/
204: void;
};
export type DeleteAdventureApiAdventuresAdventureIdDeleteResponse = DeleteAdventureApiAdventuresAdventureIdDeleteResponses[keyof DeleteAdventureApiAdventuresAdventureIdDeleteResponses];
export type GetAdventureApiAdventuresAdventureIdGetData = {
body?: never;
path: {
/**
* Adventure Id
*/
adventure_id: string;
};
query?: never;
url: '/api/adventures/{adventure_id}';
};
export type GetAdventureApiAdventuresAdventureIdGetErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetAdventureApiAdventuresAdventureIdGetError = GetAdventureApiAdventuresAdventureIdGetErrors[keyof GetAdventureApiAdventuresAdventureIdGetErrors];
export type GetAdventureApiAdventuresAdventureIdGetResponses = {
/**
* Successful Response
*/
200: AdventureResponse;
};
export type GetAdventureApiAdventuresAdventureIdGetResponse = GetAdventureApiAdventuresAdventureIdGetResponses[keyof GetAdventureApiAdventuresAdventureIdGetResponses];
export type RecordDecisionApiAdventuresAdventureIdDecisionsPostData = {
body: CreateDecisionRequest;
path: {
/**
* Adventure Id
*/
adventure_id: string;
};
query?: never;
url: '/api/adventures/{adventure_id}/decisions';
};
export type RecordDecisionApiAdventuresAdventureIdDecisionsPostErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RecordDecisionApiAdventuresAdventureIdDecisionsPostError = RecordDecisionApiAdventuresAdventureIdDecisionsPostErrors[keyof RecordDecisionApiAdventuresAdventureIdDecisionsPostErrors];
export type RecordDecisionApiAdventuresAdventureIdDecisionsPostResponses = {
/**
* Successful Response
*/
201: DecisionResponse;
};
export type RecordDecisionApiAdventuresAdventureIdDecisionsPostResponse = RecordDecisionApiAdventuresAdventureIdDecisionsPostResponses[keyof RecordDecisionApiAdventuresAdventureIdDecisionsPostResponses];
export type ListEntriesApiAdventuresAdventureIdEntriesGetData = {
body?: never;
path: {
/**
* Adventure Id
*/
adventure_id: string;
};
query?: never;
url: '/api/adventures/{adventure_id}/entries';
};
export type ListEntriesApiAdventuresAdventureIdEntriesGetErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ListEntriesApiAdventuresAdventureIdEntriesGetError = ListEntriesApiAdventuresAdventureIdEntriesGetErrors[keyof ListEntriesApiAdventuresAdventureIdEntriesGetErrors];
export type ListEntriesApiAdventuresAdventureIdEntriesGetResponses = {
/**
* Response List Entries Api Adventures Adventure Id Entries Get
*
* Successful Response
*/
200: Array<EntryResponse>;
};
export type ListEntriesApiAdventuresAdventureIdEntriesGetResponse = ListEntriesApiAdventuresAdventureIdEntriesGetResponses[keyof ListEntriesApiAdventuresAdventureIdEntriesGetResponses];
export type GetEntryApiAdventuresAdventureIdEntriesEntryIdGetData = {
body?: never;
path: {
/**
* Adventure Id
*/
adventure_id: string;
/**
* Entry Id
*/
entry_id: string;
};
query?: never;
url: '/api/adventures/{adventure_id}/entries/{entry_id}';
};
export type GetEntryApiAdventuresAdventureIdEntriesEntryIdGetErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetEntryApiAdventuresAdventureIdEntriesEntryIdGetError = GetEntryApiAdventuresAdventureIdEntriesEntryIdGetErrors[keyof GetEntryApiAdventuresAdventureIdEntriesEntryIdGetErrors];
export type GetEntryApiAdventuresAdventureIdEntriesEntryIdGetResponses = {
/**
* Successful Response
*/
200: EntryDetailResponse;
};
export type GetEntryApiAdventuresAdventureIdEntriesEntryIdGetResponse = GetEntryApiAdventuresAdventureIdEntriesEntryIdGetResponses[keyof GetEntryApiAdventuresAdventureIdEntriesEntryIdGetResponses];
export type GetAccountBffAccountGetData = { export type GetAccountBffAccountGetData = {
body?: never; body?: never;
path?: never; path?: never;

View file

@ -1 +1,2 @@
// place files you want to import through the `$lib` alias in this folder. // place files you want to import through the `$lib` alias in this folder.
export { shuffleArray } from './shuffleArray';

View file

@ -0,0 +1,3 @@
export function shuffleArray<T>(data: Array<T>): Array<T> {
return data.sort(() => Math.random() - 0.5);
}

View file

@ -0,0 +1,28 @@
<script>
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { getAdventures } from './getAdventures.remote';
onMount(async () => {
const _adventures = await getAdventures('');
if (_adventures) {
adventures = _adventures;
}
});
let adventures = $state([]);
</script>
<div class="app-page">
<header class="page-header">
<h1 class="page-title">Adventures</h1>
<a href={resolve('/app/adventures/new')} class="btn">Create</a>
</header>
{#each adventures as adventure (adventure.id)}
<div class="adventure-card">
<h2>{adventure.title}</h2>
<p>{adventure.description}</p>
</div>
{/each}
</div>

View file

@ -0,0 +1,12 @@
import { getRequestEvent, query } from '$app/server';
import { listAdventuresApiAdventuresGet } from '@client';
export const getAdventures = query('unchecked', async () => {
const request = getRequestEvent();
const { data } = await listAdventuresApiAdventuresGet({
headers: {
Authorization: `Bearer ${request.locals.authToken}`
}
});
return data;
});

View file

@ -0,0 +1,167 @@
import { error, type Action, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createAdventureApiAdventuresPost } from '@client';
import * as v from 'valibot';
import { shuffleArray } from '$lib';
export const load: PageServerLoad = async () => {
return {
lengths: ['100-200 Words', '200-350 Words', '350-500 Words', '500-750 Words'],
competencies: ['A1', 'A2', 'B1', 'B2', 'C1'],
languages: [
{ code: 'fr', name: 'French' },
{ code: 'de', name: 'German' },
{ code: 'it', name: 'Italian' },
{ code: 'es', name: 'Spanish' },
{ code: 'pt', name: 'Portuguese' }
],
genres: shuffleArray([
'Crime Fiction',
'Crime noir',
'Who-dun-it mystery',
'Paranormal',
'Horror',
'Psychological thriller',
'Romance',
'Family',
'Fantasy',
'Science Fiction'
]),
eras: [
'Ancient/Classical',
'Medieval',
'1500-1800',
'Renaissance',
'19th century',
'Early 20th century',
'Mid-century',
'Contemporary',
'Near future',
'Far future'
],
settings: [
'Capital city',
'Large city (not the capital)',
'Urban',
'The suburbs',
'Rural',
'Pastoral',
'Small town',
'Wilderness',
'Space',
'Another planet'
],
vibes: [
'Melancholic',
'Gothic',
'Sun-drenched',
'Bleak',
'Whimsical',
'Eerie',
'Cosy',
'Tense',
'Witty',
'Propulsive',
'Mentor and student',
'Unlikely duo',
'Lone wolf',
'Queer-norm',
'Class tensions',
'Chosen family',
'Diaspora',
'Academia',
'Small town',
'The sea',
'Grand house',
'Road trip',
'A single night',
'Heist',
'Mystery box',
'Reluctant hero',
'Redemption',
'Animal companions',
'Gentle',
'Happy ever after',
'Bittersweet',
'Epistolary (letters / diary entries)',
"A big city that isn't the capital",
'Parenthood',
'Sly',
'Slapstick',
'Recovery',
'Political',
'Apocalyptic',
'Post-apocalyptic',
'Survival',
'War',
'Spy thriller',
'Time travel'
]
};
};
const CreateFormSchema = v.object({
vibes: v.array(v.string()),
genre: v.string(),
setting: v.string(),
competency: v.pipe(v.string(), v.picklist(['A1', 'A2', 'B1', 'B2', 'C1'])),
language: v.pipe(v.string(), v.picklist(['fr', 'it', 'de', 'it', 'es'])),
length: v.string(),
protagonist_gender: v.string(),
protagonist_age: v.string()
});
export const actions = {
default: async ({ locals, request }) => {
const { authToken } = locals;
const formData = await request.formData();
const data = v.safeParse(CreateFormSchema, {
vibes: formData.getAll('vibes') as string[],
genre: formData.get('genre') as string,
setting: formData.get('setting') as string,
competency: formData.get('competency') as string,
language: formData.get('language') as string,
length: formData.get('length') as string,
protagonist_gender: formData.get('protagonist_gender') as string,
protagonist_age: formData.get('protagonist_age') as string
});
if (data.success == false) {
throw error(400, {
message: data.issues.map((e) => e.message).join(', ')
});
}
const {
competency,
language,
length,
genre,
setting,
protagonist_gender,
protagonist_age,
vibes
} = data.output;
const { data: apiData, error: apiError } = await createAdventureApiAdventuresPost({
headers: {
Authorization: `Bearer ${authToken}`
},
body: {
competencies: [competency],
language: language,
genres: [genre],
setting: [setting],
vibes: vibes,
protagonist: [protagonist_gender, protagonist_age],
source_language: 'en',
entry_word_count_range: length,
max_entry_count: 6
}
});
console.log({ apiData, apiError });
}
} satisfies Actions;

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { shuffleArray } from '$lib';
import type { ChangeEventHandler } from 'svelte/elements';
import type { PageServerData } from './$types';
const { data }: { data: PageServerData } = $props();
let theVibes = $state(shuffleArray(data.vibes).slice(0, 7));
const handleVibeSelected: ChangeEventHandler<HTMLInputElement> = (e) => {
const { checked, value } = e.currentTarget;
if (checked) {
selectedVibes = [...selectedVibes, value as string];
} else {
selectedVibes = selectedVibes.filter((v) => v !== value);
}
};
let selectedVibes = $state([]);
let vibeShuffleCount = $state(0);
let shuffleTheVibes = () => (theVibes = shuffleArray(data.vibes).slice(0, 5));
</script>
<section class="app-page">
<div class="page-header">
<div class="page-title">Create new Adventure</div>
</div>
<form class="form" method="POST">
<div class="field">
<label for="genre" class="label">Genre</label>
<select name="genre" id="genre">
{#each data.genres as genre (genre)}
<option value={genre}>{genre}</option>
{/each}
</select>
</div>
<div class="field">
<label for="setting" class="label">Setting</label>
<select name="setting" id="setting">
{#each ['Urban', 'Rural', 'Coastal', 'Mountain', 'Forest', 'Desert'] as setting (setting)}
<option value={setting}>{setting}</option>
{/each}
</select>
</div>
<div class="field">
<fieldset class="field-fieldset">
<legend>Entry length</legend>
{#each data.lengths as length (length)}
{@const inputId = `length-${length}`}
<div class="radio-option">
<input type="radio" name="length" value={length} id={inputId} />
<label for={inputId}>{length}</label>
</div>
{/each}
</fieldset>
</div>
<div class="field">
<fieldset class="field-fieldset">
<legend>Vibes (pick two)</legend>
{#each theVibes as vibe (vibe)}
{@const theId = `vibe-${vibe}`}
<label for={theId} class="label">
<input
type="checkbox"
disabled={selectedVibes.length >= 2 && !selectedVibes.includes(vibe)}
id={theId}
value={vibe}
onchange={handleVibeSelected}
/>
{vibe}
</label>
{/each}
</fieldset>
</div>
<div class="field">
<label for="language" class="label">Language</label>
<select name="language" id="language">
{#each data.languages as language (language.code)}
<option value={language.code}>{language.name}</option>
{/each}
</select>
</div>
<div class="field">
<label for="competency" class="label">Competency</label>
<select name="competency" id="competency">
{#each data.competencies as competency (competency)}
<option value={competency}>{competency}</option>
{/each}
</select>
</div>
<div class="field">
<fieldset class="field-fieldset">
<legend>Protagonist gender</legend>
{#each ['male', 'female', 'non-binary', 'any'] as gender (gender)}
{@const inputId = `gender-${gender}`}
<div class="radio-option">
<input type="radio" name="protagonist_gender" value={gender} id={inputId} />
<label for={inputId}>{gender}</label>
</div>
{/each}
</fieldset>
</div>
<div class="field">
<label for="protagonist_age" class="label">Protagonist age</label>
<select name="protagonist_age" id="protagonist_age">
{#each ['Adult', 'Child', 'Teen', 'Young Adult', 'Older Adult', 'Middle-aged', 'Older'] as age (age)}
<option value={age}>{age}</option>
{/each}
</select>
</div>
<input type="submit" value="Create Adventure" class="submit-button" />
</form>
</section>

View file

@ -25,7 +25,7 @@
}).format(new Date(iso)); }).format(new Date(iso));
</script> </script>
<div class="page"> <div class="app-page">
<header class="page-header"> <header class="page-header">
<p class="form-eyebrow">Reading</p> <p class="form-eyebrow">Reading</p>
<h1 class="form-title">Articles</h1> <h1 class="form-title">Articles</h1>
@ -44,7 +44,9 @@
</div> </div>
<h2 class="article-title">{article.target_title}</h2> <h2 class="article-title">{article.target_title}</h2>
<p class="article-source">{article.source_title}</p> <p class="article-source">{article.source_title}</p>
<time class="article-date label-md" datetime={article.published_at}>{fmt(article.published_at)}</time> <time class="article-date label-md" datetime={article.published_at}
>{fmt(article.published_at)}</time
>
</a> </a>
</li> </li>
{/each} {/each}
@ -58,15 +60,6 @@
</div> </div>
<style> <style>
.page {
max-width: 52rem;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* --- Article list --- */ /* --- Article list --- */
.article-list { .article-list {

View file

@ -8,7 +8,7 @@ const config = {
remoteFunctions: true remoteFunctions: true
}, },
alias: { alias: {
'@client': 'src/client/client.gen.ts' '@client': 'src/client/sdk.gen'
} }
}, },
compilerOptions: { compilerOptions: {