Add job application quick reference for storing profile links and experience
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m34s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m34s
Auth-protected CRUD for personal info (LinkedIn, GitHub, etc.) and experience entries, stored in the database so nothing sensitive is in the public repo. Displayed as a categorized panel on the Job Applications page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -69,3 +69,9 @@ models:
|
|||||||
fields:
|
fields:
|
||||||
deletedAt:
|
deletedAt:
|
||||||
resolver: false
|
resolver: false
|
||||||
|
JobAppReference:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.JobAppReference
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
22
backend/graph/job_app_reference.resolvers.go
Normal file
22
backend/graph/job_app_reference.resolvers.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *jobAppReferenceResolver) ID(ctx context.Context, obj *models.JobAppReference) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobAppReference returns JobAppReferenceResolver implementation.
|
||||||
|
func (r *Resolver) JobAppReference() JobAppReferenceResolver { return &jobAppReferenceResolver{r} }
|
||||||
|
|
||||||
|
type jobAppReferenceResolver struct{ *Resolver }
|
||||||
@@ -30,6 +30,13 @@ type CreateFavoriteInput struct {
|
|||||||
Link *string `json:"link,omitempty"`
|
Link *string `json:"link,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateJobAppReferenceInput struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
SortOrder *int `json:"sortOrder,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreateJobApplicationInput struct {
|
type CreateJobApplicationInput struct {
|
||||||
JobTitle string `json:"jobTitle"`
|
JobTitle string `json:"jobTitle"`
|
||||||
Company string `json:"company"`
|
Company string `json:"company"`
|
||||||
@@ -112,6 +119,13 @@ type SteamStatus struct {
|
|||||||
RecentGames []*SteamGame `json:"recentGames"`
|
RecentGames []*SteamGame `json:"recentGames"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateJobAppReferenceInput struct {
|
||||||
|
Category *string `json:"category,omitempty"`
|
||||||
|
Label *string `json:"label,omitempty"`
|
||||||
|
Value *string `json:"value,omitempty"`
|
||||||
|
SortOrder *int `json:"sortOrder,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type UpdateJobApplicationInput struct {
|
type UpdateJobApplicationInput struct {
|
||||||
JobTitle *string `json:"jobTitle,omitempty"`
|
JobTitle *string `json:"jobTitle,omitempty"`
|
||||||
Company *string `json:"company,omitempty"`
|
Company *string `json:"company,omitempty"`
|
||||||
|
|||||||
@@ -387,6 +387,63 @@ func (r *mutationResolver) DeleteJobApplication(ctx context.Context, id int) (bo
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateJobAppReference is the resolver for the createJobAppReference field.
|
||||||
|
func (r *mutationResolver) CreateJobAppReference(ctx context.Context, input model.CreateJobAppReferenceInput) (*models.JobAppReference, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
ref := models.JobAppReference{
|
||||||
|
Category: input.Category,
|
||||||
|
Label: input.Label,
|
||||||
|
Value: input.Value,
|
||||||
|
}
|
||||||
|
if input.SortOrder != nil {
|
||||||
|
ref.SortOrder = *input.SortOrder
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Create(&ref).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJobAppReference is the resolver for the updateJobAppReference field.
|
||||||
|
func (r *mutationResolver) UpdateJobAppReference(ctx context.Context, id int, input model.UpdateJobAppReferenceInput) (*models.JobAppReference, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var ref models.JobAppReference
|
||||||
|
if err := r.Store.DB.First(&ref, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if input.Category != nil {
|
||||||
|
ref.Category = *input.Category
|
||||||
|
}
|
||||||
|
if input.Label != nil {
|
||||||
|
ref.Label = *input.Label
|
||||||
|
}
|
||||||
|
if input.Value != nil {
|
||||||
|
ref.Value = *input.Value
|
||||||
|
}
|
||||||
|
if input.SortOrder != nil {
|
||||||
|
ref.SortOrder = *input.SortOrder
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Save(&ref).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteJobAppReference is the resolver for the deleteJobAppReference field.
|
||||||
|
func (r *mutationResolver) DeleteJobAppReference(ctx context.Context, id int) (bool, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return false, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Delete(&models.JobAppReference{}, id).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Users is the resolver for the users field.
|
// Users is the resolver for the users field.
|
||||||
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
|
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
|
||||||
var users []models.User
|
var users []models.User
|
||||||
@@ -635,6 +692,18 @@ func (r *queryResolver) JobApplication(ctx context.Context, id int) (*models.Job
|
|||||||
return &app, nil
|
return &app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobAppReferences is the resolver for the jobAppReferences field.
|
||||||
|
func (r *queryResolver) JobAppReferences(ctx context.Context) ([]*models.JobAppReference, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var refs []*models.JobAppReference
|
||||||
|
if err := r.Store.DB.Order("category ASC, sort_order ASC, created_at ASC").Find(&refs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return refs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Mutation returns MutationResolver implementation.
|
// Mutation returns MutationResolver implementation.
|
||||||
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
||||||
|
|
||||||
|
|||||||
23
backend/graph/schema/job_app_reference.graphql
Normal file
23
backend/graph/schema/job_app_reference.graphql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
type JobAppReference {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
category: String!
|
||||||
|
label: String!
|
||||||
|
value: String!
|
||||||
|
sortOrder: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateJobAppReferenceInput {
|
||||||
|
category: String!
|
||||||
|
label: String!
|
||||||
|
value: String!
|
||||||
|
sortOrder: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateJobAppReferenceInput {
|
||||||
|
category: String
|
||||||
|
label: String
|
||||||
|
value: String
|
||||||
|
sortOrder: Int
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ type Query {
|
|||||||
bookmarks: [Bookmark!]!
|
bookmarks: [Bookmark!]!
|
||||||
jobApplications: [JobApplication!]!
|
jobApplications: [JobApplication!]!
|
||||||
jobApplication(id: ID!): JobApplication
|
jobApplication(id: ID!): JobApplication
|
||||||
|
jobAppReferences: [JobAppReference!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@@ -36,4 +37,7 @@ type Mutation {
|
|||||||
createBookmark(input: CreateBookmarkInput!): Bookmark!
|
createBookmark(input: CreateBookmarkInput!): Bookmark!
|
||||||
deleteBookmark(id: ID!): Bookmark!
|
deleteBookmark(id: ID!): Bookmark!
|
||||||
deleteJobApplication(id: ID!): Boolean!
|
deleteJobApplication(id: ID!): Boolean!
|
||||||
|
createJobAppReference(input: CreateJobAppReferenceInput!): JobAppReference!
|
||||||
|
updateJobAppReference(id: ID!, input: UpdateJobAppReferenceInput!): JobAppReference!
|
||||||
|
deleteJobAppReference(id: ID!): Boolean!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ type Bookmark struct {
|
|||||||
Link string `gorm:"not null" json:"link"`
|
Link string `gorm:"not null" json:"link"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type JobAppReference struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
|
Category string `gorm:"not null" json:"category"`
|
||||||
|
Label string `gorm:"not null" json:"label"`
|
||||||
|
Value string `gorm:"not null" json:"value"`
|
||||||
|
SortOrder int `gorm:"default:0" json:"sortOrder"`
|
||||||
|
}
|
||||||
|
|
||||||
type JobApplication struct {
|
type JobApplication struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func migrateDatabase(db *gorm.DB) error {
|
|||||||
&models.Rowing{},
|
&models.Rowing{},
|
||||||
&models.Message{},
|
&models.Message{},
|
||||||
&models.JobApplication{},
|
&models.JobApplication{},
|
||||||
|
&models.JobAppReference{},
|
||||||
&models.Bookmark{},
|
&models.Bookmark{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const form = ref({
|
|||||||
appliedAt: "",
|
appliedAt: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const references = ref([]);
|
||||||
|
const refForm = ref({ category: "profile", label: "", value: "" });
|
||||||
|
const editingRefId = ref(null);
|
||||||
|
const editRefForm = ref({});
|
||||||
|
const REF_CATEGORIES = ["profile", "experience"];
|
||||||
|
const REF_FIELDS = `id category label value sortOrder createdAt`;
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["Applied", "Screening", "Interview", "Offer", "Rejected", "Withdrawn"];
|
const STATUS_OPTIONS = ["Applied", "Screening", "Interview", "Offer", "Rejected", "Withdrawn"];
|
||||||
|
|
||||||
const APP_FIELDS = `id jobTitle company location url status notes appliedAt createdAt`;
|
const APP_FIELDS = `id jobTitle company location url status notes appliedAt createdAt`;
|
||||||
@@ -132,6 +139,87 @@ function exportCsv() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchReferences() {
|
||||||
|
try {
|
||||||
|
const data = await gql(`query { jobAppReferences { ${REF_FIELDS} } }`);
|
||||||
|
references.value = data.jobAppReferences;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createReference() {
|
||||||
|
if (!refForm.value.label || !refForm.value.value) return;
|
||||||
|
try {
|
||||||
|
const input = {
|
||||||
|
category: refForm.value.category,
|
||||||
|
label: refForm.value.label,
|
||||||
|
value: refForm.value.value,
|
||||||
|
};
|
||||||
|
const data = await gql(
|
||||||
|
`mutation CreateJobAppReference($input: CreateJobAppReferenceInput!) {
|
||||||
|
createJobAppReference(input: $input) { ${REF_FIELDS} }
|
||||||
|
}`,
|
||||||
|
{ input },
|
||||||
|
);
|
||||||
|
references.value.push(data.createJobAppReference);
|
||||||
|
refForm.value = { category: refForm.value.category, label: "", value: "" };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRefEdit(ref) {
|
||||||
|
editingRefId.value = ref.id;
|
||||||
|
editRefForm.value = { category: ref.category, label: ref.label, value: ref.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRefEdit() {
|
||||||
|
editingRefId.value = null;
|
||||||
|
editRefForm.value = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRefEdit(id) {
|
||||||
|
try {
|
||||||
|
const input = {
|
||||||
|
category: editRefForm.value.category || undefined,
|
||||||
|
label: editRefForm.value.label || undefined,
|
||||||
|
value: editRefForm.value.value || undefined,
|
||||||
|
};
|
||||||
|
const data = await gql(
|
||||||
|
`mutation UpdateJobAppReference($id: ID!, $input: UpdateJobAppReferenceInput!) {
|
||||||
|
updateJobAppReference(id: $id, input: $input) { ${REF_FIELDS} }
|
||||||
|
}`,
|
||||||
|
{ id, input },
|
||||||
|
);
|
||||||
|
const idx = references.value.findIndex((r) => r.id === id);
|
||||||
|
if (idx !== -1) references.value[idx] = data.updateJobAppReference;
|
||||||
|
editingRefId.value = null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReference(id) {
|
||||||
|
try {
|
||||||
|
await gql(
|
||||||
|
`mutation DeleteJobAppReference($id: ID!) { deleteJobAppReference(id: $id) }`,
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
references.value = references.value.filter((r) => r.id !== id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refsByCategory(category) {
|
||||||
|
return references.value.filter((r) => r.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
function statusClass(status) {
|
function statusClass(status) {
|
||||||
const map = {
|
const map = {
|
||||||
Applied: "status-applied",
|
Applied: "status-applied",
|
||||||
@@ -144,7 +232,10 @@ function statusClass(status) {
|
|||||||
return map[status] ?? "";
|
return map[status] ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchApplications);
|
onMounted(() => {
|
||||||
|
fetchApplications();
|
||||||
|
fetchReferences();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -157,6 +248,40 @@ onMounted(fetchApplications);
|
|||||||
<button class="ja-btn" @click="exportCsv" :disabled="!applications.length">Export CSV</button>
|
<button class="ja-btn" @click="exportCsv" :disabled="!applications.length">Export CSV</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ja-ref-section">
|
||||||
|
<h3 class="ja-ref-heading">Quick Reference</h3>
|
||||||
|
<div v-for="cat in REF_CATEGORIES" :key="cat" class="ja-ref-category">
|
||||||
|
<h4 class="ja-ref-cat-label">{{ cat }}</h4>
|
||||||
|
<div v-for="ref in refsByCategory(cat)" :key="ref.id" class="ja-ref-item">
|
||||||
|
<template v-if="editingRefId !== ref.id">
|
||||||
|
<span class="ja-ref-label">{{ ref.label }}</span>
|
||||||
|
<span class="ja-ref-value" :title="ref.value">{{ ref.value }}</span>
|
||||||
|
<button class="ja-btn ja-btn-sm" @click="copyToClipboard(ref.value)" title="Copy">Copy</button>
|
||||||
|
<button class="ja-btn ja-btn-sm" @click="startRefEdit(ref)">Edit</button>
|
||||||
|
<button class="ja-btn ja-btn-sm ja-btn-danger" @click="deleteReference(ref.id)">Delete</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<select v-model="editRefForm.category" class="ja-input ja-input-sm ja-select">
|
||||||
|
<option v-for="c in REF_CATEGORIES" :key="c" :value="c">{{ c }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="editRefForm.label" class="ja-input ja-input-sm" placeholder="Label" />
|
||||||
|
<input v-model="editRefForm.value" class="ja-input ja-input-sm" placeholder="Value" />
|
||||||
|
<button class="ja-btn ja-btn-sm ja-btn-primary" @click="saveRefEdit(ref.id)">Save</button>
|
||||||
|
<button class="ja-btn ja-btn-sm" @click="cancelRefEdit">Cancel</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p v-if="!refsByCategory(cat).length" class="ja-ref-empty">No {{ cat }} items yet.</p>
|
||||||
|
</div>
|
||||||
|
<form class="ja-ref-form" @submit.prevent="createReference">
|
||||||
|
<select v-model="refForm.category" class="ja-input ja-select">
|
||||||
|
<option v-for="c in REF_CATEGORIES" :key="c" :value="c">{{ c }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="refForm.label" class="ja-input" placeholder="Label *" required />
|
||||||
|
<input v-model="refForm.value" class="ja-input" placeholder="Value *" required />
|
||||||
|
<button type="submit" class="ja-btn ja-btn-primary">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="ja-form" @submit.prevent="createApplication">
|
<form class="ja-form" @submit.prevent="createApplication">
|
||||||
<div class="ja-form-row">
|
<div class="ja-form-row">
|
||||||
<input v-model="form.jobTitle" class="ja-input" placeholder="Job title *" required />
|
<input v-model="form.jobTitle" class="ja-input" placeholder="Job title *" required />
|
||||||
@@ -429,4 +554,67 @@ onMounted(fetchApplications);
|
|||||||
color: #888;
|
color: #888;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ja-ref-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-ref-heading {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-ref-category {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-ref-cat-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
text-transform: capitalize;
|
||||||
|
margin: 0 0 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-ref-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-ref-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-ref-value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #555;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-ref-empty {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-ref-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user