import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
import { store, toBlueskyPostUrl, shortPostId, formatTimestamp } from "../store";
import { apiService, blueskyService } from "../services";
import type { FeedItem } from "../types";
@customElement("app-session-page")
export class AppSessionPage extends LitElement {
@state()
private isLoading = false;
static styles = css`
:host {
display: block;
}
.dashboard-grid {
max-width: 800px;
margin: 0 auto;
}
.panel {
background: var(--panel-bg, #fff);
border-radius: 0.5rem;
box-shadow: var(--panel-shadow, 0 1px 3px rgba(0,0,0,0.1));
padding: 1rem;
}
.panel-header {
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.panel-header h1 {
font-size: 1.25rem;
margin: 0;
}
.eyebrow {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #6b7280);
margin-bottom: 0.25rem;
}
.lede {
font-size: 0.8125rem;
color: var(--text-secondary, #4b5563);
margin-bottom: 1rem;
line-height: 1.4;
}
.session-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-end;
margin-bottom: 0.75rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 200px;
}
label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary, #1f2937);
}
input[type="text"] {
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 0.25rem;
font-size: 0.8125rem;
}
input[type="text"]:disabled {
background: var(--disabled-bg, #f3f4f6);
cursor: not-allowed;
}
button {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 0.25rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
button.accent {
background: var(--accent-bg, #3b82f6);
color: white;
}
button.accent:hover:not(:disabled) {
background: var(--accent-hover, #2563eb);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.session-meta {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem 1rem;
font-size: 0.6875rem;
color: var(--text-muted, #6b7280);
margin-bottom: 0.75rem;
padding: 0.5rem;
background: var(--meta-bg, #f9fafb);
border-radius: 0.25rem;
}
.generator-endpoint {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: var(--endpoint-bg, #f3f4f6);
border-radius: 0.25rem;
gap: 1rem;
}
.generator-endpoint > div {
flex: 1;
min-width: 0;
}
.generator-endpoint strong {
display: block;
font-size: 0.875rem;
margin: 0.125rem 0;
}
.small-copy {
font-size: 0.6875rem;
color: var(--text-muted, #6b7280);
display: block;
margin-top: 0.125rem;
}
.message-bar {
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.8125rem;
margin-top: 0.75rem;
}
.message-bar.error {
background: var(--error-bg, #fef2f2);
color: var(--error-text, #991b1b);
border: 1px solid var(--error-border, #fecaca);
}
.message-bar.success {
background: var(--success-bg, #f0fdf4);
color: var(--success-text, #166534);
border: 1px solid var(--success-border, #bbf7d0);
}
`;
connectedCallback() {
super.connectedCallback();
this.initialize();
this.unsubscribe = store.subscribe(() => this.requestUpdate());
}
disconnectedCallback() {
super.disconnectedCallback();
this.unsubscribe?.();
}
private unsubscribe?: () => void;
async initialize() {
try {
const [gens, info] = await Promise.all([
apiService.getGenerators(),
apiService.getFeedGeneratorInfo(),
]);
store.setGenerators(gens);
store.setFeedGeneratorInfo(info);
const sess = await blueskyService.getSession();
store.setSession(sess);
if (sess.isAuthenticated) {
store.setLoginIdentifier(sess.handle || sess.did || "");
await this.loadFeedState();
}
} catch (err) {
store.setError(`Bluesky client bootstrap failed: ${err}`);
}
}
async loadFeedState() {
const did = store.session.did;
if (!did) return;
try {
const likeFeedData = await apiService.getLikeFeed(did);
store.setLikeFeed({
importedPostCount: likeFeedData.importedPostCount,
importedAuthorCount: likeFeedData.importedAuthorCount,
items: likeFeedData.items,
authors: likeFeedData.authors,
});
} catch (err) {
console.error("Failed to load feed state:", err);
}
}
async connect() {
store.setBusy(true);
store.setError(null);
try {
const id = store.loginIdentifier.trim();
await blueskyService.beginLogin(id);
} catch (err) {
store.setError(String(err));
} finally {
store.setBusy(false);
}
}
async importFeed() {
if (!store.isAuthenticated) {
store.setError("Sign in first.");
return;
}
store.setBusy(true);
store.setError(null);
store.setStatus("Crawling your liked network...");
try {
const imported = await blueskyService.fetchLikedNetwork();
const postCount = imported.items?.length ?? 0;
const authorCount = imported.authors?.length ?? 0;
store.setStatus(`Crawled ${postCount} posts from ${authorCount} authors. Storing...`);
await apiService.importFeed(imported);
await this.loadFeedState();
store.setStatus(`Imported ${postCount} posts from ${authorCount} authors.`);
} catch (err) {
console.error("Import failed:", err);
store.setError(String(err));
} finally {
store.setBusy(false);
}
}
async publishFeed() {
if (!store.isAuthenticated || !store.feedGeneratorInfo?.serviceDid) {
store.setError("Sign in first.");
return;
}
store.setBusy(true);
store.setError(null);
try {
const info = store.feedGeneratorInfo;
await blueskyService.publishFeedGenerator({
ownerDid: store.session.did!,
serviceDid: info.serviceDid!,
feedKey: info.feedKey,
displayName: info.displayName,
description: info.description,
});
store.setStatus(`Published ${info.feedUri}`);
} catch (err) {
store.setError(String(err));
} finally {
store.setBusy(false);
}
}
private handleInputChange(e: Event) {
const target = e.target as HTMLInputElement;
store.setLoginIdentifier(target.value);
}
render() {
const isConnectDisabled = store.busy || !store.loginIdentifier.trim();
const isImportDisabled = store.busy || !store.isAuthenticated;
const isPublishDisabled = store.busy || !store.isAuthenticated || !store.feedGeneratorInfo?.serviceDid;
return html`
<div class="dashboard-grid">
<div class="panel-shell">
<div class="panel">
<div class="panel-header">
<div class="eyebrow">Session</div>
<h1>Bluesky feed dashboard</h1>
</div>
<div class="lede">
OAuth runs in TypeScript using the official atproto browser SDK, while the backend
handles feed tuning and the published feed surface.
</div>
<div class="session-actions">
<div class="form-group">
<label>Bluesky handle</label>
<input
type="text"
.value=${store.loginIdentifier}
@input=${this.handleInputChange}
placeholder="you.bsky.social"
?disabled=${store.busy}
/>
</div>
<button class="accent" @click=${this.connect} ?disabled=${isConnectDisabled}>
${store.isAuthenticated ? "Reconnect" : "Connect with Bluesky"}
</button>
<button class="accent" @click=${this.importFeed} ?disabled=${isImportDisabled}>
Refresh from likes
</button>
</div>
<div class="session-meta">
<span>Status: ${store.sessionStatusText}</span>
<span>PDS: ${store.session.pdsUrl || "not connected"}</span>
<span>Imported authors: ${store.likeFeed.importedAuthorCount}</span>
<span>Imported posts: ${store.likeFeed.importedPostCount}</span>
</div>
<div class="generator-endpoint">
<div>
<div class="eyebrow">Published Feed</div>
<strong>${store.feedGeneratorInfo?.displayName || "Feed generator"}</strong>
<span class="small-copy">
${store.feedGeneratorInfo?.isConfigured
? html`<span>${store.feedGeneratorInfo?.feedUri}</span>`
: null}
</span>
${store.feedGeneratorInfo?.serviceDid
? html`<span class="small-copy">Service DID: ${store.feedGeneratorInfo.serviceDid}</span>`
: null}
</div>
<button class="accent" @click=${this.publishFeed} ?disabled=${isPublishDisabled}>
Publish feed record
</button>
</div>
${store.error ? html`<div class="message-bar error">${store.error}</div>` : null}
${store.status ? html`<div class="message-bar success">${store.status}</div>` : null}
</div>
</div>
</div>
`;
}
}