Skip to content

Commit

Permalink
Merge pull request #966 from cornell-dti/shake-animation-and-storage-…
Browse files Browse the repository at this point in the history
…ideation

Backend logic, shake animation, and frontend draft for course notes
  • Loading branch information
nidhi-mylavarapu authored Dec 3, 2024
2 parents 478b155 + 31c151c commit e60241f
Show file tree
Hide file tree
Showing 7 changed files with 416 additions and 36 deletions.
4 changes: 4 additions & 0 deletions src/assets/images/edit-note.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
148 changes: 133 additions & 15 deletions src/components/Course/Course.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<template>
<div class="course-container">
<div class="course-container" :class="{ 'figma-shake': isShaking }">
<div
:class="{
'course--min': compact,
conflict: isCourseConflict(courseObj.uniqueID),
active: active,
}"
class="course"
:id="`course-${courseObj.uniqueID}`"
>
<save-course-modal
:courseCode="courseCode"
Expand Down Expand Up @@ -88,7 +89,7 @@
:courseCode="courseObj.code"
@open-note-modal="openNoteModal"
@open-edit-color-modal="openEditColorModal"
@delete-course="deleteCourscoe"
@delete-course="deleteCourse"
@edit-course-credit="editCourseCredit"
@open-save-course-modal="openSaveCourseModal"
:getCreditRange="getCreditRange || []"
Expand All @@ -102,14 +103,18 @@
:expandedTranslateY="'-35px'"
:width="'calc(102.8% - 10px)'"
:color="cssVars['--bg-color']"
:expand="expandNote"
@toggle="handleToggleNote"
:initialNote="courseObj.note || ''"
:lastUpdated="courseObj.lastUpdated"
@save-note="saveNote"
@open-delete-note-modal="openDeleteNoteModal"
ref="note"
v-click-outside="handleClickOutsideNote"
/>
</div>
</template>

<script lang="ts">
import { CSSProperties, PropType, defineComponent } from 'vue';
import { PropType, defineComponent } from 'vue';
import CourseMenu from '@/components/Modals/CourseMenu.vue';
import CourseCaution from '@/components/Course/CourseCaution.vue';
import SaveCourseModal from '@/components/Modals/SaveCourseModal.vue';
Expand All @@ -125,6 +130,16 @@ import trashGrayIcon from '@/assets/images/trash-gray.svg';
import trashRedIcon from '@/assets/images/trash.svg';
import Note from '../Notes/Note.vue';
// MinimalNoteComponent is a representation of everything required for a functional,
// but minimal Note component to work statefully.
interface MinimalNoteComponent {
note: string;
isDirty: boolean;
isExpanded: boolean;
collapseNote: () => void;
expandNote: () => void;
}
export default defineComponent({
name: 'Course',
components: { CourseCaution, CourseMenu, EditColor, SaveCourseModal, Note },
Expand Down Expand Up @@ -159,6 +174,9 @@ export default defineComponent({
typeof deletedFromCollection === 'object',
'add-collection': (name: string) => typeof name === 'string',
'delete-course-from-collection': (courseCode: string) => typeof courseCode === 'string',
'save-note': (uniqueID: number, note: string) =>
typeof uniqueID === 'number' && typeof note === 'string',
'open-delete-note-modal': (uniqueID: number) => typeof uniqueID === 'number',
},
data() {
return {
Expand All @@ -172,8 +190,11 @@ export default defineComponent({
trashIcon: trashGrayIcon, // Default icon
courseCode: '',
isExpanded: false,
isNoteVisible: false,
expandNote: false,
// isNotVisible represents a small open 'portrusion' indicating that there is a note
// for the course in question. The note itself will not be visible without `isExpanded`
// being true as well.
isNoteVisible: Boolean(this.courseObj.note),
isShaking: false,
};
},
computed: {
Expand All @@ -196,10 +217,10 @@ export default defineComponent({
return `${this.courseObj.credits} credits`;
},
cssVars(): CSSProperties {
cssVars(): Record<string, string> {
return {
'--bg-color': `#${this.courseObj.color}`,
} as CSSProperties;
};
},
},
methods: {
Expand Down Expand Up @@ -270,21 +291,94 @@ export default defineComponent({
unhoverTrashIcon() {
this.trashIcon = trashGrayIcon;
},
handleToggleNote() {
this.expandNote = !this.expandNote;
},
openNoteModal() {
this.isNoteVisible = true;
if (!this.isNoteVisible) {
this.isNoteVisible = true;
this.menuOpen = false;
// NOTE: should use $nextTick, as the browser engine could optimize away the
// UI update by applying changes in the same render pass. Also important for allowing
// for time for the note component to be rendered before attempting to access it.
this.$nextTick(() => {
const noteComponent = this.$refs.note as MinimalNoteComponent | undefined;
if (noteComponent) {
noteComponent.expandNote();
}
});
} else {
const noteComponent = this.$refs.note as MinimalNoteComponent | undefined;
if (!noteComponent) {
return;
}
// Note already open — trigger a shake to indicate this to the user.
if (noteComponent.isExpanded) {
this.triggerCourseCardShake();
} else {
noteComponent.expandNote();
}
}
this.menuOpen = false;
this.expandNote = true;
},
closeNote() {
this.isNoteVisible = false;
// NOTE: this function hides the note entirely!
// Grant time for the slide-back-in animation to play out.
setTimeout(() => {
this.isNoteVisible = false;
}, 300);
},
triggerCourseCardShake() {
this.isShaking = true;
setTimeout(() => {
this.isShaking = false;
}, 900); // 3 shakes * 0.3s = 0.9s
},
saveNote(note: string) {
if (!note || note === this.courseObj.note) {
return;
}
this.$emit('save-note', this.courseObj.uniqueID, note);
},
handleClickOutsideNote(event: MouseEvent) {
// Don't count a click on the open note (.courseMenu) or three dots (.course-dotRow)
// as a click outside.
const target = event.target as HTMLElement;
if (target.closest('.courseMenu') || target.closest('.course-dotRow')) {
return;
}
const noteComponent = this.$refs.note as MinimalNoteComponent | undefined;
if (!noteComponent || !this.isNoteVisible) {
return;
}
if (noteComponent.isDirty) {
// Warn if the user is trying to leave a note with unsaved changes.
this.triggerCourseCardShake();
} else if (noteComponent.note && this.isNoteVisible) {
noteComponent.collapseNote();
} else {
this.closeNote();
}
},
openDeleteNoteModal() {
this.$emit('open-delete-note-modal', this.courseObj.uniqueID);
},
},
directives: {
'click-outside': clickOutside,
},
watch: {
// NOTE: this is required for reactive deletion of notes client-side after the deletion
// modal is confirmed, as isNoteVisible is not reactive.
'courseObj.note': {
handler(newNote) {
if (!newNote) {
this.isNoteVisible = false;
}
},
immediate: true,
},
},
});
</script>

Expand All @@ -296,6 +390,30 @@ export default defineComponent({
padding-bottom: 20px;
}
// Emulates a slight side-to-side sway à la Figma micro-interaction.
.figma-shake {
animation: tilt-shaking 0.3s cubic-bezier(0.36, 0.07, 0.19, 0.97) 3;
transform-origin: center center;
}
@keyframes tilt-shaking {
0% {
transform: rotate(0deg) translateX(0);
}
25% {
transform: rotate(4deg) translateX(6px);
}
50% {
transform: rotate(0deg) translateX(0);
}
75% {
transform: rotate(-4deg) translateX(-6px);
}
100% {
transform: rotate(0deg) translateX(0);
}
}
.course {
box-shadow: 0px 0px 10px 4px rgba(0, 0, 0, 0.055);
position: relative;
Expand Down
5 changes: 3 additions & 2 deletions src/components/Modals/CourseMenu.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div class="courseMenu">
<div class="courseMenu-content">
<div class="courseMenu-section" @click="openNoteModal(courseObj.code)">
<div class="courseMenu-section" @click="openNoteModal">
<div class="courseMenu-left">
<img
class="courseMenu-icon"
Expand Down Expand Up @@ -185,6 +185,7 @@ export default defineComponent({
'delete-course': () => true,
'open-edit-color-modal': (color: string) => typeof color === 'string',
'edit-course-credit': (credit: number) => typeof credit === 'number',
'open-note-modal': () => true,
},
methods: {
toggleDisplayColors() {
Expand Down Expand Up @@ -260,7 +261,7 @@ export default defineComponent({
}
return creditArray;
},
openNoteModal(courseCode: string) {
openNoteModal() {
this.$emit('open-note-modal');
},
},
Expand Down
66 changes: 66 additions & 0 deletions src/components/Modals/DeleteNoteModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<template>
<teleport-modal
title="Delete Note"
content-class="content-delete"
left-button-text="Cancel"
right-button-text="Delete"
:rightButtonIsDisabled="false"
:rightButtonImage="icon"
rightButtonAlt="delete note trashcan icon"
@modal-closed="closeCurrentModal"
@left-button-clicked="closeCurrentModal"
@right-button-clicked="deleteNote"
>
<div class="deleteNoteModal-body">
<div class="deleteNoteModal-body-text">{{ text }}</div>
</div>
</teleport-modal>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import TeleportModal from '@/components/Modals/TeleportModal.vue';
import trashIcon from '@/assets/images/trash-white.svg';
export default defineComponent({
components: { TeleportModal },
props: {
noteCourseUniqueID: { type: Number, required: true },
},
emits: {
'close-delete-note': () => true,
'delete-note': (noteCourseUniqueID: number) => typeof noteCourseUniqueID === 'number',
},
computed: {
text() {
return 'Are you sure you want to delete this note?';
},
icon() {
return trashIcon;
},
},
methods: {
closeCurrentModal() {
this.$emit('close-delete-note');
},
deleteNote() {
this.$emit('delete-note', this.noteCourseUniqueID);
this.closeCurrentModal();
},
},
});
</script>

<style lang="scss">
@import '@/assets/scss/_variables.scss';
.content-delete {
width: 24rem;
}
@media only screen and (max-width: $small-medium-breakpoint) {
.content-delete {
width: 100%;
}
}
</style>
Loading

0 comments on commit e60241f

Please sign in to comment.