Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 170 additions & 4 deletions app/src/main/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,28 @@
<h1 class="text-4xl sm:text-5xl font-bold bg-gradient-to-r from-primary-600 to-secondary-600 bg-clip-text text-transparent mb-2">
Deepr
</h1>
<p class="text-gray-600 text-lg">Your Personal Link Manager</p>
<p class="text-gray-600 text-lg mb-4">Your Personal Link Manager</p>

<!-- Profile Selector -->
<div class="flex items-center justify-center gap-3">
<label for="profileSelect" class="text-sm font-medium text-gray-700">Profile:</label>
<select
id="profileSelect"
class="px-4 py-2 border-2 border-gray-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200 text-gray-800 bg-white"
>
<option value="1">Default</option>
</select>
<button
id="addProfileBtn"
class="bg-gradient-to-r from-primary-500 to-secondary-500 hover:from-primary-600 hover:to-secondary-600 text-white px-4 py-2 rounded-xl font-medium transition-all duration-200 transform hover:scale-105 flex items-center gap-1"
title="Add New Profile"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
New Profile
</button>
</div>
</div>
</header>

Expand Down Expand Up @@ -265,11 +286,51 @@ <h3 class="text-xl font-semibold text-gray-700 mb-2">Loading links...</h3>
</div>
</div>

<!-- Add Profile Modal -->
<div id="addProfileModal" class="fixed inset-0 z-50 hidden">
<div class="absolute inset-0 bg-black bg-opacity-50" onclick="closeAddProfileModal()"></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-2xl shadow-2xl p-6 w-full max-w-md">
<h3 class="text-xl font-semibold text-gray-800 mb-4">Create New Profile</h3>
<form id="addProfileForm">
<div class="mb-4">
<label for="newProfileName" class="block text-sm font-medium text-gray-700 mb-2">
Profile Name
</label>
<input
type="text"
id="newProfileName"
placeholder="Enter profile name..."
required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200 text-gray-800"
>
</div>
<div class="flex gap-3 justify-end">
<button
type="button"
onclick="closeAddProfileModal()"
class="px-6 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-xl transition-all duration-200"
>
Cancel
</button>
<button
type="submit"
id="createProfileBtn"
class="bg-gradient-to-r from-primary-500 to-secondary-500 hover:from-primary-600 hover:to-secondary-600 text-white px-6 py-2 rounded-xl font-semibold transition-all duration-200 transform hover:scale-105"
>
Create Profile
</button>
</div>
</form>
</div>
</div>

<script>
// Global variables to store all links and available tags
let allLinks = [];
let availableTags = []; // Now contains {id, name, count} objects
let selectedTags = []; // Now contains {id, name} objects
let profiles = []; // Contains {id, name, createdAt} objects
let selectedProfileId = 1; // Default profile

// Toast notification system
function showToast(message, type = 'success') {
Expand Down Expand Up @@ -320,6 +381,103 @@ <h3 class="text-xl font-semibold text-gray-700 mb-2">Loading links...</h3>
}
}

// Load profiles from API
async function loadProfiles() {
try {
const response = await fetch('/api/profiles');
profiles = await response.json();
populateProfileSelect();
} catch (error) {
console.error('Error loading profiles:', error);
profiles = [];
}
}

// Populate profile dropdown
function populateProfileSelect() {
const profileSelect = document.getElementById('profileSelect');
profileSelect.innerHTML = profiles.map(profile => `
<option value="${profile.id}" ${profile.id === selectedProfileId ? 'selected' : ''}>
${escapeHtml(profile.name)}
</option>
`).join('');
}
Comment on lines +396 to +404
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

populateProfileSelect() fully replaces the <select> options with profiles. If the backend returns an empty list (or the default profile’s id isn’t 1), the selector becomes empty and selectedProfileId may no longer match any option. Consider keeping a fallback option when profiles.length === 0, and/or updating selectedProfileId to the first returned profile id before rendering so subsequent /api/links?profileId=... requests stay consistent.

Copilot uses AI. Check for mistakes.

// Handle profile selection change
function onProfileChange() {
const profileSelect = document.getElementById('profileSelect');
selectedProfileId = parseInt(profileSelect.value, 10);
loadLinks();
showToast(`Switched to profile: ${profiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'}`, 'info');
}

// Open add profile modal
function openAddProfileModal() {
document.getElementById('addProfileModal').classList.remove('hidden');
document.getElementById('newProfileName').focus();
}

// Close add profile modal
function closeAddProfileModal() {
document.getElementById('addProfileModal').classList.add('hidden');
document.getElementById('newProfileName').value = '';
}

// Create new profile
async function createProfile(event) {
event.preventDefault();

const nameInput = document.getElementById('newProfileName');
const button = document.getElementById('createProfileBtn');
const name = nameInput.value.trim();

if (!name) {
showToast('Please enter a profile name', 'error');
return;
}

const originalText = button.textContent;
button.innerHTML = `
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Creating...
`;
button.disabled = true;

try {
const response = await fetch('/api/profiles', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name })
});

if (response.ok) {
showToast(`Profile "${name}" created successfully!`, 'success');
closeAddProfileModal();
await loadProfiles();
// Select the newly created profile
const newProfile = profiles.find(p => p.name === name);
if (newProfile) {
selectedProfileId = newProfile.id;
populateProfileSelect();
loadLinks();
}
} else {
throw new Error('Failed to create profile');
}
} catch (error) {
console.error('Error creating profile:', error);
showToast('Failed to create profile. Please try again.', 'error');
} finally {
button.textContent = originalText;
button.disabled = false;
}
}

// Tag management functions
function addSelectedTag(tagName) {
// Check if tag is already selected
Expand Down Expand Up @@ -612,7 +770,7 @@ <h3 class="text-lg font-semibold text-gray-800 line-clamp-2 flex-1">

async function loadLinks() {
try {
const response = await fetch('/api/links');
const response = await fetch(`/api/links?profileId=${selectedProfileId}`);
const links = await response.json();

allLinks = links;
Expand Down Expand Up @@ -665,7 +823,8 @@ <h3 class="text-xl font-semibold text-gray-700 mb-2">Error loading links</h3>
link: urlInput.value,
name: nameInput.value,
notes: notesInput.value,
tags: selectedTags // Send tags in {id, name} format
tags: selectedTags, // Send tags in {id, name} format
profileId: selectedProfileId
})
});

Expand Down Expand Up @@ -738,6 +897,11 @@ <h3 class="text-xl font-semibold text-gray-700 mb-2">Error loading links</h3>
document.getElementById('sortOrder').addEventListener('click', toggleSortOrder);
document.getElementById('clearFilters').addEventListener('click', clearAllFilters);

// Profile controls
document.getElementById('profileSelect').addEventListener('change', onProfileChange);
document.getElementById('addProfileBtn').addEventListener('click', openAddProfileModal);
document.getElementById('addProfileForm').addEventListener('submit', createProfile);

// Tag input functionality
const tagInput = document.getElementById('linkTags');
const debouncedShowSuggestions = debounce((input) => showSuggestions(input), 200);
Expand Down Expand Up @@ -768,7 +932,9 @@ <h3 class="text-xl font-semibold text-gray-700 mb-2">Error loading links</h3>

// Initialize
updateSortOrderIcon();
loadLinks();
loadProfiles().then(() => {
loadLinks();
});
loadAvailableTags();
});
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,52 @@ open class LocalServerRepositoryImpl(
}
}

get("/api/profiles") {
try {
val profiles = deeprQueries.getAllProfiles().executeAsList()
val response =
profiles.map { profile ->
ProfileResponse(
id = profile.id,
name = profile.name,
createdAt = profile.createdAt,
)
}
call.respond(HttpStatusCode.OK, response)
Comment on lines +145 to +156
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GET /api/profiles can legitimately return an empty list (e.g., on a fresh DB before the async default-profile initialization completes). Since the UI replaces the <select> options with the returned list, an empty response leaves the profile selector blank. Consider ensuring a default profile exists synchronously before responding, or returning a non-empty list by creating/including a default profile when none exist.

Copilot uses AI. Check for mistakes.
} catch (e: Exception) {
Log.e("LocalServer", "Error getting profiles", e)
call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse("Error getting profiles: ${e.message}"),
)
}
}

post("/api/profiles") {
try {
val request = call.receive<AddProfileRequest>()
deeprQueries.insertProfile(request.name)
call.respond(
HttpStatusCode.Created,
SuccessResponse("Profile created successfully"),
)
Comment on lines +169 to +173
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

POST /api/profiles writes directly via deeprQueries.insertProfile(), which bypasses the usual repository/viewmodel side effects (e.g., auto-backup scheduling in LinkRepositoryImpl.insertProfile() and analytics in AccountViewModel.insertProfile()). Also, because insertProfile is INSERT OR IGNORE (name is UNIQUE), this handler will always return 201 Created even when nothing is inserted; consider returning 409 Conflict/200 OK for existing profiles and returning the created/existing profile (including id) in the response so the UI doesn't have to re-fetch and guess.

Suggested change
deeprQueries.insertProfile(request.name)
call.respond(
HttpStatusCode.Created,
SuccessResponse("Profile created successfully"),
)
// Check if a profile with the same name already exists
val existingProfiles = deeprQueries.getAllProfiles().executeAsList()
val existingProfile = existingProfiles.find { it.name == request.name }
if (existingProfile != null) {
val response =
ProfileResponse(
id = existingProfile.id,
name = existingProfile.name,
createdAt = existingProfile.createdAt,
)
call.respond(HttpStatusCode.OK, response)
} else {
// Use AccountViewModel to ensure side effects (backup, analytics, etc.) are applied
accountViewModel.insertProfile(request.name)
// Fetch the newly created profile
val updatedProfiles = deeprQueries.getAllProfiles().executeAsList()
val createdProfile = updatedProfiles.find { it.name == request.name }
if (createdProfile != null) {
val response =
ProfileResponse(
id = createdProfile.id,
name = createdProfile.name,
createdAt = createdProfile.createdAt,
)
call.respond(HttpStatusCode.Created, response)
} else {
call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse("Profile was not found after creation"),
)
}
}

Copilot uses AI. Check for mistakes.
} catch (e: Exception) {
Log.e("LocalServer", "Error creating profile", e)
call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse("Error creating profile: ${e.message}"),
)
}
}

get("/api/links") {
try {
val profileId =
call.request.queryParameters["profileId"]?.toLongOrNull() ?: 1L
val links =
deeprQueries
.getLinksAndTags(
1L, // Default profile
profileId,
"",
"",
"",
Expand Down Expand Up @@ -192,11 +232,13 @@ open class LocalServerRepositoryImpl(
val request = call.receive<AddLinkRequest>()
// Insert the link without tags first
accountViewModel.insertAccount(
request.link,
request.name,
false,
request.tags.map { it.toDbTag() },
request.notes,
link = request.link,
name = request.name,
executed = false,
tagsList = request.tags.map { it.toDbTag() },
notes = request.notes,
thumbnail = "",
profileId = request.profileId,
)
call.respond(
HttpStatusCode.Created,
Expand Down Expand Up @@ -469,6 +511,19 @@ data class AddLinkRequest(
val name: String,
val notes: String = "",
val tags: List<TagData> = emptyList(),
val profileId: Long = 1L,
)

@Serializable
data class ProfileResponse(
val id: Long,
val name: String,
val createdAt: String,
)

@Serializable
data class AddProfileRequest(
val name: String,
)

@Serializable
Expand Down
Loading