318 lines
14 KiB
Vue
318 lines
14 KiB
Vue
<template>
|
|
<div v-if="campaign">
|
|
<div class="page-header">
|
|
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
|
<router-link to="/campaigns" style="color: var(--color-text-muted); text-decoration: none;">← Campaigns</router-link>
|
|
<span style="color: var(--color-border);">/</span>
|
|
<h1 class="page-title">{{ campaign.name }}</h1>
|
|
<span class="badge badge-info">{{ campaign.product }}</span>
|
|
</div>
|
|
<button class="btn btn-primary" @click="triggerAll" :disabled="triggering">
|
|
{{ triggering ? 'Running...' : '▶ Run All Subs' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="detail-grid">
|
|
<!-- Left: variants + subs -->
|
|
<div>
|
|
<!-- Variants -->
|
|
<div class="card" style="margin-bottom: var(--spacing-lg);">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--spacing-md);">
|
|
<div class="card-title" style="margin: 0;">Content Variants</div>
|
|
<button class="btn btn-ghost btn-sm" @click="showAddVariant = true">+ Variant</button>
|
|
</div>
|
|
<div v-if="variants.length === 0" class="empty-state" style="padding: var(--spacing-md);">
|
|
No variants. Add a default (*) variant to start posting.
|
|
</div>
|
|
<div v-for="v in variants" :key="v.id" class="variant-row">
|
|
<div style="display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: 4px;">
|
|
<code style="font-size: 11px; color: var(--color-primary); background: var(--color-primary-dim); padding: 1px 6px; border-radius: 4px;">{{ v.sub_pattern }}</code>
|
|
<span v-if="v.flair" style="font-size: 11px; color: var(--color-text-muted);">flair: {{ v.flair }}</span>
|
|
<button class="btn btn-ghost btn-sm" style="margin-left: auto;" @click="deleteVariant(v.id)">✕</button>
|
|
</div>
|
|
<div style="font-weight: 500; font-size: 13px; margin-bottom: 2px;">{{ v.title }}</div>
|
|
<div style="color: var(--color-text-muted); font-size: 12px; white-space: pre-wrap; max-height: 60px; overflow: hidden;">{{ v.body }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Subs -->
|
|
<div class="card">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--spacing-md);">
|
|
<div class="card-title" style="margin: 0;">Target Subreddits</div>
|
|
<button class="btn btn-ghost btn-sm" @click="showAddSub = true">+ Sub</button>
|
|
</div>
|
|
<div v-for="s in campaignSubs" :key="s.id" style="display: flex; align-items: center; gap: var(--spacing-sm); padding: 6px 0; border-bottom: 1px solid var(--color-border);">
|
|
<span>r/{{ s.sub }}</span>
|
|
<div style="margin-left: auto; display: flex; gap: 4px;">
|
|
<button class="btn btn-ghost btn-sm" @click="openCopyModal(s.sub)" title="Copy & open Reddit">Copy & Post</button>
|
|
<button class="btn btn-primary btn-sm" @click="triggerSub(s.sub)" :disabled="triggeringSub === s.sub" title="Auto-post via Playwright">
|
|
{{ triggeringSub === s.sub ? '...' : 'Run' }}
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" style="color: var(--color-danger);" @click="removeSub(s.sub)">✕</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="campaignSubs.length === 0" class="empty-state" style="padding: var(--spacing-md);">No subs configured.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: recent posts -->
|
|
<div class="card" style="padding: 0; overflow: hidden; align-self: start;">
|
|
<div class="card-title" style="padding: var(--spacing-md) var(--spacing-md) 0;">Recent Posts</div>
|
|
<table class="table table-responsive">
|
|
<thead>
|
|
<tr>
|
|
<th>Sub</th>
|
|
<th>Status</th>
|
|
<th>When</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="p in recentPosts" :key="p.id">
|
|
<td data-label="Sub">r/{{ p.target }}</td>
|
|
<td data-label="Status">
|
|
<span :class="['status-dot', p.status]"></span>
|
|
<a v-if="p.url" :href="p.url" target="_blank" style="color: var(--color-primary); text-decoration: none;">{{ p.status }}</a>
|
|
<span v-else>{{ p.status }}</span>
|
|
</td>
|
|
<td data-label="When" style="color: var(--color-text-muted); font-size: 12px;">{{ formatDate(p.posted_at) }}</td>
|
|
</tr>
|
|
<tr v-if="recentPosts.length === 0">
|
|
<td colspan="3" style="color: var(--color-text-muted); text-align: center; padding: var(--spacing-lg);">No posts yet.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add variant modal -->
|
|
<div v-if="showAddVariant" class="modal-backdrop" @click.self="showAddVariant = false">
|
|
<div class="modal card" style="width: 600px; max-height: 90vh; overflow-y: auto;">
|
|
<h2 style="margin-bottom: var(--spacing-md); font-size: 16px;">Add Variant</h2>
|
|
<div class="form-group">
|
|
<label class="form-label">Sub pattern <span style="color: var(--color-text-muted)">(* = default, exact sub name, or prefix*)</span></label>
|
|
<input class="form-input" v-model="variantForm.sub_pattern" placeholder="*" style="font-family: var(--font-mono);" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Flair (optional)</label>
|
|
<input class="form-input" v-model="variantForm.flair" placeholder="Action / DIY / Activism" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Title</label>
|
|
<input class="form-input" v-model="variantForm.title" placeholder="Post title..." />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Body</label>
|
|
<textarea class="form-textarea" v-model="variantForm.body" rows="8" placeholder="Post body (markdown)..." style="min-height: 200px;" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Internal notes</label>
|
|
<input class="form-input" v-model="variantForm.notes" placeholder="Framing angle, tone notes..." />
|
|
</div>
|
|
<div style="display: flex; gap: var(--spacing-sm); justify-content: flex-end;">
|
|
<button class="btn btn-ghost" @click="showAddVariant = false">Cancel</button>
|
|
<button class="btn btn-primary" @click="addVariant" :disabled="!variantForm.title || !variantForm.body">Add Variant</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Copy & Open modal -->
|
|
<div v-if="copyModal.sub" class="modal-backdrop" @click.self="copyModal.sub = ''">
|
|
<div class="modal card" style="width: 620px; max-height: 90vh; overflow-y: auto;">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--spacing-md);">
|
|
<h2 style="font-size: 16px; margin: 0;">Post to r/{{ copyModal.sub }}</h2>
|
|
<a :href="copyModal.url" target="_blank" class="btn btn-primary btn-sm">Open Reddit ↗</a>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
|
<label class="form-label" style="margin: 0;">Title</label>
|
|
<button class="btn btn-ghost btn-sm" @click="copy(copyModal.title, 'title')">{{ copied === 'title' ? '✓ Copied' : 'Copy' }}</button>
|
|
</div>
|
|
<input class="form-input" :value="copyModal.title" readonly @click="($event.target as HTMLInputElement).select()" />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
|
<label class="form-label" style="margin: 0;">Body</label>
|
|
<button class="btn btn-ghost btn-sm" @click="copy(copyModal.body, 'body')">{{ copied === 'body' ? '✓ Copied' : 'Copy' }}</button>
|
|
</div>
|
|
<textarea class="form-textarea" :value="copyModal.body" readonly rows="14"
|
|
style="font-family: var(--font-mono); font-size: 12px; resize: vertical;"
|
|
@click="($event.target as HTMLTextAreaElement).select()" />
|
|
</div>
|
|
|
|
<!-- Sub-specific notes (e.g. AI disclosure requirement) -->
|
|
<div v-if="copyModal.notes" class="form-group">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
|
<label class="form-label" style="margin: 0;">Sub notes</label>
|
|
<button class="btn btn-ghost btn-sm" @click="copy(copyModal.notes, 'notes')">{{ copied === 'notes' ? '✓ Copied' : 'Copy' }}</button>
|
|
</div>
|
|
<textarea class="form-textarea" :value="copyModal.notes" readonly rows="3"
|
|
style="font-size: 12px; resize: vertical; color: var(--color-text-muted);"
|
|
@click="($event.target as HTMLTextAreaElement).select()" />
|
|
</div>
|
|
|
|
<div style="color: var(--color-text-muted); font-size: 12px; margin-bottom: var(--spacing-md);">
|
|
1. Copy title → paste into Reddit title field<br>
|
|
2. Copy body → paste into body<br>
|
|
3. Submit on Reddit
|
|
</div>
|
|
|
|
<div style="display: flex; justify-content: flex-end;">
|
|
<button class="btn btn-ghost" @click="copyModal.sub = ''">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add sub modal -->
|
|
<div v-if="showAddSub" class="modal-backdrop" @click.self="showAddSub = false">
|
|
<div class="modal card" style="width: 360px;">
|
|
<h2 style="margin-bottom: var(--spacing-md); font-size: 16px;">Add Subreddit</h2>
|
|
<div class="form-group">
|
|
<label class="form-label">Subreddit name (without r/)</label>
|
|
<input class="form-input" v-model="subForm.sub" placeholder="selfhosted" />
|
|
</div>
|
|
<div style="display: flex; gap: var(--spacing-sm); justify-content: flex-end;">
|
|
<button class="btn btn-ghost" @click="showAddSub = false">Cancel</button>
|
|
<button class="btn btn-primary" @click="addSub" :disabled="!subForm.sub">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="empty-state">Loading campaign...</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onMounted, reactive, ref } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { api, type Campaign, type Variant, type CampaignSub, type Post, type SubRules } from '@/services/api'
|
|
|
|
const route = useRoute()
|
|
const campaignId = Number(route.params.id)
|
|
|
|
const campaign = ref<Campaign | null>(null)
|
|
const variants = ref<Variant[]>([])
|
|
const campaignSubs = ref<CampaignSub[]>([])
|
|
const recentPosts = ref<Post[]>([])
|
|
const subRulesMap = ref<Record<string, SubRules>>({})
|
|
const triggering = ref(false)
|
|
const triggeringSub = ref<string | null>(null)
|
|
const showAddVariant = ref(false)
|
|
const showAddSub = ref(false)
|
|
const copyModal = reactive({ sub: '', title: '', body: '', url: '', notes: '' })
|
|
const copied = ref('')
|
|
|
|
const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
|
|
const subForm = reactive({ sub: '' })
|
|
|
|
onMounted(async () => {
|
|
const [c, v, s, p, allRules] = await Promise.all([
|
|
api.campaigns.get(campaignId),
|
|
api.variants.list(campaignId),
|
|
api.subs.listForCampaign(campaignId),
|
|
api.posts.list(campaignId, undefined, 20),
|
|
api.subs.listRules(),
|
|
])
|
|
campaign.value = c
|
|
variants.value = v
|
|
campaignSubs.value = s
|
|
recentPosts.value = p
|
|
subRulesMap.value = Object.fromEntries(allRules.map(r => [r.sub, r]))
|
|
})
|
|
|
|
async function triggerSub(sub: string) {
|
|
triggeringSub.value = sub
|
|
try {
|
|
await api.posts.trigger(campaignId, sub)
|
|
recentPosts.value = await api.posts.list(campaignId, undefined, 20)
|
|
} finally {
|
|
triggeringSub.value = null
|
|
}
|
|
}
|
|
|
|
async function triggerAll() {
|
|
triggering.value = true
|
|
try {
|
|
await api.campaigns.trigger(campaignId)
|
|
recentPosts.value = await api.posts.list(campaignId, undefined, 20)
|
|
} finally {
|
|
triggering.value = false
|
|
}
|
|
}
|
|
|
|
async function addVariant() {
|
|
const v = await api.variants.create(campaignId, {
|
|
sub_pattern: variantForm.sub_pattern || '*',
|
|
title: variantForm.title,
|
|
body: variantForm.body,
|
|
flair: variantForm.flair || null,
|
|
notes: variantForm.notes || null,
|
|
})
|
|
variants.value = [...variants.value, v]
|
|
showAddVariant.value = false
|
|
Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
|
|
}
|
|
|
|
async function deleteVariant(id: number) {
|
|
await api.variants.delete(campaignId, id)
|
|
variants.value = variants.value.filter(v => v.id !== id)
|
|
}
|
|
|
|
async function addSub() {
|
|
const s = await api.subs.add(campaignId, subForm.sub)
|
|
campaignSubs.value = [...campaignSubs.value, s]
|
|
showAddSub.value = false
|
|
subForm.sub = ''
|
|
}
|
|
|
|
async function removeSub(sub: string) {
|
|
await api.subs.remove(campaignId, sub)
|
|
campaignSubs.value = campaignSubs.value.filter(s => s.sub !== sub)
|
|
}
|
|
|
|
function resolveVariant(sub: string): Variant | null {
|
|
// Exact sub match first, then wildcard — mirrors backend resolve_variant logic
|
|
return (
|
|
variants.value.find(v => v.sub_pattern === sub) ??
|
|
variants.value.find(v => v.sub_pattern === '*') ??
|
|
null
|
|
)
|
|
}
|
|
|
|
function openCopyModal(sub: string) {
|
|
const v = resolveVariant(sub)
|
|
const rules = subRulesMap.value[sub]
|
|
copyModal.sub = sub
|
|
copyModal.title = v?.title ?? ''
|
|
copyModal.body = v?.body ?? ''
|
|
copyModal.url = rules?.post_url ?? `https://www.reddit.com/r/${sub}/submit?type=TEXT`
|
|
copyModal.notes = rules?.notes ?? ''
|
|
copied.value = ''
|
|
}
|
|
|
|
async function copy(text: string, which: string) {
|
|
await navigator.clipboard.writeText(text)
|
|
copied.value = which
|
|
setTimeout(() => { copied.value = '' }, 2000)
|
|
}
|
|
|
|
function formatDate(iso: string) {
|
|
const d = new Date(iso + 'Z')
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
|
.modal { padding: var(--spacing-lg); }
|
|
.variant-row { padding: var(--spacing-sm); border-bottom: 1px solid var(--color-border); }
|
|
.variant-row:last-child { border-bottom: none; }
|
|
.detail-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: var(--spacing-lg);
|
|
}
|
|
@media (max-width: 768px) {
|
|
.detail-grid { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|