add in update and ai

This commit is contained in:
Nathan 2026-02-09 16:45:28 -05:00
parent 22b8c354df
commit 1638d07b03
13 changed files with 306 additions and 14 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE "projects" ADD COLUMN "is_update" integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "update_description" text;

View file

@ -0,0 +1,2 @@
ALTER TABLE "projects" DROP COLUMN IF EXISTS "is_update";--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "ai_description" text;

View file

@ -8,6 +8,20 @@
"when": 1770422400000,
"tag": "0002_feedback_and_activity",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1770508800000,
"tag": "0003_add_project_update_fields",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1770595200000,
"tag": "0004_remove_is_update_add_ai_description",
"breakpoints": true
}
]
}

View file

@ -42,6 +42,8 @@ async function syncProjectsToAirtable(): Promise<void> {
hoursOverride: projectsTable.hoursOverride,
tier: projectsTable.tier,
status: projectsTable.status,
updateDescription: projectsTable.updateDescription,
aiDescription: projectsTable.aiDescription,
feedbackSource: projectsTable.feedbackSource,
feedbackGood: projectsTable.feedbackGood,
feedbackImprove: projectsTable.feedbackImprove,
@ -117,9 +119,17 @@ async function syncProjectsToAirtable(): Promise<void> {
const firstName = userInfo?.given_name || (project.username || '').split(' ')[0] || ''
const lastName = userInfo?.family_name || (project.username || '').split(' ').slice(1).join(' ') || ''
const descriptionParts = [project.description || '']
if (project.updateDescription) {
descriptionParts.push(`\nThis project is an update. ${project.updateDescription}`)
}
if (project.aiDescription) {
descriptionParts.push(`\nAI was used in this project. ${project.aiDescription}`)
}
const fields: Airtable.FieldSet = {
'Code URL': project.githubUrl,
'Description': project.description || '',
'Description': descriptionParts.join('\n'),
'Email': project.email || '',
'First Name': firstName,
'Last Name': lastName,

View file

@ -392,6 +392,9 @@ projects.get('/:id', async ({ params, headers }) => {
status: project[0].status,
scrapsAwarded: project[0].scrapsAwarded,
views: project[0].views,
updateDescription: project[0].updateDescription,
aiDescription: isOwner ? project[0].aiDescription : undefined,
usedAi: !!project[0].aiDescription,
createdAt: project[0].createdAt,
updatedAt: project[0].updatedAt
},
@ -413,6 +416,8 @@ projects.post('/', async ({ body, headers }) => {
githubUrl?: string
hackatimeProject?: string
tier?: number
updateDescription?: string
aiDescription?: string
}
if (!validateImageUrl(data.image)) {
@ -437,7 +442,9 @@ projects.post('/', async ({ body, headers }) => {
githubUrl: data.githubUrl || null,
hackatimeProject: projectName || null,
hours: 0,
tier
tier,
updateDescription: data.updateDescription || null,
aiDescription: data.aiDescription || null
})
.returning()
@ -482,6 +489,8 @@ projects.put('/:id', async ({ params, body, headers }) => {
playableUrl?: string | null
hackatimeProject?: string | null
tier?: number
updateDescription?: string | null
aiDescription?: string | null
}
if (!validateImageUrl(data.image)) {
@ -511,6 +520,8 @@ projects.put('/:id', async ({ params, body, headers }) => {
playableUrl: data.playableUrl,
hackatimeProject: projectName,
tier,
updateDescription: data.updateDescription !== undefined ? (data.updateDescription || null) : undefined,
aiDescription: data.aiDescription !== undefined ? (data.aiDescription || null) : undefined,
updatedAt: new Date()
})
.where(and(eq(projectsTable.id, parseInt(params.id)), eq(projectsTable.userId, user.id)))

View file

@ -22,6 +22,12 @@ export const projectsTable = pgTable('projects', {
scrapsAwarded: integer('scraps_awarded').notNull().default(0),
views: integer().notNull().default(0),
// Update fields
updateDescription: text('update_description'),
// AI usage fields
aiDescription: text('ai_description'),
// Feedback fields (filled on submission)
feedbackSource: text('feedback_source'),
feedbackGood: text('feedback_good'),

View file

@ -60,6 +60,10 @@
let loading = $state(false);
let error = $state<string | null>(null);
let selectedTier = $state(1);
let isUpdate = $state(false);
let updateDescription = $state('');
let usedAi = $state(false);
let aiDescription = $state('');
const TIERS = [
{ value: 1, description: $t.createProject.tierDescriptions.tier1 },
@ -78,7 +82,9 @@
description.trim().length >= DESC_MIN && description.trim().length <= DESC_MAX
);
let hasName = $derived(name.trim().length > 0 && name.trim().length <= NAME_MAX);
let allRequirementsMet = $derived(hasDescription && hasName);
let updateValid = $derived(!isUpdate || updateDescription.trim().length > 0);
let aiValid = $derived(!usedAi || aiDescription.trim().length > 0);
let allRequirementsMet = $derived(hasDescription && hasName && updateValid && aiValid);
async function fetchHackatimeProjects() {
loadingProjects = true;
@ -173,6 +179,10 @@
selectedHackatimeProjects = [];
showDropdown = false;
selectedTier = 1;
isUpdate = false;
updateDescription = '';
usedAi = false;
aiDescription = '';
error = null;
}
@ -201,7 +211,9 @@
image: imageUrl || null,
githubUrl: finalGithubUrl,
hackatimeProject: hackatimeValue,
tier: selectedTier
tier: selectedTier,
updateDescription: isUpdate ? updateDescription : null,
aiDescription: usedAi ? aiDescription : null
})
});
@ -465,6 +477,66 @@
</div>
</div>
<!-- Is Update Checkbox -->
<div>
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
bind:checked={isUpdate}
class="h-5 w-5 cursor-pointer accent-black"
/>
<span class="text-sm font-bold">{$t.createProject.isUpdateLabel}</span>
</label>
</div>
{#if isUpdate}
<div>
<label for="updateDescription" class="mb-1 block text-sm font-bold"
>{$t.createProject.whatDidYouUpdate} <span class="text-red-500">*</span></label
>
<textarea
id="updateDescription"
bind:value={updateDescription}
rows="3"
placeholder={$t.createProject.updateDescriptionPlaceholder}
class="w-full resize-none rounded-lg border-2 border-black px-4 py-2 focus:border-dashed focus:outline-none"
></textarea>
{#if updateDescription.trim().length === 0}
<p class="mt-1 text-xs text-red-500">{$t.createProject.pleaseDescribeUpdate}</p>
{/if}
</div>
{/if}
<!-- AI Usage Checkbox -->
<div>
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
bind:checked={usedAi}
class="h-5 w-5 cursor-pointer accent-black"
/>
<span class="text-sm font-bold">{$t.createProject.usedAiLabel}</span>
</label>
</div>
{#if usedAi}
<div>
<label for="aiDescription" class="mb-1 block text-sm font-bold"
>{$t.createProject.howWasAiUsed} <span class="text-red-500">*</span></label
>
<textarea
id="aiDescription"
bind:value={aiDescription}
rows="3"
placeholder={$t.createProject.aiDescriptionPlaceholder}
class="w-full resize-none rounded-lg border-2 border-black px-4 py-2 focus:border-dashed focus:outline-none"
></textarea>
{#if aiDescription.trim().length === 0}
<p class="mt-1 text-xs text-red-500">{$t.createProject.pleaseDescribeAiUsage}</p>
{/if}
</div>
{/if}
<!-- Requirements Checklist -->
<div class="rounded-lg border-2 border-black p-4">
<p class="mb-3 font-bold">{$t.createProject.requirements}</p>

View file

@ -269,7 +269,15 @@ export default {
tier2: 'moderate complexity, multi-file projects',
tier3: 'complex features, APIs, integrations',
tier4: 'full applications, major undertakings'
}
},
isUpdateLabel: 'this project is an update to a previous project',
whatDidYouUpdate: 'what did you update?',
updateDescriptionPlaceholder: 'Describe what you changed or improved in this update...',
pleaseDescribeUpdate: 'Please describe what you updated',
usedAiLabel: 'AI was used in this project',
howWasAiUsed: 'how was AI used?',
aiDescriptionPlaceholder: 'Describe how AI was used in this project...',
pleaseDescribeAiUsage: 'Please describe how AI was used'
},
address: {
shippingAddress: 'shipping address',
@ -374,7 +382,17 @@ export default {
unsubmitConfirmTitle: 'unsubmit project?',
unsubmitConfirmMessage: 'this will remove your project from the review queue and return it to in-progress status.',
unsubmitting: 'unsubmitting...',
unsubmitSuccess: 'project unsubmitted successfully'
unsubmitSuccess: 'project unsubmitted successfully',
isUpdateLabel: 'this project is an update to a previous project',
whatDidYouUpdate: 'what did you update?',
updateDescriptionPlaceholder: 'Describe what you changed or improved in this update...',
pleaseDescribeUpdate: 'Please describe what you updated',
whatWasUpdated: 'what was updated',
usedAiLabel: 'AI was used in this project',
howWasAiUsed: 'how was AI used?',
aiDescriptionPlaceholder: 'Describe how AI was used in this project...',
pleaseDescribeAiUsage: 'Please describe how AI was used',
aiWasUsed: 'AI was used in this project'
},
faq: {
title: 'faq',

View file

@ -269,7 +269,15 @@ export default {
tier2: 'complejidad moderada, proyectos multi-archivo',
tier3: 'características complejas, APIs, integraciones',
tier4: 'aplicaciones completas, emprendimientos mayores'
}
},
isUpdateLabel: 'este proyecto es una actualización de un proyecto anterior',
whatDidYouUpdate: '¿qué actualizaste?',
updateDescriptionPlaceholder: 'Describe qué cambiaste o mejoraste en esta actualización...',
pleaseDescribeUpdate: 'Por favor describe qué actualizaste',
usedAiLabel: 'se usó IA en este proyecto',
howWasAiUsed: '¿cómo se usó la IA?',
aiDescriptionPlaceholder: 'Describe cómo se usó la IA en este proyecto...',
pleaseDescribeAiUsage: 'Por favor describe cómo se usó la IA'
},
address: {
shippingAddress: 'dirección de envío',
@ -414,7 +422,17 @@ export default {
unsubmitConfirmTitle: '¿retirar proyecto?',
unsubmitConfirmMessage: 'esto eliminará tu proyecto de la cola de revisión y lo devolverá al estado en progreso.',
unsubmitting: 'retirando...',
unsubmitSuccess: 'proyecto retirado exitosamente'
unsubmitSuccess: 'proyecto retirado exitosamente',
isUpdateLabel: 'este proyecto es una actualización de un proyecto anterior',
whatDidYouUpdate: '¿qué actualizaste?',
updateDescriptionPlaceholder: 'Describe qué cambiaste o mejoraste en esta actualización...',
pleaseDescribeUpdate: 'Por favor describe qué actualizaste',
whatWasUpdated: 'qué se actualizó',
usedAiLabel: 'se usó IA en este proyecto',
howWasAiUsed: '¿cómo se usó la IA?',
aiDescriptionPlaceholder: 'Describe cómo se usó la IA en este proyecto...',
pleaseDescribeAiUsage: 'Por favor describe cómo se usó la IA',
aiWasUsed: 'se usó IA en este proyecto'
},
profile: {
backToLeaderboard: 'volver a clasificación',

View file

@ -12,7 +12,8 @@
XCircle,
Info,
Globe,
RefreshCw
RefreshCw,
Bot
} from '@lucide/svelte';
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte';
import { getUser } from '$lib/auth-client';
@ -57,6 +58,8 @@
feedbackSource: string | null;
feedbackGood: string | null;
feedbackImprove: string | null;
updateDescription: string | null;
aiDescription: string | null;
}
interface User {
@ -343,6 +346,24 @@
</span>
</div>
<p class="mb-4 text-gray-600">{project.description}</p>
{#if project.updateDescription}
<div class="mb-4 rounded-lg border-2 border-dashed border-gray-400 bg-gray-50 p-4">
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-gray-600">
<RefreshCw size={14} />
{$t.project.whatWasUpdated}
</p>
<p class="text-gray-700">{project.updateDescription}</p>
</div>
{/if}
{#if project.aiDescription}
<div class="mb-4 rounded-lg border-2 border-dashed border-purple-400 bg-purple-50 p-4">
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-purple-600">
<Bot size={14} />
{$t.project.aiWasUsed}
</p>
<p class="text-purple-700">{project.aiDescription}</p>
</div>
{/if}
<div class="flex flex-wrap items-center gap-3 text-sm">
<span class="rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold"
>{formatHours(project.hours)}h logged</span

View file

@ -17,7 +17,8 @@
Spool,
Eye,
RefreshCw,
Undo2
Undo2,
Bot
} from '@lucide/svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
@ -42,6 +43,8 @@
status: string;
scrapsAwarded: number;
views: number;
updateDescription: string | null;
usedAi: boolean;
createdAt: string;
updatedAt: string;
}
@ -278,12 +281,20 @@
</span>
{/if}
</div>
<div class="mb-4 flex items-center gap-2">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span
class="rounded-full border-2 border-gray-400 bg-gray-100 px-3 py-1 text-sm font-bold text-gray-700"
>
{$t.project.tier.replace('{value}', String(project.tier))}
</span>
{#if project.usedAi}
<span
class="flex items-center gap-1 rounded-full border-2 border-purple-400 bg-purple-100 px-3 py-1 text-sm font-bold text-purple-700"
>
<Bot size={14} />
{$t.project.aiWasUsed}
</span>
{/if}
</div>
{#if project.description}
@ -292,6 +303,16 @@
<p class="mb-4 text-lg text-gray-400 italic">{$t.project.noDescriptionYet}</p>
{/if}
{#if project.updateDescription}
<div class="mb-4 rounded-lg border-2 border-dashed border-gray-400 bg-gray-50 p-4">
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-gray-600">
<RefreshCw size={14} />
{$t.project.whatWasUpdated}
</p>
<p class="text-gray-700">{project.updateDescription}</p>
</div>
{/if}
<div class="mb-3 flex flex-wrap items-center gap-3">
<span
class="flex items-center gap-2 rounded-full border-4 border-black bg-white px-4 py-2 font-bold"

View file

@ -22,6 +22,8 @@
hours: number;
tier: number;
status: string;
updateDescription: string | null;
aiDescription: string | null;
}
const TIERS = [
@ -52,6 +54,10 @@
let loadingProjects = $state(false);
let showDropdown = $state(false);
let selectedTier = $state(1);
let isUpdate = $state(false);
let updateDescription = $state('');
let usedAi = $state(false);
let aiDescription = $state('');
const NAME_MAX = 50;
const DESC_MIN = 20;
@ -66,7 +72,9 @@
);
let githubValidation = $derived(validateGithubUrl(project?.githubUrl));
let playableValidation = $derived(validatePlayableUrl(project?.playableUrl));
let canSave = $derived(hasDescription && hasName && githubValidation.valid && playableValidation.valid);
let updateValid = $derived(!isUpdate || updateDescription.trim().length > 0);
let aiValid = $derived(!usedAi || aiDescription.trim().length > 0);
let canSave = $derived(hasDescription && hasName && githubValidation.valid && playableValidation.valid && updateValid && aiValid);
onMount(async () => {
const user = await getUser();
@ -89,6 +97,10 @@
project = responseData.project;
imagePreview = project?.image || null;
selectedTier = project?.tier || 1;
isUpdate = !!(project?.updateDescription);
updateDescription = project?.updateDescription || '';
usedAi = !!(project?.aiDescription);
aiDescription = project?.aiDescription || '';
if (project?.hackatimeProject) {
selectedHackatimeNames = project.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0);
}
@ -216,7 +228,9 @@
githubUrl: project.githubUrl,
playableUrl: project.playableUrl,
hackatimeProject: hackatimeValue,
tier: selectedTier
tier: selectedTier,
updateDescription: isUpdate ? updateDescription : null,
aiDescription: usedAi ? aiDescription : null
})
});
@ -505,6 +519,66 @@
{/each}
</div>
</div>
<!-- Is Update Checkbox -->
<div>
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
bind:checked={isUpdate}
class="h-5 w-5 cursor-pointer accent-black"
/>
<span class="text-sm font-bold">{$t.project.isUpdateLabel}</span>
</label>
</div>
{#if isUpdate}
<div>
<label for="updateDescription" class="mb-2 block text-sm font-bold"
>{$t.project.whatDidYouUpdate} <span class="text-red-500">*</span></label
>
<textarea
id="updateDescription"
bind:value={updateDescription}
rows="3"
placeholder={$t.project.updateDescriptionPlaceholder}
class="w-full resize-none rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
></textarea>
{#if updateDescription.trim().length === 0}
<p class="mt-1 text-xs text-red-500">{$t.project.pleaseDescribeUpdate}</p>
{/if}
</div>
{/if}
<!-- AI Usage Checkbox -->
<div>
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
bind:checked={usedAi}
class="h-5 w-5 cursor-pointer accent-black"
/>
<span class="text-sm font-bold">{$t.project.usedAiLabel}</span>
</label>
</div>
{#if usedAi}
<div>
<label for="aiDescription" class="mb-2 block text-sm font-bold"
>{$t.project.howWasAiUsed} <span class="text-red-500">*</span></label
>
<textarea
id="aiDescription"
bind:value={aiDescription}
rows="3"
placeholder={$t.project.aiDescriptionPlaceholder}
class="w-full resize-none rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
></textarea>
{#if aiDescription.trim().length === 0}
<p class="mt-1 text-xs text-red-500">{$t.project.pleaseDescribeAiUsage}</p>
{/if}
</div>
{/if}
</div>
<!-- Actions -->

View file

@ -7,7 +7,9 @@
Clock,
CheckCircle,
AlertTriangle,
Package
Package,
RefreshCw,
Bot
} from '@lucide/svelte';
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
@ -26,6 +28,8 @@
playableUrl: string | null;
hours: number;
status: string;
updateDescription: string | null;
usedAi: boolean;
createdAt: string;
}
@ -131,6 +135,25 @@
{/if}
</div>
<p class="mb-4 text-gray-600">{project.description}</p>
{#if project.updateDescription}
<div class="mb-4 rounded-lg border-2 border-dashed border-gray-400 bg-gray-50 p-4">
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-gray-600">
<RefreshCw size={14} />
{$t.project.whatWasUpdated}
</p>
<p class="text-gray-700">{project.updateDescription}</p>
</div>
{/if}
<div class="mb-4 flex flex-wrap items-center gap-2">
{#if project.usedAi}
<span
class="flex items-center gap-1 rounded-full border-2 border-purple-400 bg-purple-100 px-3 py-1 text-sm font-bold text-purple-700"
>
<Bot size={14} />
{$t.project.aiWasUsed}
</span>
{/if}
</div>
<div class="flex flex-wrap items-center gap-3 text-sm">
<span
class="flex items-center gap-1 rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold"